diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..526c8a3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf \ No newline at end of file diff --git a/.gitea/workflows/docker-build.yaml b/.gitea/workflows/docker-build.yaml index 2fafa07..e41de5f 100644 --- a/.gitea/workflows/docker-build.yaml +++ b/.gitea/workflows/docker-build.yaml @@ -1,72 +1,72 @@ -name: push new builds to gitea, dockerhub - -on: - push: - branches: ["master"] - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Get youdis version - id: youdis - run: | - YOUDIS_VER=$(cat youdis-version.txt) - if [ -z "$YOUDIS_VER" ]; then - echo "youdis version empty" >&2 - exit 1 - fi - echo "youdis-version=$YOUDIS_VER" >> $GITHUB_OUTPUT - echo "shortsha=${GITHUB_SHA::5}" >> $GITHUB_OUTPUT - - - name: Decide if build needed - id: check_build - run: | - SHOULD_BUILD=false - if [ "${{ steps.youdis.outputs.youdis-version }}" != "$(docker inspect --format='{{ index .Config.Labels "YOUDIS_VERSION" }}' eulaly/youdis:latest || echo none)" ]; then - SHOULD_BUILD=true - fi - echo "should_build=$SHOULD_BUILD" >> $GITHUB_OUTPUT - - - name: Login to Dockerhub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and push to Dockerhub - if: ${{ steps.check_build.outputs.should_build == 'true' }} - uses: docker/build-push-action@v5 - with: - push: true - tags: | - eulaly/youdis:latest - eulaly/youdis:${{ steps.youdis.outputs.shortsha }} - labels: | - YOUDIS_VERSION=${{ steps.youdis.outputs.youdis-version }} - - - name: Login to Gitea - uses: docker/login-action@v3 - with: - registry: git.hgsky.me - username: ${{ secrets.USERNAME }} - password: ${{ secrets.YOUDIS_PASSWORD }} - - - name: Build and push to Gitea - if: ${{ steps.check_build.outputs.should_build == 'true' }} - uses: docker/build-push-action@v5 - with: - push: true - tags: | - git.hgsky.me/ben/youdis:latest - git.hgsky.me/ben/youdis:${{ steps.youdis.outputs.shortsha }} - labels: | - YOUDIS_VERSION=${{ steps.youdis.outputs.youdis-version }} - - - name: link container in gitea - run: | - curl -X POST -H "Authorization: token ${{ secrets.SUPER }}" https://git.hgsky.me/api/v1/packages/ben/container/youdis/-/link/youdis - +name: push new builds to gitea, dockerhub + +on: + push: + branches: ["master"] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Get youdis version + id: youdis + run: | + YOUDIS_VER=$(cat youdis-version.txt) + if [ -z "$YOUDIS_VER" ]; then + echo "youdis version empty" >&2 + exit 1 + fi + echo "youdis-version=$YOUDIS_VER" >> $GITHUB_OUTPUT + echo "shortsha=${GITHUB_SHA::5}" >> $GITHUB_OUTPUT + + - name: Decide if build needed + id: check_build + run: | + SHOULD_BUILD=false + if [ "${{ steps.youdis.outputs.youdis-version }}" != "$(docker inspect --format='{{ index .Config.Labels "YOUDIS_VERSION" }}' eulaly/youdis:latest || echo none)" ]; then + SHOULD_BUILD=true + fi + echo "should_build=$SHOULD_BUILD" >> $GITHUB_OUTPUT + + - name: Login to Dockerhub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push to Dockerhub + if: ${{ steps.check_build.outputs.should_build == 'true' }} + uses: docker/build-push-action@v5 + with: + push: true + tags: | + eulaly/youdis:latest + eulaly/youdis:${{ steps.youdis.outputs.shortsha }} + labels: | + YOUDIS_VERSION=${{ steps.youdis.outputs.youdis-version }} + + - name: Login to Gitea + uses: docker/login-action@v3 + with: + registry: git.hgsky.me + username: ${{ secrets.USERNAME }} + password: ${{ secrets.YOUDIS_PASSWORD }} + + - name: Build and push to Gitea + if: ${{ steps.check_build.outputs.should_build == 'true' }} + uses: docker/build-push-action@v5 + with: + push: true + tags: | + git.hgsky.me/ben/youdis:latest + git.hgsky.me/ben/youdis:${{ steps.youdis.outputs.shortsha }} + labels: | + YOUDIS_VERSION=${{ steps.youdis.outputs.youdis-version }} + + - name: link container in gitea + run: | + curl -X POST -H "Authorization: token ${{ secrets.SUPER }}" https://git.hgsky.me/api/v1/packages/ben/container/youdis/-/link/youdis + diff --git a/.gitignore b/.gitignore index 0ee2b33..e42ef9a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,13 @@ -*.pyc -*.org -*.ps1 -.env -#* -.#* -__pycache__/ -venv/ -archive/ -config/ -downloads/ -data.json +*.pyc +*.org +*.ps1 +.env +#* +.#* +__pycache__/ +venv/ +archive/ +config/ +downloads/ +data.json \ No newline at end of file diff --git a/LICENSE b/LICENSE index fdddb29..f50ef62 100644 --- a/LICENSE +++ b/LICENSE @@ -1,24 +1,24 @@ -This is free and unencumbered software released into the public domain. - -Anyone is free to copy, modify, publish, use, compile, sell, or -distribute this software, either in source code form or as a compiled -binary, for any purpose, commercial or non-commercial, and by any -means. - -In jurisdictions that recognize copyright laws, the author or authors -of this software dedicate any and all copyright interest in the -software to the public domain. We make this dedication for the benefit -of the public at large and to the detriment of our heirs and -successors. We intend this dedication to be an overt act of -relinquishment in perpetuity of all present and future rights to this -software under copyright law. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR -OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. - -For more information, please refer to +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/README.md b/README.md index 9dc804e..462dbe2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -build and run the docker container -``` -api_token = [discord bot token] --v [downloads]:/downloads --v [config]:/config -``` -config contains data to persist across container updates, i.e., unraid appdata, -such as users.json (authorized users) and yt-dlp's archive.txt +build and run the docker container +``` +api_token = [discord bot token] +-v [downloads]:/downloads +-v [config]:/config +``` +config contains data to persist across container updates, i.e., unraid appdata, +such as users.json (authorized users) and yt-dlp's archive.txt diff --git a/banner.svg b/banner.svg index a04107f..0688fa2 100644 --- a/banner.svg +++ b/banner.svg @@ -1,31 +1,31 @@ - - - - - - - - + + + + + + + + \ No newline at end of file diff --git a/default-yt-dlp.conf b/default-yt-dlp.conf index efb7c69..a1f8dab 100644 --- a/default-yt-dlp.conf +++ b/default-yt-dlp.conf @@ -1,11 +1,11 @@ -# yt-dlp config file (yt-dlp.conf or .config/yt-dlp/config) ---simulate --f "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" ---embed-chapters ---embed-info-json ---write-playlist-metafiles -#--paths "{"home":"./downloads"}" ---download-archive "/config/archive.txt" ---restrict-filenames ---output "/downloads/%(uploader)s/%(playlist_title)s/%(playlist_index)s%(playlist_index& - )s%(title)s.%(ext)s" +# yt-dlp config file (yt-dlp.conf or .config/yt-dlp/config) +--simulate +-f "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" +--embed-chapters +--embed-info-json +--write-playlist-metafiles +#--paths "{"home":"./downloads"}" +--download-archive "/config/archive.txt" +--restrict-filenames +--output "/downloads/%(uploader)s/%(playlist_title)s/%(playlist_index)s%(playlist_index& - )s%(title)s.%(ext)s" # --split-chapters \ No newline at end of file diff --git a/dockerfile b/dockerfile index c3b0883..fc71aaf 100644 --- a/dockerfile +++ b/dockerfile @@ -1,16 +1,16 @@ -# syntax=docker/dockerfile:1 -FROM python:3.12.1-alpine3.18 -WORKDIR /app -RUN apk update && \ - apk add --no-cache build-base ffmpeg && \ - rm -rf /var/cache/apk/* -COPY requirements.txt . -RUN python3 -m pip install --no-cache-dir -r requirements.txt - -COPY youdis.py default-yt-dlp.conf update-ytdlp.sh run-youdis.sh /app/ -RUN chmod +x /app/update-ytdlp.sh /app/run-youdis.sh - -COPY weekly-restart /etc/cron.d/ -RUN chmod 0644 /etc/cron.d/weekly-restart - -ENTRYPOINT ["/app/run-youdis.sh"] +# syntax=docker/dockerfile:1 +FROM python:3.12.1-alpine3.18 +WORKDIR /app +RUN apk update && \ + apk add --no-cache build-base ffmpeg && \ + rm -rf /var/cache/apk/* +COPY requirements.txt . +RUN python3 -m pip install --no-cache-dir -r requirements.txt + +COPY youdis.py default-yt-dlp.conf update-ytdlp.sh run-youdis.sh /app/ +RUN chmod +x /app/update-ytdlp.sh /app/run-youdis.sh + +COPY weekly-restart /etc/cron.d/ +RUN chmod 0644 /etc/cron.d/weekly-restart + +ENTRYPOINT ["/app/run-youdis.sh"] diff --git a/unraid-ca-template.xml b/unraid-ca-template.xml index 7470115..0c421a4 100644 --- a/unraid-ca-template.xml +++ b/unraid-ca-template.xml @@ -1,18 +1,18 @@ - - - youdis - eulaly/youdis - https://hub.docker.com/r/eulaly/youdis/ - bridge - sh - false - [Unraid Support Thread] - https://github.com/eulaly/youdis - Discord bot-based wrapper for yt-dlp. Let your friends download videos to your server! Supports playlists, requires a configured Discord bot. - Downloaders: Tools: - https://raw.githubusercontent.com/eulaly/unraid-templates/refs/heads/master/unraid-ca-template.xml - https://github.com/eulaly/youdis/blob/c978a2326984efa9670678687ed1a1473478d753/yt_dlp.png - - - - + + + youdis + eulaly/youdis + https://hub.docker.com/r/eulaly/youdis/ + bridge + sh + false + [Unraid Support Thread] + https://github.com/eulaly/youdis + Discord bot-based wrapper for yt-dlp. Let your friends download videos to your server! Supports playlists, requires a configured Discord bot. + Downloaders: Tools: + https://raw.githubusercontent.com/eulaly/unraid-templates/refs/heads/master/unraid-ca-template.xml + https://github.com/eulaly/youdis/blob/c978a2326984efa9670678687ed1a1473478d753/yt_dlp.png + + + + diff --git a/weekly-restart b/weekly-restart index 49c089b..01bef55 100644 --- a/weekly-restart +++ b/weekly-restart @@ -1 +1 @@ -0 3 * * 0 root /app/update-ytdlp.sh +0 3 * * 0 root /app/update-ytdlp.sh diff --git a/youdis-version.txt b/youdis-version.txt index c388ae8..82984f5 100644 --- a/youdis-version.txt +++ b/youdis-version.txt @@ -1 +1 @@ -20250829-22fb495 +20250829-f8f625f diff --git a/youdis.py b/youdis.py index b9831b3..126c1d6 100644 --- a/youdis.py +++ b/youdis.py @@ -1,157 +1,157 @@ -#!/usr/bin/env python3 -''' -youdis v1.1 -bot for downloading youtube videos using yt-dlp -discord-py-interactions 5.11.0 has new option - requires python>=3.9 -''' -# match_filter: info_dict -> Raise utils.DownloadCancelled(msg) ? interrupt - -import interactions -from os import getenv -from pathlib import Path -import yt_dlp -import json -import asyncio -import threading - -userFile = Path('/config/users.json') -userFile.touch(exist_ok=True) - -bot = interactions.Client(intents=interactions.Intents.DEFAULT,default_scope=2147491904) - -userFile.parent.mkdir(exist_ok=True, parents=True) -try: - with open(userFile, 'x') as f: - print(f'users.json not found; saving to {userFile}') -except FileExistsError: - with open(userFile, 'r') as f: - authorized_users = json.load(f).get('authorized_users') - print(f'authorized_users:{authorized_users}') - -title = '' - -async def send_message(ctx, message): - await ctx.author.send(message) - -def download_video(url, options): - with yt_dlp.YoutubeDL(options) as ydl: - ydl.download(url) - -def create_hook(ctx,loop): - def hook(d): - global title - status = d.get('status') - if status == 'error': - msg = f'error; video probably already exists, have you checked archive.txt' - asyncio.run_coroutine_threadsafe(send_message(ctx,msg),loop) - elif d.get('info_dict').get('title') != title: - title = d.get('info_dict').get('title') - playlist_index = d.get('info_dict').get('playlist_index') - playlist_count = d.get('info_dict').get('playlist_count') - filename = d.get('filename') - url = d.get('info_dict').get('webpage_url') - msg = f'{status} {playlist_index} of {playlist_count}: {filename} <{url}>' - asyncio.run_coroutine_threadsafe(send_message(ctx,msg),loop) - return hook - -@interactions.slash_command(name="youtube",description="download video from youtube to server") -@interactions.slash_option( - name='url', - opt_type=interactions.OptionType.STRING, - required=True, - description='url target' -) -async def youtube(ctx: interactions.SlashContext, url:str): - print(f'{ctx.author.id} requested {url}') - loop = asyncio.get_running_loop() - hook = create_hook(ctx,loop) - msg = '' - # use api_to_cli and paste cli options to get the output you need - yoptions = { - 'format':'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', - 'fragment_tries': 10, - 'restrictfilenames':True, - 'paths': {'home':'/downloads'}, - 'retries':10, - 'writeinfojson':False, - 'allow_playlist_files':True, - 'noplaylist':True, - 'download_archive':'/config/archive.txt', - 'progress_hooks':[hook], - 'outtmpl': '%(uploader)s/%(playlist_title)s/%(playlist_index)s%(playlist_index& - )s%(title)s.%(ext)s', - 'outtmpl_na_placeholder':'', - } - # check that user is authorized - if ctx.author.id not in authorized_users: - if ctx.author.id == 127831327012683776: - await ctx.author.send('potato stop') - await ctx.author.send('you are not authorized to use this command. message my owner to be added.') - return - else: - await ctx.channel.send(f'Downloading from <{url}>. Status updates via DM.') - #await ctx.defer() #if you need up to 15m to respond - - # 1/2 - download in separate thread, else progress_hook blocks downstream async ctx.send - download_thread = threading.Thread(target=download_video, args=(url,yoptions)) - download_thread.start() - await asyncio.to_thread(download_thread.join) - - # 2/2 - replace the above with this next try: - #try: - # await asyncio.to_thread(download_video, url, yoptions) - #except Exception as e: - # print(f"download failed: {e}") - # await ctx.author.send(f"download failed: {str(e)}") - - -@interactions.slash_command(name="interrupt",description="cancel current job") -@interactions.check(interactions.is_owner()) -async def _interrupt(ctx): - # interrupt here - print('interrupting current job - not implemented') - await ctx.author.send('interrupting current job - not implemented') - -@interactions.slash_command(name="adduser",description="authorize target user") -@interactions.slash_option( - name="user", - opt_type=interactions.OptionType.USER, - required=True, - description='enable this bot for target user', -) -@interactions.check(interactions.is_owner()) -async def _adduser(ctx: interactions.SlashContext, user:interactions.OptionType.USER): - if str(user.id) not in authorized_users: - authorized_users.append(str(user.id)) - with open(userFile,'w') as f: #overwrite file - fix later if other params come up - json.dump({'authorized_users':authorized_users}) - print('react:checkmark') - await ctx.message.add_reaction('✅') - -@interactions.slash_command(name="removeuser",description="deauthorize target user") -@interactions.slash_option( - name="user", - opt_type=interactions.OptionType.USER, - required=True, - description='disable this bot for target user', -) -@interactions.check(interactions.is_owner()) -async def _removeuser(ctx: interactions.SlashContext, user:interactions.OptionType.USER): - if str(user.id) in authorized_users: - # ? ? ? fix pls - i = index(authorized_users(str(user.id))) - - # update list, rewrite json - - print('react:checkmark') - await ctx.message.add_reaction('✅') - -async def dl_hook(d): - msg = f'{d["status"]} {d["filename"]}' - print(msg) - await ctx.author.send(msg) - -api_token = getenv('api_token') -if not api_token: - raise ValueError('API token not set. Retrieve from your Discord bot.') -bot.start(api_token) +#!/usr/bin/env python3 +''' +youdis v1.1 +bot for downloading youtube videos using yt-dlp +discord-py-interactions 5.11.0 has new option + requires python>=3.9 +''' +# match_filter: info_dict -> Raise utils.DownloadCancelled(msg) ? interrupt + +import interactions +from os import getenv +from pathlib import Path +import yt_dlp +import json +import asyncio +import threading + +userFile = Path('/config/users.json') +userFile.touch(exist_ok=True) + +bot = interactions.Client(intents=interactions.Intents.DEFAULT,default_scope=2147491904) + +userFile.parent.mkdir(exist_ok=True, parents=True) +try: + with open(userFile, 'x') as f: + print(f'users.json not found; saving to {userFile}') +except FileExistsError: + with open(userFile, 'r') as f: + authorized_users = json.load(f).get('authorized_users') + print(f'authorized_users:{authorized_users}') + +title = '' + +async def send_message(ctx, message): + await ctx.author.send(message) + +def download_video(url, options): + with yt_dlp.YoutubeDL(options) as ydl: + ydl.download(url) + +def create_hook(ctx,loop): + def hook(d): + global title + status = d.get('status') + if status == 'error': + msg = f'error; video probably already exists, have you checked archive.txt' + asyncio.run_coroutine_threadsafe(send_message(ctx,msg),loop) + elif d.get('info_dict').get('title') != title: + title = d.get('info_dict').get('title') + playlist_index = d.get('info_dict').get('playlist_index') + playlist_count = d.get('info_dict').get('playlist_count') + filename = d.get('filename') + url = d.get('info_dict').get('webpage_url') + msg = f'{status} {playlist_index} of {playlist_count}: {filename} <{url}>' + asyncio.run_coroutine_threadsafe(send_message(ctx,msg),loop) + return hook + +@interactions.slash_command(name="youtube",description="download video from youtube to server") +@interactions.slash_option( + name='url', + opt_type=interactions.OptionType.STRING, + required=True, + description='url target' +) +async def youtube(ctx: interactions.SlashContext, url:str): + print(f'{ctx.author.id} requested {url}') + loop = asyncio.get_running_loop() + hook = create_hook(ctx,loop) + msg = '' + # use api_to_cli and paste cli options to get the output you need + yoptions = { + 'format':'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', + 'fragment_tries': 10, + 'restrictfilenames':True, + 'paths': {'home':'/downloads'}, + 'retries':10, + 'writeinfojson':False, + 'allow_playlist_files':True, + 'noplaylist':True, + 'download_archive':'/config/archive.txt', + 'progress_hooks':[hook], + 'outtmpl': '%(uploader)s/%(playlist_title)s/%(playlist_index)s%(playlist_index& - )s%(title)s.%(ext)s', + 'outtmpl_na_placeholder':'', + } + # check that user is authorized + if ctx.author.id not in authorized_users: + if ctx.author.id == 127831327012683776: + await ctx.author.send('potato stop') + await ctx.author.send('you are not authorized to use this command. message my owner to be added.') + return + else: + await ctx.channel.send(f'Downloading from <{url}>. Status updates via DM.') + #await ctx.defer() #if you need up to 15m to respond + + # 1/2 - download in separate thread, else progress_hook blocks downstream async ctx.send + download_thread = threading.Thread(target=download_video, args=(url,yoptions)) + download_thread.start() + await asyncio.to_thread(download_thread.join) + + # 2/2 - replace the above with this next try: + #try: + # await asyncio.to_thread(download_video, url, yoptions) + #except Exception as e: + # print(f"download failed: {e}") + # await ctx.author.send(f"download failed: {str(e)}") + + +@interactions.slash_command(name="interrupt",description="cancel current job") +@interactions.check(interactions.is_owner()) +async def _interrupt(ctx): + # interrupt here + print('interrupting current job - not implemented') + await ctx.author.send('interrupting current job - not implemented') + +@interactions.slash_command(name="adduser",description="authorize target user") +@interactions.slash_option( + name="user", + opt_type=interactions.OptionType.USER, + required=True, + description='enable this bot for target user', +) +@interactions.check(interactions.is_owner()) +async def _adduser(ctx: interactions.SlashContext, user:interactions.OptionType.USER): + if str(user.id) not in authorized_users: + authorized_users.append(str(user.id)) + with open(userFile,'w') as f: #overwrite file - fix later if other params come up + json.dump({'authorized_users':authorized_users}) + print('react:checkmark') + await ctx.message.add_reaction('✅') + +@interactions.slash_command(name="removeuser",description="deauthorize target user") +@interactions.slash_option( + name="user", + opt_type=interactions.OptionType.USER, + required=True, + description='disable this bot for target user', +) +@interactions.check(interactions.is_owner()) +async def _removeuser(ctx: interactions.SlashContext, user:interactions.OptionType.USER): + if str(user.id) in authorized_users: + # ? ? ? fix pls + i = index(authorized_users(str(user.id))) + + # update list, rewrite json + + print('react:checkmark') + await ctx.message.add_reaction('✅') + +async def dl_hook(d): + msg = f'{d["status"]} {d["filename"]}' + print(msg) + await ctx.author.send(msg) + +api_token = getenv('api_token') +if not api_token: + raise ValueError('API token not set. Retrieve from your Discord bot.') +bot.start(api_token)