reindex files to force unix line endings in .sh files
All checks were successful
push new builds to gitea, dockerhub / build (push) Successful in 1m26s

This commit is contained in:
2025-08-29 16:24:33 -04:00
parent f8f625f0bf
commit ec72c5657b
12 changed files with 350 additions and 349 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.sh text eol=lf

View File

@@ -1,72 +1,72 @@
name: push new builds to gitea, dockerhub name: push new builds to gitea, dockerhub
on: on:
push: push:
branches: ["master"] branches: ["master"]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Get youdis version - name: Get youdis version
id: youdis id: youdis
run: | run: |
YOUDIS_VER=$(cat youdis-version.txt) YOUDIS_VER=$(cat youdis-version.txt)
if [ -z "$YOUDIS_VER" ]; then if [ -z "$YOUDIS_VER" ]; then
echo "youdis version empty" >&2 echo "youdis version empty" >&2
exit 1 exit 1
fi fi
echo "youdis-version=$YOUDIS_VER" >> $GITHUB_OUTPUT echo "youdis-version=$YOUDIS_VER" >> $GITHUB_OUTPUT
echo "shortsha=${GITHUB_SHA::5}" >> $GITHUB_OUTPUT echo "shortsha=${GITHUB_SHA::5}" >> $GITHUB_OUTPUT
- name: Decide if build needed - name: Decide if build needed
id: check_build id: check_build
run: | run: |
SHOULD_BUILD=false SHOULD_BUILD=false
if [ "${{ steps.youdis.outputs.youdis-version }}" != "$(docker inspect --format='{{ index .Config.Labels "YOUDIS_VERSION" }}' eulaly/youdis:latest || echo none)" ]; then if [ "${{ steps.youdis.outputs.youdis-version }}" != "$(docker inspect --format='{{ index .Config.Labels "YOUDIS_VERSION" }}' eulaly/youdis:latest || echo none)" ]; then
SHOULD_BUILD=true SHOULD_BUILD=true
fi fi
echo "should_build=$SHOULD_BUILD" >> $GITHUB_OUTPUT echo "should_build=$SHOULD_BUILD" >> $GITHUB_OUTPUT
- name: Login to Dockerhub - name: Login to Dockerhub
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push to Dockerhub - name: Build and push to Dockerhub
if: ${{ steps.check_build.outputs.should_build == 'true' }} if: ${{ steps.check_build.outputs.should_build == 'true' }}
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
push: true push: true
tags: | tags: |
eulaly/youdis:latest eulaly/youdis:latest
eulaly/youdis:${{ steps.youdis.outputs.shortsha }} eulaly/youdis:${{ steps.youdis.outputs.shortsha }}
labels: | labels: |
YOUDIS_VERSION=${{ steps.youdis.outputs.youdis-version }} YOUDIS_VERSION=${{ steps.youdis.outputs.youdis-version }}
- name: Login to Gitea - name: Login to Gitea
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: git.hgsky.me registry: git.hgsky.me
username: ${{ secrets.USERNAME }} username: ${{ secrets.USERNAME }}
password: ${{ secrets.YOUDIS_PASSWORD }} password: ${{ secrets.YOUDIS_PASSWORD }}
- name: Build and push to Gitea - name: Build and push to Gitea
if: ${{ steps.check_build.outputs.should_build == 'true' }} if: ${{ steps.check_build.outputs.should_build == 'true' }}
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
push: true push: true
tags: | tags: |
git.hgsky.me/ben/youdis:latest git.hgsky.me/ben/youdis:latest
git.hgsky.me/ben/youdis:${{ steps.youdis.outputs.shortsha }} git.hgsky.me/ben/youdis:${{ steps.youdis.outputs.shortsha }}
labels: | labels: |
YOUDIS_VERSION=${{ steps.youdis.outputs.youdis-version }} YOUDIS_VERSION=${{ steps.youdis.outputs.youdis-version }}
- name: link container in gitea - name: link container in gitea
run: | run: |
curl -X POST -H "Authorization: token ${{ secrets.SUPER }}" https://git.hgsky.me/api/v1/packages/ben/container/youdis/-/link/youdis curl -X POST -H "Authorization: token ${{ secrets.SUPER }}" https://git.hgsky.me/api/v1/packages/ben/container/youdis/-/link/youdis

