Compare commits

..

31 Commits

Author SHA1 Message Date
2beb54f219 udpated pm note format 2026-03-31 16:22:46 -04:00
f546c9fbb7 update 1.1.2 2026-03-31 14:01:25 -04:00
667b06fe4a enforce single-job blocking 2026-03-31 13:45:17 -04:00
033d9dd167 load authorized_users.json robustly 2026-03-31 13:24:25 -04:00
7a97e1f23d updated gitignore with template 2026-03-31 10:44:06 -04:00
a399a91ab0 fixed typo
All checks were successful
push new builds to gitea, dockerhub / build (push) Successful in 1m27s
2025-08-29 20:22:34 -04:00
ec72c5657b 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
2025-08-29 16:24:33 -04:00
f8f625f0bf fix typo
All checks were successful
push new builds to gitea, dockerhub / build (push) Successful in 1m6s
2025-08-29 16:11:29 -04:00
22fb495e0d fix docker syntax
Some checks failed
push new builds to gitea, dockerhub / build (push) Failing after 2m40s
2025-08-29 15:29:03 -04:00
33d5de6a77 update ytdlp on container start; cleanup gitea workflow
Some checks failed
push new builds to gitea, dockerhub / build (push) Failing after 38s
2025-08-29 15:22:10 -04:00
d539915486 cleanup and remove github workflow
Some checks failed
weekly, if new yt-dlp, build + push container to docker/gitea / build (push) Failing after 1m6s
2025-08-24 22:42:15 -04:00
f173c64b70 Revert "removed github workflow"
Some checks failed
weekly, if new yt-dlp, build + push container to docker/gitea / build (push) Failing after 26s
This reverts commit 750dc265ca.
2025-08-24 22:40:20 -04:00
750dc265ca removed github workflow
Some checks failed
weekly, if new yt-dlp version, build + push containers to docker / build (push) Failing after 27s
2025-08-24 22:29:45 -04:00
846d07f099 fixed gitea api url
Some checks failed
weekly, if new yt-dlp, build + push container to docker/gitea / build (push) Failing after 1m7s
2025-08-24 22:25:19 -04:00
adca108376 chagnged to link using api
Some checks failed
weekly, if new yt-dlp, build + push container to docker/gitea / build (push) Failing after 1m45s
2025-08-24 22:16:05 -04:00
748e3fb22a gitea vars redux
Some checks failed
weekly, if new yt-dlp, build + push container to docker/gitea / build (push) Failing after 58s
2025-08-24 19:00:35 -04:00
0968aa84b8 fixed username
Some checks failed
weekly, if new yt-dlp, build + push container to docker/gitea / build (push) Failing after 59s
2025-08-24 17:59:28 -04:00
b74f70c42e added gitea repo variables
Some checks failed
weekly, if new yt-dlp, build + push container to docker/gitea / build (push) Failing after 49s
2025-08-24 17:56:31 -04:00
07cdae823a gitea registry /youdis, tags ben/youdis
Some checks failed
weekly, if new yt-dlp, build + push container to docker/gitea / build (push) Failing after 2m32s
2025-08-24 17:20:05 -04:00
81619074d7 fixed youdis shortsha syntax
All checks were successful
weekly, if new yt-dlp, build + push container to docker/gitea / build (push) Successful in 2m29s
2025-08-24 15:35:21 -04:00
690f97ed82 consol. dev build tag; push directly to youdis container repo
Some checks failed
build + push containers to gitea (every) and docker (daily) / build (push) Failing after 56s
2025-08-24 15:28:44 -04:00
b772383d14 collapsed to single job
All checks were successful
build + push containers to gitea (every) and docker (daily) / build (push) Successful in 3m2s
2025-08-24 14:57:32 -04:00
53fbcc5a8a fix gitea user 2025-08-24 14:28:34 -04:00
713f316679 fixed: secrets cannot begin with "GITEA_" or "GITHUB_" 2025-08-24 14:24:14 -04:00
08bcc3d3bd split jobs into versioining, dockerhub, gitea 2025-08-23 14:43:48 -04:00
0abf3a5adb added ytdlp/youdis version tags, check youdis:latest before build
Some checks failed
build + push containers to gitea (every) and docker (daily) / docker (push) Has been cancelled
2025-08-23 14:36:30 -04:00
32ed9bc81b update version to 20250823-b37e943
Some checks failed
build + push containers to gitea (every) and docker (daily) / dockerhub (push) Failing after 25s
build + push containers to gitea (every) and docker (daily) / gitea (push) Failing after 15s
2025-08-23 12:15:11 -04:00
b37e943a8a added workflow.txt 2025-08-23 12:15:05 -04:00
7ada5c6bb9 revert docker/login-action v2 since v3 failed
Some checks failed
build + push containers to gitea (every) and docker (daily) / dockerhub (push) Failing after 24s
build + push containers to gitea (every) and docker (daily) / gitea (push) Failing after 16s
2025-08-23 11:35:16 -04:00
d94b63b1f0 split workflow stespt o push every update to gitea
Some checks failed
build + push containers to gitea (every) and docker (daily) / dockerhub (push) Failing after 3m45s
build + push containers to gitea (every) and docker (daily) / gitea (push) Failing after 17s
2025-08-23 11:19:48 -04:00
1d6e9bac6f fixed archive.txt 2025-08-19 16:26:50 -04:00
18 changed files with 580 additions and 323 deletions

