diff --git a/.env b/.env new file mode 100644 index 0000000..758a46d --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +SRC_URL=https://... +OUT_FILE=/downloads/video.mp4 \ No newline at end of file diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..d10f746 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,123 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + tags: + - '*' + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver-opts: image=moby/buildkit:latest + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ github.repository_owner }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Clone repo to build + run: git clone https://github.com/${{ github.repository }}.git repo + + - name: Build and push image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ github.repository }}:${{ github.ref_name }} + ghcr.io/${{ github.repository }}:${{ github.ref_name }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Push latest image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ github.repository }}:latest + ghcr.io/${{ github.repository }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + if: startsWith(github.ref, 'refs/tags/') + + - name: Docker Hub Description + uses: peter-evans/dockerhub-description@v4 + with: + username: ${{ github.repository_owner }} + password: ${{ secrets.DOCKER_PASSWORD }} + repository: ${{ github.repository }} + short-description: ${{ github.event.repository.description }} + enable-url-completion: true + + create-release: + runs-on: ubuntu-latest + needs: build-and-push + permissions: write-all + if: startsWith(github.ref, 'refs/tags/') + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Update CHANGELOG + id: changelog + uses: requarks/changelog-action@v1 + with: + token: ${{ github.token }} + tag: ${{ github.ref_name }} + + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref_name }} + release_name: v${{ github.ref_name }} + body: | + Container images for this release: + - Docker Hub: `${{ github.repository }}:${{ github.ref_name }}` + - GitHub Container Registry: `ghcr.io/${{ github.repository }}:${{ github.ref_name }}` + ${{ steps.changelog.outputs.changes }} + draft: false + prerelease: false + + - name: Commit CHANGELOG.md + uses: stefanzweifel/git-auto-commit-action@v4 + with: + branch: main + commit_message: 'docs: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]' + file_pattern: CHANGELOG.md + + - name: Docker Hub Description + uses: peter-evans/dockerhub-description@v4 + with: + username: ${{ github.repository_owner }} + password: ${{ secrets.DOCKER_PASSWORD }} + repository: ${{ github.repository }} + short-description: ${{ github.event.repository.description }} + enable-url-completion: true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..55e450b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python +RUN apt-get update && apt-get install -y \ + youtube-dl \ + && ln -s /usr/bin/yt-dlp /usr/local/bin/youtube-dl \ + && rm -rf /var/lib/apt/lists/* +RUN python -m pip install requests tqdm moviepy ffmpeg-python +COPY video.py /video.py +VOLUME /downloads +WORKDIR /downloads +ENTRYPOINT ["python"] +CMD ["/video.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce3b009 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# vimeo-dl + +A container image based on [Javi3rV script](https://gist.github.com/alexeygrigorev/a1bc540925054b71e1a7268e50ad55cd?permalink_comment_id=5279414#gistcomment-5279414) to download segmented videos from vimeo. +It supports playlist.json and master.json urls. + + +## Example usage + +### From docker CLI + +```bash +docker run \ + -e 'SRC_URL=https://...' \ + -e 'OUT_FILE=/downloads/video.mp4' \ + -v $(pwd)/out:/downloads \ + --rm -it davidecavestro/vimeo-dl +``` + +### From docker compose + +```yaml +version: "3" + +services: + downloader: + build: + context: . + volumes: + - ./out:/downloads + environment: + - SRC_URL=${SRC_URL} + - OUT_FILE=${OUT_FILE} + - MAX_WORKERS=${MAX_WORKERS} +``` +passing the url from `.env` file +```.env +SRC_URL=https://... +OUT_FILE=/downloads/video.mp4 +MAX_WORKERS=5 +``` + + +## Image project home + +https://github.com/davidecavestro/vimeo-dl + + +## Disclaimer + +This software is released just for educational purposes. +**Please do not use it for illegal activities.** + +## Credits + +Entirely based on [alexeygrigorev](https://github.com/alexeygrigorev)'s [vimeo-download.py gist](https://gist.github.com/alexeygrigorev/a1bc540925054b71e1a7268e50ad55cd) and refining comments, just with some minor tweaks. diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..ac894d6 --- /dev/null +++ b/compose.yml @@ -0,0 +1,10 @@ +services: + downloader: +# build: +# context: . + image: davidecavestro/vimeo-dl + volumes: + - ./out:/downloads + environment: + - SRC_URL=${SRC_URL} + - OUT_FILE=${OUT_FILE} diff --git a/video.py b/video.py new file mode 100644 index 0000000..bf98b5a --- /dev/null +++ b/video.py @@ -0,0 +1,88 @@ +import os +import sys +import base64 +import requests +import subprocess +from concurrent.futures import ThreadPoolExecutor + +from tqdm import tqdm +from moviepy.editor import * +import ffmpeg + + +url = os.getenv("SRC_URL") or input('enter [master|playlist].json url: ') +name = os.getenv("OUT_FILE") or input('enter output name: ') +max_workers = min(int(os.getenv("MAX_WORKERS", 5)), 15) + +if 'master.json' in url: + url = url[:url.find('?')] + '?query_string_ranges=1' + url = url.replace('master.json', 'master.mpd') + print(url) + subprocess.run(['youtube-dl', url, '-o', name]) + sys.exit(0) + + +def download_segment(segment_url, segment_path): + resp = requests.get(segment_url, stream=True) + if resp.status_code != 200: + print('not 200!') + print(segment_url) + return + with open(segment_path, 'wb') as segment_file: + for chunk in resp: + segment_file.write(chunk) + +def download(what, to, base, max_workers): + print('saving', what['mime_type'], 'to', to) + init_segment = base64.b64decode(what['init_segment']) + + segment_urls = [base + segment['url'] for segment in what['segments']] + segment_paths = [f"segment_{i}.tmp" for i in range(len(segment_urls))] + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + list(tqdm(executor.map(download_segment, segment_urls, segment_paths), total=len(segment_urls))) + + with open(to, 'wb') as file: + file.write(init_segment) + for segment_path in segment_paths: + with open(segment_path, 'rb') as segment_file: + file.write(segment_file.read()) + os.remove(segment_path) + + print('done') + + +base_url = url[:url.rfind('/', 0, -26) + 1] +content = requests.get(url).json() + +vid_heights = [(i, d['height']) for (i, d) in enumerate(content['video'])] +vid_idx, _ = max(vid_heights, key=lambda _h: _h[1]) + +audio_quality = [(i, d['bitrate']) for (i, d) in enumerate(content['audio'])] +audio_idx, _ = max(audio_quality, key=lambda _h: _h[1]) + +video = content['video'][vid_idx] +audio = content['audio'][audio_idx] +base_url = base_url + content['base_url'] + +video_tmp_file = 'video.mp4' +audio_tmp_file = 'audio.mp4' + +download(video, video_tmp_file, base_url + video['base_url'], max_workers) +download(audio, audio_tmp_file, base_url + audio['base_url'], max_workers) + +def combine_video_audio(video_file, audio_file, output_file): + try: + video_stream = ffmpeg.input(video_file) + audio_stream = ffmpeg.input(audio_file) + + ffmpeg.output(video_stream, audio_stream, output_file, vcodec='copy', acodec='copy').run(overwrite_output=True) + + print(f"Fragments joined into {output_file}") + except ffmpeg.Error as e: + print(f"Cannot join fragments: {e.stderr.decode()}") + +combine_video_audio('video.mp4', 'audio.mp4', name) + +os.remove(video_tmp_file) +os.remove(audio_tmp_file)