24
.gitignore vendored
View File

@@ -1,13 +1,13 @@
*.pyc *.pyc
*.org *.org
*.ps1 *.ps1
.env .env
#* #*
.#* .#*
__pycache__/ __pycache__/
venv/ venv/
archive/ archive/
config/ config/
downloads/ downloads/
data.json data.json

48
LICENSE
View File

@@ -1,24 +1,24 @@
This is free and unencumbered software released into the public domain. This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any binary, for any purpose, commercial or non-commercial, and by any
means. means.
In jurisdictions that recognize copyright laws, the author or authors In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit 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 of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this relinquishment in perpetuity of all present and future rights to this
software under copyright law. software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 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 ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE. OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org> For more information, please refer to <https://unlicense.org>

View File

@@ -1,8 +1,8 @@
build and run the docker container build and run the docker container
``` ```
api_token = [discord bot token] api_token = [discord bot token]
-v [downloads]:/downloads -v [downloads]:/downloads
-v [config]:/config -v [config]:/config
``` ```
config contains data to persist across container updates, i.e., unraid appdata, config contains data to persist across container updates, i.e., unraid appdata,
such as users.json (authorized users) and yt-dlp's archive.txt such as users.json (authorized users) and yt-dlp's archive.txt

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -1,11 +1,11 @@
# yt-dlp config file (yt-dlp.conf or .config/yt-dlp/config) # yt-dlp config file (yt-dlp.conf or .config/yt-dlp/config)
--simulate --simulate
-f "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" -f "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best"
--embed-chapters --embed-chapters
--embed-info-json --embed-info-json
--write-playlist-metafiles --write-playlist-metafiles
#--paths "{"home":"./downloads"}" #--paths "{"home":"./downloads"}"
--download-archive "/config/archive.txt" --download-archive "/config/archive.txt"
--restrict-filenames --restrict-filenames
--output "/downloads/%(uploader)s/%(playlist_title)s/%(playlist_index)s%(playlist_index& - )s%(title)s.%(ext)s" --output "/downloads/%(uploader)s/%(playlist_title)s/%(playlist_index)s%(playlist_index& - )s%(title)s.%(ext)s"
# --split-chapters # --split-chapters

View File