1
.gitattributes vendored Normal file
View File

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

View File

@@ -1,44 +1,72 @@
name: Check yt-dlp weekly and rebuild if new name: push new builds to gitea, dockerhub
on:
schedule: on:
- cron: '0 0 * * 0' push:
workflow_dispatch: branches: ["master"]
workflow_dispatch:
jobs:
build: jobs:
runs-on: ubuntu-latest build:
steps: runs-on: ubuntu-latest
- uses: actions/checkout@v4 steps:
- uses: actions/checkout@v4
- name: Get current version
id: current - name: Get youdis version
run: | id: youdis
CURRENT=$(cat version.txt || echo "none") run: |
echo "version=$CURRENT" >> $GITHUB_OUTPUT YOUDIS_VER=$(cat youdis-version.txt)
if [ -z "$YOUDIS_VER" ]; then
- name: Check latest version echo "youdis version empty" >&2
id: latest exit 1
run: | fi
LATEST=$(curl -s https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest | jq -r .tag_name || echo "error") echo "youdis-version=$YOUDIS_VER" >> $GITHUB_OUTPUT
if [ "$LATEST" == "error" ]; then echo "shortsha=${GITHUB_SHA::5}" >> $GITHUB_OUTPUT
echo "failed to fetch latest version" >&2
exit 1 - name: Decide if build needed
fi id: check_build
echo "version=$LATEST" >> $GITHUB_OUTPUT run: |
SHOULD_BUILD=false
- name: Update version file if changed if [ "${{ steps.youdis.outputs.youdis-version }}" != "$(docker inspect --format='{{ index .Config.Labels "YOUDIS_VERSION" }}' eulaly/youdis:latest || echo none)" ]; then
run: echo ${{ steps.latest.outputs.version }} > version.txt SHOULD_BUILD=true
if: ${{ steps.current.outputs.version != steps.latest.outputs.version }} fi
echo "should_build=$SHOULD_BUILD" >> $GITHUB_OUTPUT
- name: Login to Dockerhub
uses: docker/login-action@v2 - name: Login to Dockerhub
with: uses: docker/login-action@v3
username: ${{ secrets.DOCKERHUB_USERNAME }} with:
password: ${{ secrets.DOCKERHUB_TOKEN }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push docker image
uses: docker/build-push-action@v5 - name: Build and push to Dockerhub
with: if: ${{ steps.check_build.outputs.should_build == 'true' }}
push: true uses: docker/build-push-action@v5
tags: eulaly/youdis:latest,eulaly/youdis:${{ steps.latest.outputs.version }} with:
if: ${{ steps.current.outputs.version != steps.latest.outputs.version }} 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

View File

@@ -1,44 +0,0 @@
name: Check yt-dlp and rebuild if new
on:
schedule:
- cron: '0 0 * * 0'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get current version
id: current
run: |
CURRENT=$(cat version.txt || echo "none")
echo "version=$CURRENT" >> $GITHUB_OUTPUT
- name: Check latest version
id: latest
run: |
LATEST=$(curl -s https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest | jq -r .tag_name || echo "error")
if [ "$LATEST" == "error" ]; then
echo "failed to fetch latest version" >&2
exit 1
fi
echo "version=$LATEST" >> $GITHUB_OUTPUT
- name: Update version file if changed
run: echo ${{ steps.latest.outputs.version }} > version.txt
if: ${{ steps.current.outputs.version != steps.latest.outputs.version }}
- name: Login to Dockerhub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push docker image
uses: docker/build-push-action@v5
with:
push: true
tags: eulaly/youdis:latest,eulaly/youdis:${{ steps.latest.outputs.version }}
if: ${{ steps.current.outputs.version != steps.latest.outputs.version }}

47
.gitignore vendored
View File

@@ -1,13 +1,34 @@
*.pyc # --- python bytecode ---
*.org __pycache__/
*.ps1 *.py[cod]
.env *$py.class
#*
.#* # --- virtual environments ---
__pycache__/ .venv/
venv/ venv/
archive/ env/
config/
downloads/ # --- environment files ---
data.json .env
.env.*
*.local
# --- emacs ---
*~
\#*\#
.\#*
*.elc
# --- project private data ---
/private/
archive/
downloads/
data.json
# --- django ---
db.sqlite3
staticfiles/
media/
# --- misc ---
.DS_Store

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,8 +0,0 @@
#!/bin/sh
#copy yt-dlp.conf if missing
mkdir -p /config
if [ ! -f /config/yt-dlp.conf ]; then
cp /app/default-yt-dlp.conf /config/yt-dlp.conf
fi
exec "$@"

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,14 +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 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 youdis.py
COPY default-yt-dlp.conf /app/default-yt-dlp.conf COPY youdis.py default-yt-dlp.conf update-ytdlp.sh run-youdis.sh /app/
COPY create-ytdlp-config.sh /app/create-ytdlp-config.sh RUN chmod +x /app/update-ytdlp.sh /app/run-youdis.sh
RUN chmod 755 /app/create-ytdlp-config.sh
ENTRYPOINT ["/app/create-ytdlp-config.sh"] COPY weekly-restart /etc/cron.d/
CMD ["python3", "youdis.py"] RUN chmod 0644 /etc/cron.d/weekly-restart
ENTRYPOINT ["/app/run-youdis.sh"]

21
pm/task-sample.org Normal file
View File

@@ -0,0 +1,21 @@
#+title: Task Log
#+updated: [2026-03-31 Tue 16:03]
Use the template below, which should be a top-level org-mode header.
* [ ] M.m.m: Task Title (# commits)
description of the task
** pm notes: amplifying information
** Acceptance Criteria
1. Criterion
- expanded data
2. Criterion
** evidence
- commit: abc123, bcd234
- tests:
- datetime: [2026-03-18 Wed 14:15]
** notes
- explanation of work done, decisions made, reasoning

123
pm/tasks.org Normal file
View File

@@ -0,0 +1,123 @@
#+title: Youdis Task Log
#+updated: [2026-03-31 Tue 08:00]
* [X] 1.1.1: stabilize youdis core bot behavior (estimate 3 commits)
refactor the current `youdis.py` flow so authorization, download execution, and user feedback are correct and predictable without changing the product shape. keep this narrowly scoped to correctness and maintainability; do not redesign into a queueing platform yet. preserve archive-first behavior and dm status updates; do not add new infrastructure dependencies and prefer boring explicit state over clever concurrency.
** acceptance criteria
1. initialize and load `/config/users.json` safely in all cases
- create parent dirs before touch/open
- ensure `authorized_users` always has a valid default
- normalize stored ids to a single type
2. fix command-path correctness for `/youtube`, `/adduser`, and `/removeuser`
- authorized users can successfully invoke downloads
- add/remove user commands persist changes correctly
- remove broken/incomplete code paths
3. duplicate prevention relies on archive.txt
** pm notes
** evidence
- commit: 033d9dd
- tests: ~python3 -m py_compile ./youdis.py~
- datetime: [2026-03-31 Tue 13:28]
** notes
- store Discord user ids as strings in `users.json`
- duplicate prevention should continue to rely on `archive.txt`, not inferred hook errors
* [X] 1.1.2: remove global mutable download state and define single-job semantics (estimate 2 commits)
eliminate shared mutable hook state and make concurrent behavior explicit, even if the initial policy is just "one active job at a time." don't build a scheduler; ok if simplest outcome is single active job with clear busy message. cancellation can be coarse if yt-dlp/process boundaries make graceful stop annoying
** acceptance criteria
1. improve runtime handling for downloads
- replace brittle thread/join pattern with a simpler async-safe execution path
- catch and report real yt-dlp failures
- avoid misleading "already exists" error assumptions
2. progress reporting is isolated per request
- no module-level mutable title state shared across jobs
- hooks derive state from request-local context
3. active-job behavior is explicit
- either reject a second request while busy or implement a minimal tracked active job
- user-facing response explains current behavior
4. `/interrupt` is either implemented minimally or downgraded honestly
- no fake command implying cancellation works when it does not
- command behavior matches implementation
** evidence
- commit: 667b06f
- tests: ~python3 -m py_compile /home/user/proj/youdis/youdis.py~
- datetime: [2026-03-31 Tue 14:00]
** notes
- verify slash-command response patterns against the `interactions` library while touching runtime flow
* [ ] 1.1.3: move static yt-dlp behavior into config and shrink python surface area (estimate 2 commits)
shift stable downloader options into `default-yt-dlp.conf` so the bot code only handles dynamic inputs and orchestration. optimize for inspectability and low-friction manual ops. keep output naming durable enough for plex/plain-file use. avoid duplicating config values across code and conf.
** acceptance criteria
1. separate static vs dynamic yt-dlp options cleanly
- stable defaults live in `default-yt-dlp.conf`
- python injects only request-specific/runtime values
2. preserve archive and output behavior
- `archive.txt` remains the duplicate-prevention mechanism
- output paths remain stable and browseable
3. document config ownership
- clarify which settings belong in config vs code
- make future yt-dlp tuning possible without major python edits
** evidence
- commit:
- tests:
- datetime:
** notes
* [ ] 1.1.4: simplify image/build/update workflow around manual ops (estimate 3 commits)
reduce repo cruft from the gitea-runner/nightly-update experiment and replace it with explicit manual update/rebuild mechanics.
** acceptance criteria
1. define a manual update path for yt-dlp and app image lifecycle
- document or script manual `git pull`, rebuild, and redeploy
- remove or quarantine brittle auto-update assumptions
2. review and simplify `update-ytdlp.sh`, workflow yaml, and weekly restart artifacts
- keep only artifacts that serve the current manual-ops model
- delete or mark deprecated anything tied to abandoned automation paths
3. retain unraid deployment viability
- container can still be rebuilt and redeployed cleanly on jeeves
- resulting flow is understandable without rereading old ci experiments
- pm note: weekly restart is presumed suspect until proven necessary
** evidence
- commit:
- tests:
- datetime:
** notes
- do not let runner/workflow complexity dominate a small bot
- prefer explicit version pinning or manual binary refresh over magical nightlies
* [ ] 1.1.5: clean up packaging/deployment artifacts for unraid consumption (estimate 2 commits)
make the dockerfile, run script, and unraid-ca template consistent with the refactored app so deployment is less of a ritual ordeal.
** acceptance criteria
1. align docker/runtime assumptions
- paths like `/config` and `/downloads` are consistent across code, scripts, and container metadata
- env vars are documented and validated
2. review deployment artifacts for drift
- `dockerfile`, `run-youdis.sh`, and `unraid-ca-template.xml` reflect current behavior
- remove stale references and dead assumptions
3. make fresh deployment understandable
- a new deploy on unraid is possible without reconstructing tribal knowledge from old files
- pm note: this is packaging polish after core correctness, not before
** evidence
- commit:
- tests:
- datetime:
** notes
- keep container surface area small
- optimize for “future me can redeploy this without cursing past me too hard”

21
run-youdis.sh Normal file
View File

@@ -0,0 +1,21 @@
#!/bin/sh
set -e
#start crond in bg
crond -l 2
echo "checking for /config/yt-dlp.conf"
#copy yt-dlp.conf if missing
mkdir -p /config
if [ ! -f /config/yt-dlp.conf ]; then
echo "yt-dlp.conf not found, setting default"
cp /app/default-yt-dlp.conf /config/yt-dlp.conf
echo "created yt-dlp.conf"
fi
python3 -m pip install --no-cache-dir --upgrade --pre "yt-dlp[default]"
VERSION=$(python3 -m pip show yt-dlp 2>/dev/null | awk '/Version:/ {print $2}')
echo "updated yt-dlp to $VERSION"
echo "starting youdis"
exec python3 /app/youdis.py

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>

12
update-ytdlp.sh Normal file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
set -e
echo "updating yt-dlp"
echo "killing youdis"
pkill -f youdis.py || true
python3 -m pip install --no-cache-dir --upgrade --pre "yt-dlp[default]"
VERSION=$(python3 -m pip show yt-dlp 2>/dev/null | awk '/Version:/ {print $2}')
echo "updated yt-dlp to $VERSION"
echo "restarting youdis"
python3 /app/youdis.py &

1
weekly-restart Normal file
View File

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

1
youdis-version.txt Normal file
View File

@@ -0,0 +1 @@
20250829-ec72c56

298
youdis.py
View File

@@ -1,72 +1,149 @@
#!/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.parent.mkdir(exist_ok=True, parents=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) def save_authorized_users(authorized_users):
try: with open(userFile, 'w') as f:
with open(userFile, 'x') as f: json.dump({'authorized_users': authorized_users}, f)
print(f'users.json not found; saving to {userFile}')
except FileExistsError: def load_authorized_users():
with open(userFile, 'r') as f: if not userFile.exists():
authorized_users = json.load(f).get('authorized_users') save_authorized_users([])
print(f'users.json not found; saving to {userFile}')
return []
try:
with open(userFile, 'r') as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
save_authorized_users([])
print(f'users.json invalid; resetting {userFile}')
return []
authorized_users = data.get('authorized_users', [])
if not isinstance(authorized_users, list):
authorized_users = []
authorized_users = [str(user_id) for user_id in authorized_users]
save_authorized_users(authorized_users)
print(f'authorized_users:{authorized_users}') print(f'authorized_users:{authorized_users}')
return authorized_users
title = ''
authorized_users = load_authorized_users()
active_job_lock = threading.Lock()
active_job = None
async def send_message(ctx, message): async def send_message(ctx, message):
await ctx.author.send(message) await ctx.author.send(message)
def claim_active_job(job):
global active_job
with active_job_lock:
if active_job is not None:
return active_job
active_job = job
return None
def get_active_job():
with active_job_lock:
return active_job
def clear_active_job(job):
global active_job
with active_job_lock:
if active_job is job:
active_job = None
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 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") def create_hook(ctx, loop, cancel_event):
@interactions.slash_option( seen_updates = set()
name='url',
opt_type=interactions.OptionType.STRING, def hook(d):
required=True, if cancel_event.is_set():
description='url target' raise yt_dlp.utils.DownloadCancelled('download canceled by /interrupt')
)
status = d.get('status')
info = d.get('info_dict') or {}
if status not in {'downloading', 'finished'}:
return
filename = d.get('filename') or info.get('_filename') or info.get('title')
update_key = (status, filename)
if update_key in seen_updates:
return
seen_updates.add(update_key)
playlist_index = info.get('playlist_index')
playlist_count = info.get('playlist_count')
url = info.get('webpage_url')
prefix = status
if playlist_index and playlist_count:
prefix = f'{status} {playlist_index} of {playlist_count}'
msg = f'{prefix}: {filename}'
if url:
msg = f'{msg} <{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): async def youtube(ctx: interactions.SlashContext, url:str):
print(f'{ctx.author.id} requested {url}') print(f'{ctx.author.id} requested {url}')
# check that user is authorized
if str(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
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
hook = create_hook(ctx,loop) cancel_event = threading.Event()
msg = '' hook = create_hook(ctx, loop, cancel_event)
job = {
'requester_id': str(ctx.author.id),
'request_url': url,
'cancel_event': cancel_event,
}
existing_job = claim_active_job(job)
if existing_job:
await ctx.author.send(
f'already downloading for <@{existing_job["requester_id"]}>. '
'single-job mode is enabled right now; try again after it finishes.'
)
return
# 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',
@@ -77,81 +154,82 @@ async def youtube(ctx: interactions.SlashContext, url:str):
'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 await ctx.channel.send(f'Downloading from <{url}>. Status updates via DM. Single-job mode is enabled.')
if ctx.author.id not in authorized_users:
if ctx.author.id == 127831327012683776: try:
await ctx.author.send('potato stop') await asyncio.to_thread(download_video, url, yoptions)
await ctx.author.send('you are not authorized to use this command. message my owner to be added.') except yt_dlp.utils.DownloadCancelled as exc:
return print(f'download canceled: {exc}')
await ctx.author.send(f'download canceled: {exc}')
except yt_dlp.utils.DownloadError as exc:
print(f'download failed: {exc}')
await ctx.author.send(f'download failed: {exc}')
except Exception as exc:
print(f'unexpected download failure: {exc}')
await ctx.author.send(f'unexpected download failure: {exc}')
else: else:
await ctx.channel.send(f'Downloading from <{url}>. Status updates via DM.') await ctx.author.send(f'download complete for <{url}>')
#await ctx.defer() #if you need up to 15m to respond finally:
clear_active_job(job)
# 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.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 job = get_active_job()
print('interrupting current job - not implemented') if not job:
await ctx.author.send('interrupting current job - not implemented') await ctx.author.send('no active download to interrupt')
return
@interactions.slash_command(name="adduser",description="authorize target user")
@interactions.slash_option( job['cancel_event'].set()
name="user", print(f'interrupt requested for {job["request_url"]}')
opt_type=interactions.OptionType.USER, await ctx.author.send(
required=True, f'interrupt requested for <{job["request_url"]}>; '
description='enable this bot for target user', 'cancellation is coarse and will stop on the next yt-dlp progress update'
) )
@interactions.check(interactions.is_owner())
@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): async def _adduser(ctx: interactions.SlashContext, user:interactions.OptionType.USER):
if str(user.id) not in authorized_users: user_id = str(user.id)
authorized_users.append(str(user.id)) if user_id not in authorized_users:
with open(userFile,'w') as f: #overwrite file - fix later if other params come up authorized_users.append(user_id)
json.dump({'authorized_users':authorized_users}) save_authorized_users(authorized_users)
print('react:checkmark') print(f'authorized {user_id}')
await ctx.message.add_reaction('') await ctx.author.send(f'authorized {user.mention}')
else:
@interactions.slash_command(name="removeuser",description="deauthorize target user") await ctx.author.send(f'{user.mention} is already authorized')
@interactions.slash_option(
name="user", @interactions.slash_command(name="removeuser",description="deauthorize target user")
opt_type=interactions.OptionType.USER, @interactions.slash_option(
required=True, name="user",
description='disable this bot for target user', opt_type=interactions.OptionType.USER,
) required=True,
@interactions.check(interactions.is_owner()) description='disable this bot for target user',
)
@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: user_id = str(user.id)
# ? ? ? fix pls if user_id in authorized_users:
i = index(authorized_users(str(user.id))) authorized_users.remove(user_id)
save_authorized_users(authorized_users)
# update list, rewrite json print(f'deauthorized {user_id}')
await ctx.author.send(f'deauthorized {user.mention}')
print('react:checkmark') else:
await ctx.message.add_reaction('') await ctx.author.send(f'{user.mention} is not currently authorized')
async def dl_hook(d):
msg = f'{d["status"]} {d["filename"]}'
print(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)