@@ -1,16 +1,16 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM python:3.12.1-alpine3.18 FROM python:3.12.1-alpine3.18
WORKDIR /app WORKDIR /app
RUN apk update && \ RUN apk update && \
apk add --no-cache build-base ffmpeg && \ apk add --no-cache build-base ffmpeg && \
rm -rf /var/cache/apk/* rm -rf /var/cache/apk/*
COPY requirements.txt . COPY requirements.txt .
RUN python3 -m pip install --no-cache-dir -r 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/ 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 RUN chmod +x /app/update-ytdlp.sh /app/run-youdis.sh
COPY weekly-restart /etc/cron.d/ COPY weekly-restart /etc/cron.d/
RUN chmod 0644 /etc/cron.d/weekly-restart RUN chmod 0644 /etc/cron.d/weekly-restart
ENTRYPOINT ["/app/run-youdis.sh"] ENTRYPOINT ["/app/run-youdis.sh"]

View File

@@ -1,18 +1,18 @@
<?xml version="1.0"?> <?xml version="1.0"?>
<Container version="2"> <Container version="2">
<Name>youdis</Name> <Name>youdis</Name>
<Repository>eulaly/youdis</Repository> <Repository>eulaly/youdis</Repository>
<Registry>https://hub.docker.com/r/eulaly/youdis/</Registry> <Registry>https://hub.docker.com/r/eulaly/youdis/</Registry>
<Network>bridge</Network> <Network>bridge</Network>
<Shell>sh</Shell> <Shell>sh</Shell>
<Privileged>false</Privileged> <Privileged>false</Privileged>
<Support>[Unraid Support Thread]</Support> <Support>[Unraid Support Thread]</Support>
<Project>https://github.com/eulaly/youdis</Project> <Project>https://github.com/eulaly/youdis</Project>
<Overview>Discord bot-based wrapper for yt-dlp. Let your friends download videos to your server! Supports playlists, requires a configured Discord bot.</Overview> <Overview>Discord bot-based wrapper for yt-dlp. Let your friends download videos to your server! Supports playlists, requires a configured Discord bot.</Overview>
<Category>Downloaders: Tools:</Category> <Category>Downloaders: Tools:</Category>
<TemplateURL>https://raw.githubusercontent.com/eulaly/unraid-templates/refs/heads/master/unraid-ca-template.xml</TemplateURL> <TemplateURL>https://raw.githubusercontent.com/eulaly/unraid-templates/refs/heads/master/unraid-ca-template.xml</TemplateURL>
<Icon>https://github.com/eulaly/youdis/blob/c978a2326984efa9670678687ed1a1473478d753/yt_dlp.png</Icon> <Icon>https://github.com/eulaly/youdis/blob/c978a2326984efa9670678687ed1a1473478d753/yt_dlp.png</Icon>
<Config Name="api_token" Target="api_token" Default="" Mode="" Description="Discord bot token" Type="Variable" Display="always" Required="true" Mask="true"/> <Config Name="api_token" Target="api_token" Default="" Mode="" Description="Discord bot token" Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="Downloads" Target="/downloads" Default="" Mode="rw" Description="Video download location" Type="Path" Display="always" Required="false"/> <Config Name="Downloads" Target="/downloads" Default="" Mode="rw" Description="Video download location" Type="Path" Display="always" Required="false"/>
<Config Name="Config" Target="/config" Default="/mnt/user/appdata/youdis/config" Mode="rw" Description="Config location (archive.txt, users.json)" Type="Path" Display="always" Required="false"/> <Config Name="Config" Target="/config" Default="/mnt/user/appdata/youdis/config" Mode="rw" Description="Config location (archive.txt, users.json)" Type="Path" Display="always" Required="false"/>
</Container> </Container>

View File

@@ -1 +1 @@
0 3 * * 0 root /app/update-ytdlp.sh 0 3 * * 0 root /app/update-ytdlp.sh

View File

@@ -1 +1 @@
20250829-22fb495 20250829-f8f625f

314
youdis.py
View File

@@ -1,157 +1,157 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
''' '''
youdis v1.1 youdis v1.1
bot for downloading youtube videos using yt-dlp bot for downloading youtube videos using yt-dlp
discord-py-interactions 5.11.0 has new option discord-py-interactions 5.11.0 has new option
requires python>=3.9 requires python>=3.9
''' '''
# match_filter: info_dict -> Raise utils.DownloadCancelled(msg) ? interrupt # match_filter: info_dict -> Raise utils.DownloadCancelled(msg) ? interrupt
import interactions import interactions
from os import getenv from os import getenv
from pathlib import Path from pathlib import Path
import yt_dlp import yt_dlp
import json import json
import asyncio import asyncio
import threading import threading
userFile = Path('/config/users.json') userFile = Path('/config/users.json')
userFile.touch(exist_ok=True) userFile.touch(exist_ok=True)
bot = interactions.Client(intents=interactions.Intents.DEFAULT,default_scope=2147491904) bot = interactions.Client(intents=interactions.Intents.DEFAULT,default_scope=2147491904)
userFile.parent.mkdir(exist_ok=True, parents=True) userFile.parent.mkdir(exist_ok=True, parents=True)
try: try:
with open(userFile, 'x') as f: with open(userFile, 'x') as f:
print(f'users.json not found; saving to {userFile}') print(f'users.json not found; saving to {userFile}')
except FileExistsError: except FileExistsError:
with open(userFile, 'r') as f: with open(userFile, 'r') as f:
authorized_users = json.load(f).get('authorized_users') authorized_users = json.load(f).get('authorized_users')
print(f'authorized_users:{authorized_users}') print(f'authorized_users:{authorized_users}')
title = '' title = ''
async def send_message(ctx, message): async def send_message(ctx, message):
await ctx.author.send(message) await ctx.author.send(message)
def download_video(url, options): def download_video(url, options):
with yt_dlp.YoutubeDL(options) as ydl: with yt_dlp.YoutubeDL(options) as ydl:
ydl.download(url) ydl.download(url)
def create_hook(ctx,loop): def create_hook(ctx,loop):
def hook(d): def hook(d):
global title global title
status = d.get('status') status = d.get('status')
if status == 'error': if status == 'error':
msg = f'error; video probably already exists, have you checked archive.txt' msg = f'error; video probably already exists, have you checked archive.txt'
asyncio.run_coroutine_threadsafe(send_message(ctx,msg),loop) asyncio.run_coroutine_threadsafe(send_message(ctx,msg),loop)
elif d.get('info_dict').get('title') != title: elif d.get('info_dict').get('title') != title:
title = d.get('info_dict').get('title') title = d.get('info_dict').get('title')
playlist_index = d.get('info_dict').get('playlist_index') playlist_index = d.get('info_dict').get('playlist_index')
playlist_count = d.get('info_dict').get('playlist_count') playlist_count = d.get('info_dict').get('playlist_count')
filename = d.get('filename') filename = d.get('filename')
url = d.get('info_dict').get('webpage_url') url = d.get('info_dict').get('webpage_url')
msg = f'{status} {playlist_index} of {playlist_count}: {filename} <{url}>' msg = f'{status} {playlist_index} of {playlist_count}: {filename} <{url}>'
asyncio.run_coroutine_threadsafe(send_message(ctx,msg),loop) asyncio.run_coroutine_threadsafe(send_message(ctx,msg),loop)
return hook return hook
@interactions.slash_command(name="youtube",description="download video from youtube to server") @interactions.slash_command(name="youtube",description="download video from youtube to server")
@interactions.slash_option( @interactions.slash_option(
name='url', name='url',
opt_type=interactions.OptionType.STRING, opt_type=interactions.OptionType.STRING,
required=True, required=True,
description='url target' description='url target'
) )
async def youtube(ctx: interactions.SlashContext, url:str): async def youtube(ctx: interactions.SlashContext, url:str):
print(f'{ctx.author.id} requested {url}') print(f'{ctx.author.id} requested {url}')
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
hook = create_hook(ctx,loop) hook = create_hook(ctx,loop)
msg = '' msg = ''
# use api_to_cli and paste cli options to get the output you need # use api_to_cli and paste cli options to get the output you need
yoptions = { yoptions = {
'format':'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', 'format':'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
'fragment_tries': 10, 'fragment_tries': 10,
'restrictfilenames':True, 'restrictfilenames':True,
'paths': {'home':'/downloads'}, 'paths': {'home':'/downloads'},
'retries':10, 'retries':10,
'writeinfojson':False, 'writeinfojson':False,
'allow_playlist_files':True, 'allow_playlist_files':True,
'noplaylist':True, 'noplaylist':True,
'download_archive':'/config/archive.txt', 'download_archive':'/config/archive.txt',
'progress_hooks':[hook], 'progress_hooks':[hook],
'outtmpl': '%(uploader)s/%(playlist_title)s/%(playlist_index)s%(playlist_index& - )s%(title)s.%(ext)s', 'outtmpl': '%(uploader)s/%(playlist_title)s/%(playlist_index)s%(playlist_index& - )s%(title)s.%(ext)s',
'outtmpl_na_placeholder':'', 'outtmpl_na_placeholder':'',
} }
# check that user is authorized # check that user is authorized
if ctx.author.id not in authorized_users: if ctx.author.id not in authorized_users:
if ctx.author.id == 127831327012683776: if ctx.author.id == 127831327012683776:
await ctx.author.send('potato stop') await ctx.author.send('potato stop')
await ctx.author.send('you are not authorized to use this command. message my owner to be added.') await ctx.author.send('you are not authorized to use this command. message my owner to be added.')
return return
else: else:
await ctx.channel.send(f'Downloading from <{url}>. Status updates via DM.') await ctx.channel.send(f'Downloading from <{url}>. Status updates via DM.')
#await ctx.defer() #if you need up to 15m to respond #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 # 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 = threading.Thread(target=download_video, args=(url,yoptions))
download_thread.start() download_thread.start()
await asyncio.to_thread(download_thread.join) await asyncio.to_thread(download_thread.join)
# 2/2 - replace the above with this next try: # 2/2 - replace the above with this next try:
#try: #try:
# await asyncio.to_thread(download_video, url, yoptions) # await asyncio.to_thread(download_video, url, yoptions)
#except Exception as e: #except Exception as e:
# print(f"download failed: {e}") # print(f"download failed: {e}")
# await ctx.author.send(f"download failed: {str(e)}") # await ctx.author.send(f"download failed: {str(e)}")
@interactions.slash_command(name="interrupt",description="cancel current job") @interactions.slash_command(name="interrupt",description="cancel current job")
@interactions.check(interactions.is_owner()) @interactions.check(interactions.is_owner())
async def _interrupt(ctx): async def _interrupt(ctx):
# interrupt here # interrupt here
print('interrupting current job - not implemented') print('interrupting current job - not implemented')
await ctx.author.send('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_command(name="adduser",description="authorize target user")
@interactions.slash_option( @interactions.slash_option(
name="user", name="user",
opt_type=interactions.OptionType.USER, opt_type=interactions.OptionType.USER,
required=True, required=True,
description='enable this bot for target user', description='enable this bot for target user',
) )
@interactions.check(interactions.is_owner()) @interactions.check(interactions.is_owner())
async def _adduser(ctx: interactions.SlashContext, user:interactions.OptionType.USER): async def _adduser(ctx: interactions.SlashContext, user:interactions.OptionType.USER):
if str(user.id) not in authorized_users: if str(user.id) not in authorized_users:
authorized_users.append(str(user.id)) authorized_users.append(str(user.id))
with open(userFile,'w') as f: #overwrite file - fix later if other params come up with open(userFile,'w') as f: #overwrite file - fix later if other params come up
json.dump({'authorized_users':authorized_users}) json.dump({'authorized_users':authorized_users})
print('react:checkmark') print('react:checkmark')
await ctx.message.add_reaction('') await ctx.message.add_reaction('')
@interactions.slash_command(name="removeuser",description="deauthorize target user") @interactions.slash_command(name="removeuser",description="deauthorize target user")
@interactions.slash_option( @interactions.slash_option(
name="user", name="user",
opt_type=interactions.OptionType.USER, opt_type=interactions.OptionType.USER,
required=True, required=True,
description='disable this bot for target user', description='disable this bot for target user',
) )
@interactions.check(interactions.is_owner()) @interactions.check(interactions.is_owner())
async def _removeuser(ctx: interactions.SlashContext, user:interactions.OptionType.USER): async def _removeuser(ctx: interactions.SlashContext, user:interactions.OptionType.USER):
if str(user.id) in authorized_users: if str(user.id) in authorized_users:
# ? ? ? fix pls # ? ? ? fix pls
i = index(authorized_users(str(user.id))) i = index(authorized_users(str(user.id)))
# update list, rewrite json # update list, rewrite json
print('react:checkmark') print('react:checkmark')
await ctx.message.add_reaction('') await ctx.message.add_reaction('')
async def dl_hook(d): async def dl_hook(d):
msg = f'{d["status"]} {d["filename"]}' msg = f'{d["status"]} {d["filename"]}'
print(msg) print(msg)
await ctx.author.send(msg) await ctx.author.send(msg)
api_token = getenv('api_token') api_token = getenv('api_token')
if not api_token: if not api_token:
raise ValueError('API token not set. Retrieve from your Discord bot.') raise ValueError('API token not set. Retrieve from your Discord bot.')
bot.start(api_token) bot.start(api_token)