Merge branch 'master' into j/config-getting-started

This commit is contained in:
Jesse Bannon 2026-02-16 23:25:24 -08:00
commit 99663bc6f3
360 changed files with 16433 additions and 3153 deletions

View file

@ -19,7 +19,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: "3.10" python-version: "3.12"
- name: Run unit tests with coverage - name: Run unit tests with coverage
run: | run: |
@ -42,7 +42,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: "3.10" python-version: "3.12"
- name: Run integration tests with coverage - name: Run integration tests with coverage
run: | run: |
@ -66,7 +66,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: "3.10" python-version: "3.12"
- name: Run prebuilt preset integration tests with coverage - name: Run prebuilt preset integration tests with coverage
run: | run: |
@ -89,7 +89,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: "3.10" python-version: "3.12"
- name: Run e2e tests with coverage - name: Run e2e tests with coverage
run: | run: |

View file

@ -9,7 +9,7 @@ on:
- master - master
jobs: jobs:
test-lint: test-lint:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@ -19,7 +19,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: "3.10" python-version: "3.12"
- name: Run linters - name: Run linters
run: | run: |
@ -27,7 +27,7 @@ jobs:
make check_lint make check_lint
test-unit: test-unit:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@ -37,7 +37,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: "3.10" python-version: "3.12"
- name: Run unit tests with coverage - name: Run unit tests with coverage
run: | run: |
@ -53,7 +53,7 @@ jobs:
key: ${{github.sha}}-coverage-unit key: ${{github.sha}}-coverage-unit
test-integration: test-integration:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@ -63,7 +63,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: "3.10" python-version: "3.12"
- name: Run integration tests with coverage - name: Run integration tests with coverage
run: | run: |
@ -79,7 +79,7 @@ jobs:
key: ${{github.sha}}-coverage-integration key: ${{github.sha}}-coverage-integration
test-integration-prebuilt-presets: test-integration-prebuilt-presets:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@ -89,7 +89,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: "3.10" python-version: "3.12"
- name: Run prebuilt preset integration tests with coverage - name: Run prebuilt preset integration tests with coverage
run: | run: |
@ -105,7 +105,7 @@ jobs:
key: ${{github.sha}}-coverage-integration-prebuilt-presets key: ${{github.sha}}-coverage-integration-prebuilt-presets
test-e2e: test-e2e:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@ -115,7 +115,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: "3.10" python-version: "3.12"
- name: Run e2e tests with coverage - name: Run e2e tests with coverage
run: | run: |
@ -125,7 +125,7 @@ jobs:
coverage run -m pytest tests/e2e && coverage xml -o /opt/coverage/e2e/coverage.xml coverage run -m pytest tests/e2e && coverage xml -o /opt/coverage/e2e/coverage.xml
codecov-upload: codecov-upload:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
needs: [ needs: [
test-unit, test-unit,
test-integration, test-integration,

View file

@ -63,10 +63,10 @@ jobs:
echo 'init_contents=__pypi_version__ = "${{ env.PYPI_VERSION }}";__local_version__ = "${{ env.LOCAL_VERSION }}"' >> "$GITHUB_OUTPUT" echo 'init_contents=__pypi_version__ = "${{ env.PYPI_VERSION }}";__local_version__ = "${{ env.LOCAL_VERSION }}"' >> "$GITHUB_OUTPUT"
build: build:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [ "3.10" ] python-version: [ "3.12" ]
permissions: permissions:
contents: read contents: read
@ -92,7 +92,7 @@ jobs:
# Build ARM64 container, only on master branch to save time testing # Build ARM64 container, only on master branch to save time testing
package-arm64: package-arm64:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
needs: [ needs: [
build build
] ]
@ -141,7 +141,7 @@ jobs:
# Build AMD64 container # Build AMD64 container
package-amd64: package-amd64:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
needs: [ needs: [
build build
] ]
@ -186,7 +186,7 @@ jobs:
# On master branch, build the docker manifest file from the cached # On master branch, build the docker manifest file from the cached
# docker builds and push to the registry # docker builds and push to the registry
deploy: deploy:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
needs: [ needs: [
version, version,
build, build,

View file

@ -63,10 +63,10 @@ jobs:
echo 'init_contents=__pypi_version__ = "${{ env.PYPI_VERSION }}";__local_version__ = "${{ env.LOCAL_VERSION }}"' >> "$GITHUB_OUTPUT" echo 'init_contents=__pypi_version__ = "${{ env.PYPI_VERSION }}";__local_version__ = "${{ env.LOCAL_VERSION }}"' >> "$GITHUB_OUTPUT"
build: build:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [ "3.10" ] python-version: [ "3.12" ]
permissions: permissions:
contents: read contents: read
@ -92,7 +92,7 @@ jobs:
# Build ARM64 container, only on master branch to save time testing # Build ARM64 container, only on master branch to save time testing
package-arm64: package-arm64:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
needs: [ needs: [
build build
] ]
@ -141,7 +141,7 @@ jobs:
# Build AMD64 container # Build AMD64 container
package-amd64: package-amd64:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
needs: [ needs: [
build build
] ]
@ -186,7 +186,7 @@ jobs:
# On master branch, build the docker manifest file from the cached # On master branch, build the docker manifest file from the cached
# docker builds and push to the registry # docker builds and push to the registry
deploy: deploy:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
needs: [ needs: [
version, version,
build, build,

View file

@ -63,10 +63,10 @@ jobs:
echo 'init_contents=__pypi_version__ = "${{ env.PYPI_VERSION }}";__local_version__ = "${{ env.LOCAL_VERSION }}"' >> "$GITHUB_OUTPUT" echo 'init_contents=__pypi_version__ = "${{ env.PYPI_VERSION }}";__local_version__ = "${{ env.LOCAL_VERSION }}"' >> "$GITHUB_OUTPUT"
build: build:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [ "3.10" ] python-version: [ "3.12" ]
permissions: permissions:
contents: read contents: read
@ -92,7 +92,7 @@ jobs:
# Build ARM64 container, only on master branch to save time testing # Build ARM64 container, only on master branch to save time testing
package-arm64: package-arm64:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
needs: [ needs: [
build build
] ]
@ -136,7 +136,7 @@ jobs:
# Build AMD64 container # Build AMD64 container
package-amd64: package-amd64:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
needs: [ needs: [
build build
] ]
@ -180,7 +180,7 @@ jobs:
# On master branch, build the docker manifest file from the cached # On master branch, build the docker manifest file from the cached
# docker builds and push to the registry # docker builds and push to the registry
deploy: deploy:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
needs: [ needs: [
version, version,
build, build,

View file

@ -61,9 +61,16 @@ jobs:
strategy: strategy:
matrix: matrix:
arch: [ "aarch64", "x86_64" ] arch: [ "aarch64", "x86_64" ]
runs-on: ubuntu-latest include:
- arch: "aarch64"
runner: "ubuntu-24.04-arm"
container: "quay.io/pypa/manylinux_2_28_aarch64"
- arch: "x86_64"
runner: "ubuntu-latest"
container: "quay.io/pypa/manylinux_2_28_x86_64"
runs-on: ${{ matrix.runner }}
container: container:
image: quay.io/pypa/manylinux_2_28_x86_64 image: ${{ matrix.container }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Write version to init file - name: Write version to init file
@ -76,17 +83,17 @@ jobs:
dnf install -y epel-release tar wget make gcc openssl-devel bzip2-devel libffi-devel zlib-devel dnf install -y epel-release tar wget make gcc openssl-devel bzip2-devel libffi-devel zlib-devel
- name: Install Python - name: Install Python
run: | run: |
wget https://www.python.org/ftp/python/3.10.10/Python-3.10.10.tar.xz wget https://www.python.org/ftp/python/3.12.9/Python-3.12.9.tar.xz
tar -xf Python-3.10.10.tar.xz tar -xf Python-3.12.9.tar.xz
cd Python-3.10.10 && ./configure --with-ensurepip=install --prefix=/usr/local --enable-shared LDFLAGS="-Wl,-rpath /usr/local/lib" cd Python-3.12.9 && ./configure --with-ensurepip=install --prefix=/usr/local --enable-shared LDFLAGS="-Wl,-rpath /usr/local/lib"
make -j 8 make -j 8
make altinstall make altinstall
python3.10 --version python3.12 --version
python3.10 -m ensurepip --upgrade python3.12 -m ensurepip --upgrade
- name: Build Package - name: Build Package
run: | run: |
python3.10 -m pip install -e . python3.12 -m pip install -e .
python3.10 -m pip install pyinstaller python3.12 -m pip install pyinstaller
# Build executable # Build executable
pyinstaller ytdl-sub.spec pyinstaller ytdl-sub.spec
mkdir -p /opt/builds mkdir -p /opt/builds
@ -95,7 +102,7 @@ jobs:
mv dist/ytdl-sub /opt/builds/ytdl-sub_${{ matrix.arch }} mv dist/ytdl-sub /opt/builds/ytdl-sub_${{ matrix.arch }}
- name: Upload build - name: Upload build
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: ytdl-sub_${{ matrix.arch }} name: ytdl-sub_${{ matrix.arch }}
path: /opt/builds/ytdl-sub_${{ matrix.arch }} path: /opt/builds/ytdl-sub_${{ matrix.arch }}
@ -106,13 +113,13 @@ jobs:
name: build-windows name: build-windows
needs: needs:
- version - version
runs-on: windows-2019 runs-on: windows-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: "3.10" python-version: "3.12"
- name: Write version to init file - name: Write version to init file
run: | run: |
echo '${{ needs.version.outputs.init_contents }}'> src/ytdl_sub/__init__.py echo '${{ needs.version.outputs.init_contents }}'> src/ytdl_sub/__init__.py
@ -124,7 +131,7 @@ jobs:
.\dist\ytdl-sub.exe -h .\dist\ytdl-sub.exe -h
- name: Upload build - name: Upload build
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: ytdl-sub_exe name: ytdl-sub_exe
path: .\dist\ytdl-sub.exe path: .\dist\ytdl-sub.exe
@ -145,19 +152,19 @@ jobs:
echo '${{ needs.version.outputs.init_contents }}' > src/ytdl_sub/__init__.py echo '${{ needs.version.outputs.init_contents }}' > src/ytdl_sub/__init__.py
- name: Restore exe build - name: Restore exe build
uses: actions/download-artifact@v3 uses: actions/download-artifact@v4
with: with:
name: ytdl-sub_exe name: ytdl-sub_exe
path: /opt/builds path: /opt/builds
- name: Restore aarch64 build - name: Restore aarch64 build
uses: actions/download-artifact@v3 uses: actions/download-artifact@v4
with: with:
name: ytdl-sub_aarch64 name: ytdl-sub_aarch64
path: /opt/builds path: /opt/builds
- name: Restore x86_64 build - name: Restore x86_64 build
uses: actions/download-artifact@v3 uses: actions/download-artifact@v4
with: with:
name: ytdl-sub_x86_64 name: ytdl-sub_x86_64
path: /opt/builds path: /opt/builds
@ -193,13 +200,13 @@ jobs:
name: pypi-publish name: pypi-publish
needs: needs:
- version - version
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-python@v4 - uses: actions/setup-python@v4
with: with:
python-version: '3.10' python-version: '3.12'
- name: Write version to init file - name: Write version to init file
run: | run: |
echo '${{ needs.version.outputs.init_contents }}' > src/ytdl_sub/__init__.py echo '${{ needs.version.outputs.init_contents }}' > src/ytdl_sub/__init__.py

5
.gitignore vendored
View file

@ -146,8 +146,11 @@ docker/testing/volumes
.local/ .local/
.ytdl-sub-working-directory .ytdl-sub-working-directory
.ytdl-sub-lock
ffmpeg.exe ffmpeg.exe
ffprobe.exe ffprobe.exe
tools/docgen/out tools/docgen/out
prof/

View file

@ -7,6 +7,7 @@ build:
sphinx: sphinx:
configuration: docs/source/conf.py configuration: docs/source/conf.py
fail_on_warning: true
python: python:
install: install:
@ -14,4 +15,4 @@ python:
- method: pip - method: pip
path: . path: .
extra_requirements: extra_requirements:
- docs - docs

View file

@ -1,7 +1,20 @@
# Defensive settings for make:
# https://tech.davis-hansson.com/p/make/
SHELL:=bash
.ONESHELL:
.SHELLFLAGS:=-eu -o pipefail -c
.SILENT:
.DELETE_ON_ERROR:
MAKEFLAGS+=--warn-undefined-variables
MAKEFLAGS+=--no-builtin-rules
export PS1?=$$
# Prefix echoed recipe commands with the recipe line number for debugging:
export PS4?=:$$LINENO+
# Get version related variables # Get version related variables
export DATE=$(shell date +'%Y.%m.%d') export DATE:=$(shell date +'%Y.%m.%d')
export DATE_COMMIT_COUNT=$(shell git rev-list --count HEAD --since="$(DATE) 00:00:00") export DATE_COMMIT_COUNT:=$(shell git rev-list --count HEAD --since="$(DATE) 00:00:00")
export COMMIT_HASH=$(shell git rev-parse --short HEAD) export COMMIT_HASH:=$(shell git rev-parse --short HEAD)
# Set Local version to YYYY.MM.DD-<hash> # Set Local version to YYYY.MM.DD-<hash>
export LOCAL_VERSION="$(DATE)+$(COMMIT_HASH)" export LOCAL_VERSION="$(DATE)+$(COMMIT_HASH)"
@ -13,10 +26,19 @@ else
export PYPI_VERSION="$(DATE).post$(DATE_COMMIT_COUNT)" export PYPI_VERSION="$(DATE).post$(DATE_COMMIT_COUNT)"
endif endif
# Finished with `$(shell)`, echo recipe commands going forward
.SHELLFLAGS+= -x
### Top-level targets:
.PHONY: all
all: check_lint docs docker docker_ubuntu docker_gui
lint: lint:
@-isort . python3 -m isort .
@-black . python3 -m black .
@-pylint src/ python3 -m pylint src
check_lint: check_lint:
isort . --check-only --diff \ isort . --check-only --diff \
&& black . --check \ && black . --check \
@ -41,7 +63,8 @@ executable: clean
mv dist/ytdl-sub dist/ytdl-sub${EXEC_SUFFIX} mv dist/ytdl-sub dist/ytdl-sub${EXEC_SUFFIX}
docs: docs:
REGENERATE_DOCS=1 pytest tests/unit/docgen/test_docgen.py REGENERATE_DOCS=1 pytest tests/unit/docgen/test_docgen.py
sphinx-build -M html docs/source/ docs/build/ sphinx-build --write-all --fail-on-warning --nitpicky -b html \
"./docs/source/" "./docs/build/"
clean: clean:
rm -rf \ rm -rf \
.pytest_cache/ \ .pytest_cache/ \

View file

@ -71,7 +71,7 @@ __preset__:
cookiefile: "/config/cookie.txt" cookiefile: "/config/cookie.txt"
################################################################### ###################################################################
# TV Show Presets. Can replace Plex with Plex/Jellyfin/Kodi # TV Show Presets. Can replace Plex with Plex/Jellyfin/Emby/Kodi
Plex TV Show by Date: Plex TV Show by Date:
@ -106,7 +106,7 @@ Plex TV Show Collection:
s02_url: "https://www.youtube.com/playlist?list=PLE62gWlWZk5NWVAVuf0Lm9jdv_-_KXs0W" s02_url: "https://www.youtube.com/playlist?list=PLE62gWlWZk5NWVAVuf0Lm9jdv_-_KXs0W"
################################################################### ###################################################################
# Music Presets. Can replace Plex with Plex/Jellyfin/Kodi # Music Presets.
YouTube Releases: YouTube Releases:
= Jazz: # Sets genre tag to "Jazz" = Jazz: # Sets genre tag to "Jazz"
@ -128,7 +128,7 @@ Bandcamp:
"Emily Hopkins": "https://emilyharpist.bandcamp.com/" "Emily Hopkins": "https://emilyharpist.bandcamp.com/"
################################################################### ###################################################################
# Music Video Presets # Music Video Presets. Can replace Plex with Plex/Jellyfin/Kodi
"Plex Music Videos": "Plex Music Videos":
= Pop: # Sets genre tag to "Pop" = Pop: # Sets genre tag to "Pop"
"Rick Astley": "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc" "Rick Astley": "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc"

View file

@ -7,16 +7,20 @@ FROM ghcr.io/linuxserver/baseimage-alpine:edge
ENV OPENSSL_CONF="/etc/ssl" ENV OPENSSL_CONF="/etc/ssl"
# For downloading thumbnails # For downloading thumbnails
ENV SSL_CERT_DIR="/etc/ssl/certs/" ENV SSL_CERT_DIR="/etc/ssl/certs/"
# Working directory used at both build and run times:
ENV DEFAULT_WORKSPACE="/config"
COPY root/ / COPY root/ /
RUN mkdir -p /config && \ RUN mkdir -pv "${DEFAULT_WORKSPACE}" && \
apk update --no-cache && \ apk update --no-cache && \
apk upgrade --no-cache && \ apk upgrade --no-cache && \
apk add --no-cache --repository=http://dl-3.alpinelinux.org/alpine/edge/main/ \ apk add --no-cache --repository=http://dl-3.alpinelinux.org/alpine/edge/main/ \
vim \ vim \
g++ \ g++ \
nano \ nano \
unzip \
make \ make \
deno \
libffi-dev \ libffi-dev \
"python3>=3.10" \ "python3>=3.10" \
py3-pip \ py3-pip \
@ -28,35 +32,44 @@ RUN mkdir -p /config && \
"aria2>=1.36.0" && \ "aria2>=1.36.0" && \
ffmpeg -version && \ ffmpeg -version && \
aria2c --version && \ aria2c --version && \
deno --version && \
# Install phantomjs if using x86_64, ensure it is properly installed # Install phantomjs if using x86_64, ensure it is properly installed
if [[ $(uname -m) == "x86_64" ]]; then \ if [[ $(uname -m) == "x86_64" ]]; then \
echo "installing phantomjs" && \
apk add --no-cache gcompat && \ apk add --no-cache gcompat && \
cd /usr/share && \ tar -xjvf /defaults/phantomjs-2.1.1-linux-x86_64.tar.bz2 && \
curl -L https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2 | tar xj && \ mv phantomjs-2.1.1-linux-x86_64/bin/phantomjs /usr/share/phantomjs && \
mv /usr/share/phantomjs-2.1.1-linux-x86_64/bin/phantomjs phantomjs && \ rm -rf phantomjs-2.1.1-linux-x86_64 && \
rm -rf /usr/share/phantomjs-2.1.1-linux-x86_64 && \ rm /defaults/phantomjs-2.1.1-linux-x86_64.tar.bz2 && \
ln -s /usr/share/phantomjs /usr/bin/phantomjs && \ ln -s /usr/share/phantomjs /usr/bin/phantomjs && \
echo "Phantom JS version:" && \ echo "Phantom JS version:" && \
phantomjs --version && \ phantomjs --version && \
cd -; \ cd -; \
fi && \ fi && \
echo "hi" && \ # Configure pip globally
# Install ytdl-sub, ensure it is installed properly echo -e "[global]\nbreak-system-packages = true\nroot-user-action = ignore\nno-cache-dir = true" > /etc/pip.conf && \
python3 -m pip install --break-system-packages --no-cache-dir ytdl_sub-*.whl && \ # Install ytdl-sub and yt-dlp dependencies, ensure they are installed properly
python3 -m pip install ytdl_sub-*.whl curl-cffi yt-dlp-ejs && \
ytdl-sub -h && \ ytdl-sub -h && \
# Delete unneeded packages after install # Delete unneeded packages after install
rm ytdl_sub-*.whl && \ rm ytdl_sub-*.whl && \
apk del \ apk del \
g++ \ g++ \
make \ make \
libffi-dev \ libffi-dev && \
py3-pip \ python3 -m pip --help
py3-setuptools
############################################################################### ###############################################################################
# CONTAINER CONFIGS # CONTAINER CONFIGS
ENV EDITOR="nano" \ ENV EDITOR="nano" \
HOME="/config" HOME="${DEFAULT_WORKSPACE}" \
DOCKER_MODS=linuxserver/mods:universal-stdout-logs|linuxserver/mods:universal-cron \
CRON_SCRIPT="${DEFAULT_WORKSPACE}/cron" \
CRON_WRAPPER_SCRIPT="${DEFAULT_WORKSPACE}/.cron_wrapper" \
LOGS_TO_STDOUT="${DEFAULT_WORKSPACE}/.cron.log" \
LSIO_FIRST_PARTY=false
VOLUME /config VOLUME "${DEFAULT_WORKSPACE}"
WORKDIR "${DEFAULT_WORKSPACE}"

View file

@ -1,9 +1,11 @@
FROM lscr.io/linuxserver/code-server:4.18.0-ls181 FROM lscr.io/linuxserver/code-server:4.98.2
# For phantomjs # For phantomjs
ENV OPENSSL_CONF="/etc/ssl" ENV OPENSSL_CONF="/etc/ssl"
# For downloading thumbnails # For downloading thumbnails
ENV SSL_CERT_DIR="/etc/ssl/certs/" ENV SSL_CERT_DIR="/etc/ssl/certs/"
# Working directory used at both build and run times:
ENV DEFAULT_WORKSPACE="/config/ytdl-sub-configs"
############################################################################### ###############################################################################
# YTDL-SUB INSTALL # YTDL-SUB INSTALL
@ -21,8 +23,8 @@ RUN mkdir -p /config && \
vim \ vim \
g++ \ g++ \
nano \ nano \
unzip \
make \ make \
python3.10-dev \
python3-pip \ python3-pip \
fontconfig \ fontconfig \
xz-utils \ xz-utils \
@ -52,16 +54,21 @@ RUN mkdir -p /config && \
ffmpeg -version && \ ffmpeg -version && \
# Install phantomjs if using x86_64, ensure it is properly installed # Install phantomjs if using x86_64, ensure it is properly installed
if [[ $(uname -m) == "x86_64" ]]; then \ if [[ $(uname -m) == "x86_64" ]]; then \
curl -L -o phantomjs.tar.bz2 https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2 && \ echo "installing phantomjs" && \
tar -xvf phantomjs.tar.bz2 && \ tar -xjvf /defaults/phantomjs-2.1.1-linux-x86_64.tar.bz2 && \
mv phantomjs-2.1.1-linux-x86_64/bin/phantomjs /usr/bin/phantomjs && \ mv phantomjs-2.1.1-linux-x86_64/bin/phantomjs /usr/bin/phantomjs && \
rm -rf phantomjs-2.1.1-linux-x86_64/ && \ rm -rf phantomjs-2.1.1-linux-x86_64 && \
rm phantomjs.tar.bz2 && \ rm /defaults/phantomjs-2.1.1-linux-x86_64.tar.bz2 && \
echo "Phantom JS version:" && \ echo "Phantom JS version:" && \
phantomjs --version ; \ phantomjs --version ; \
fi && \ fi && \
# Install ytdl-sub, ensure it is installed properly # Install Deno, required for YouTube downloads
pip install --no-cache-dir ytdl_sub-*.whl && \ curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y --no-modify-path && \
deno --help && \
# Configure pip globally
echo -e "[global]\nbreak-system-packages = true\nroot-user-action = ignore\nno-cache-dir = true" > /etc/pip.conf && \
# Install ytdl-sub and yt-dlp dependencies, ensure they are installed properly
python3 -m pip install ytdl_sub-*.whl curl-cffi yt-dlp-ejs && \
ytdl-sub -h && \ ytdl-sub -h && \
# Delete unneeded packages after install # Delete unneeded packages after install
rm ytdl_sub-*.whl && \ rm ytdl_sub-*.whl && \
@ -69,21 +76,22 @@ RUN mkdir -p /config && \
g++ \ g++ \
make \ make \
xz-utils \ xz-utils \
bzip2 \ bzip2 && \
python3.10-dev \
python3-venv && \
apt-get autoremove -y && \ apt-get autoremove -y && \
apt-get purge -y --auto-remove && \ apt-get purge -y --auto-remove && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/* && \
python3 -m pip --help
############################################################################### ###############################################################################
# CONTAINER CONFIGS # CONTAINER CONFIGS
ENV EDITOR="nano" \
ENV YTDL_SUB_TYPE="gui" \
EDITOR="nano" \
HOME="/config" \ HOME="/config" \
DOCKER_MODS=linuxserver/mods:universal-cron \ DOCKER_MODS=linuxserver/mods:universal-stdout-logs|linuxserver/mods:universal-cron \
DEFAULT_WORKSPACE=/config/ytdl-sub-configs CRON_SCRIPT="${DEFAULT_WORKSPACE}/cron" \
CRON_WRAPPER_SCRIPT="/config/.cron_wrapper" \
LOGS_TO_STDOUT=/config/.cron.log \
LSIO_FIRST_PARTY=false
VOLUME /config VOLUME /config
WORKDIR "${DEFAULT_WORKSPACE}"

1
docker/Dockerfile.headless Symbolic link
View file

@ -0,0 +1 @@
Dockerfile

View file

@ -1,4 +1,4 @@
FROM ghcr.io/linuxserver/baseimage-ubuntu:jammy FROM ghcr.io/linuxserver/baseimage-ubuntu:noble
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable # https://askubuntu.com/questions/972516/debian-frontend-environment-variable
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
@ -7,13 +7,15 @@ ARG DEBIAN_FRONTEND=noninteractive
ENV OPENSSL_CONF="/etc/ssl" ENV OPENSSL_CONF="/etc/ssl"
# For downloading thumbnails # For downloading thumbnails
ENV SSL_CERT_DIR="/etc/ssl/certs/" ENV SSL_CERT_DIR="/etc/ssl/certs/"
# Working directory used at both build and run times:
ENV DEFAULT_WORKSPACE="/config"
############################################################################### ###############################################################################
# YTDL-SUB INSTALL # YTDL-SUB INSTALL
SHELL ["/bin/bash", "-c"] SHELL ["/bin/bash", "-c"]
COPY root/ / COPY root/ /
RUN mkdir -p /config && \ RUN mkdir -pv "${DEFAULT_WORKSPACE}" && \
apt-get -y update && \ apt-get -y update && \
apt-get -y upgrade && \ apt-get -y upgrade && \
apt-get install --no-install-recommends -y \ apt-get install --no-install-recommends -y \
@ -24,8 +26,8 @@ RUN mkdir -p /config && \
vim \ vim \
g++ \ g++ \
nano \ nano \
unzip \
make \ make \
python3.10-dev \
python3-pip \ python3-pip \
fontconfig \ fontconfig \
xz-utils \ xz-utils \
@ -55,16 +57,21 @@ RUN mkdir -p /config && \
ffmpeg -version && \ ffmpeg -version && \
# Install phantomjs if using x86_64, ensure it is properly installed # Install phantomjs if using x86_64, ensure it is properly installed
if [[ $(uname -m) == "x86_64" ]]; then \ if [[ $(uname -m) == "x86_64" ]]; then \
curl -L -o phantomjs.tar.bz2 https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2 && \ echo "installing phantomjs" && \
tar -xvf phantomjs.tar.bz2 && \ tar -xjvf /defaults/phantomjs-2.1.1-linux-x86_64.tar.bz2 && \
mv phantomjs-2.1.1-linux-x86_64/bin/phantomjs /usr/bin/phantomjs && \ mv phantomjs-2.1.1-linux-x86_64/bin/phantomjs /usr/bin/phantomjs && \
rm -rf phantomjs-2.1.1-linux-x86_64/ && \ rm -rf phantomjs-2.1.1-linux-x86_64 && \
rm phantomjs.tar.bz2 && \ rm /defaults/phantomjs-2.1.1-linux-x86_64.tar.bz2 && \
echo "Phantom JS version:" && \ echo "Phantom JS version:" && \
phantomjs --version ; \ phantomjs --version ; \
fi && \ fi && \
# Install ytdl-sub, ensure it is installed properly # Install Deno, required for YouTube downloads
pip install --no-cache-dir ytdl_sub-*.whl && \ curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y --no-modify-path && \
deno --help && \
# Configure pip globally
echo -e "[global]\nbreak-system-packages = true\nroot-user-action = ignore\nno-cache-dir = true" > /etc/pip.conf && \
# Install ytdl-sub and yt-dlp dependencies, ensure they are installed properly
python3 -m pip install ytdl_sub-*.whl curl-cffi yt-dlp-ejs && \
ytdl-sub -h && \ ytdl-sub -h && \
# Delete unneeded packages after install # Delete unneeded packages after install
rm ytdl_sub-*.whl && \ rm ytdl_sub-*.whl && \
@ -72,17 +79,22 @@ RUN mkdir -p /config && \
g++ \ g++ \
make \ make \
xz-utils \ xz-utils \
bzip2 \ bzip2 && \
python3.10-dev \
python3-venv && \
apt-get autoremove -y && \ apt-get autoremove -y && \
apt-get purge -y --auto-remove && \ apt-get purge -y --auto-remove && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/* && \
python3 -m pip --help
############################################################################### ###############################################################################
# CONTAINER CONFIGS # CONTAINER CONFIGS
ENV EDITOR="nano" \ ENV EDITOR="nano" \
HOME="/config" HOME="${DEFAULT_WORKSPACE}" \
DOCKER_MODS=linuxserver/mods:universal-stdout-logs|linuxserver/mods:universal-cron \
CRON_SCRIPT="${DEFAULT_WORKSPACE}/cron" \
CRON_WRAPPER_SCRIPT="${DEFAULT_WORKSPACE}/.cron_wrapper" \
LOGS_TO_STDOUT="${DEFAULT_WORKSPACE}/.cron.log" \
LSIO_FIRST_PARTY=false
VOLUME /config VOLUME "${DEFAULT_WORKSPACE}"
WORKDIR "${DEFAULT_WORKSPACE}"

View file

@ -0,0 +1,84 @@
#!/usr/bin/with-contenv bash
echo "Starting ytdl-sub..."
# copy config
[[ ! -e "$DEFAULT_WORKSPACE/config.yaml" ]] && \
mkdir -p "$DEFAULT_WORKSPACE" && \
cp /defaults/config.yaml "$DEFAULT_WORKSPACE/config.yaml"
[[ ! -e "$DEFAULT_WORKSPACE/subscriptions.yaml" ]] && \
mkdir -p "$DEFAULT_WORKSPACE" && \
cp /defaults/subscriptions.yaml "$DEFAULT_WORKSPACE/subscriptions.yaml"
[[ ! -d "$DEFAULT_WORKSPACE/examples" ]] && \
mkdir -p "$DEFAULT_WORKSPACE/examples" && \
cp -r /defaults/examples/* "$DEFAULT_WORKSPACE/examples"
[[ ! -e "/config/.bashrc" ]] && \
echo "alias ls='ls --color=auto'" > /config/.bashrc && \
echo "cd ." >> /config/.bashrc
# always create empty cron log file on start
echo "" > "$LOGS_TO_STDOUT"
# permissions
chown -R ${PUID:-abc}:${PGID:-abc} \
/config
# update command reference:
# https://github.com/yt-dlp/yt-dlp/wiki/Installation#with-pip
if [ "$UPDATE_YT_DLP_ON_START" == "stable" ] ; then
echo "UPDATE_YT_DLP_ON_START is set to stable, attempting to update to a new stable version of yt-dlp if it exists."
python3 -m pip install -U "yt-dlp[default]"
elif [ "$UPDATE_YT_DLP_ON_START" == "nightly" ] ; then
echo "UPDATE_YT_DLP_ON_START is set to nightly, attempting to update to the latest nightly version of yt-dlp."
python3 -m pip install -U --pre "yt-dlp[default]"
elif [ "$UPDATE_YT_DLP_ON_START" == "master" ] ; then
echo "UPDATE_YT_DLP_ON_START is set to master, pulling yt-dlp's latest commit for install."
python3 -m pip install -U pip hatchling wheel
python3 -m pip install --force-reinstall "yt-dlp[default] @ https://github.com/yt-dlp/yt-dlp/archive/master.tar.gz"
else
echo "UPDATE_YT_DLP_ON_START is not set, using packaged version."
fi
# set up cron
if [ "$CRON_SCHEDULE" != "" ] ; then
[[ ! -e "$CRON_SCRIPT" ]] && \
cp /defaults/cron "$CRON_SCRIPT"
# create cron script wrapper
echo '#!/bin/bash' > "$CRON_WRAPPER_SCRIPT"
echo "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" >> "$CRON_WRAPPER_SCRIPT"
echo "cd \"$DEFAULT_WORKSPACE\"" >> "$CRON_WRAPPER_SCRIPT"
echo ". \"$CRON_SCRIPT\" >> \"$LOGS_TO_STDOUT\" 2>&1" >> "$CRON_WRAPPER_SCRIPT"
chmod +x "$CRON_WRAPPER_SCRIPT"
chown abc:abc "$CRON_WRAPPER_SCRIPT"
# Set the crontab file to the schedule, cleanly
CRON_SCHEDULE_CLEAN="${CRON_SCHEDULE//\"/}"
CRON_SCHEDULE_CLEAN="${CRON_SCHEDULE_CLEAN//\'/}"
echo "# min hour day month weekday command" > /config/crontabs/abc
echo "$CRON_SCHEDULE_CLEAN $CRON_WRAPPER_SCRIPT" >> /config/crontabs/abc
chmod +x "$CRON_SCRIPT"
chown abc:abc "$CRON_SCRIPT"
crontab -u abc /config/crontabs/abc
CRON_SUCCESS=$?
if [ $CRON_SUCCESS -eq 0 ] ; then
echo "Cron enabled with schedule $CRON_SCHEDULE_CLEAN"
if [ "$CRON_RUN_ON_START" = true ] ; then
echo "Running cron script on start in the background"
# ensure it runs as abc to respect puid/guid with delay for tail to start
su -s "/bin/bash" -c "sleep 5 && . '$CRON_WRAPPER_SCRIPT'" abc > /dev/null 2>&1 &
fi
else
echo "Error in CRON_SCHEDULE definition, disabling cron."
exit 1
fi
else
echo "CRON_SCHEDULE not specified, leaving crontabs as-is. Current configuration in /config/crontabs/abc"
cat /config/crontabs/abc
fi

View file

@ -1,23 +0,0 @@
#!/usr/bin/with-contenv bash
# Exit if not gui
if [ "$YTDL_SUB_TYPE" != "gui" ] ; then
exit 0
fi
echo "Checking ytdl-sub-gui defaults..."
# copy config
[[ ! -e /config/ytdl-sub-configs/config.yaml ]] && \
mkdir -p /config/ytdl-sub-configs && \
cp /defaults/config.yaml /config/ytdl-sub-configs/config.yaml
[[ ! -e /config/ytdl-sub-configs/subscriptions.yaml ]] && \
mkdir -p /config/ytdl-sub-configs && \
cp /defaults/subscriptions.yaml /config/ytdl-sub-configs/subscriptions.yaml
[[ ! -d /config/ytdl-sub-configs/examples ]] && \
mkdir -p /config/ytdl-sub-configs/examples && \
cp /defaults/examples/* /config/ytdl-sub-configs/examples
# permissions
chown -R ${PUID:-abc}:${PGID:-abc} \
/config

View file

@ -1,20 +0,0 @@
#!/usr/bin/with-contenv bash
# Exit if gui
if [ "$YTDL_SUB_TYPE" == "gui" ] ; then
exit 0
fi
echo "Checking ytdl-sub defaults..."
# copy config
[[ ! -e /config/config.yaml ]] && \
cp /defaults/config.yaml /config/config.yaml
[[ ! -e /config/subscriptions.yaml ]] && \
cp /defaults/subscriptions.yaml /config/subscriptions.yaml
[[ ! -d /config/examples ]] && \
cp -R /defaults/examples /config/
# permissions
chown -R ${PUID:-abc}:${PGID:-abc} \
/config

View file

@ -16,4 +16,7 @@
# See the documentation above on how to build your own custom presets. # See the documentation above on how to build your own custom presets.
# #
configuration: configuration:
# Avoid unnecessarily long large file renames, set this to a path on the same
# filesystem as the destination for downloaded files in the `overrides: /
# *_directory:` paths:
working_directory: ".ytdl-sub-working-directory" working_directory: ".ytdl-sub-working-directory"

View file

@ -0,0 +1,4 @@
echo "Beginning cron job..."
# Place your ytdl-sub command(s) here.
# This script is executed in the same relative path as this file.

View file

@ -15,12 +15,12 @@ __preset__:
music_video_directory: "/music_videos" music_video_directory: "/music_videos"
# For 'Only Recent' preset, only keep vids within this range and limit # For 'Only Recent' preset, only keep vids within this range and limit
only_recent_date_range: "2months" # only_recent_date_range: "2months"
only_recent_max_files: 30 # only_recent_max_files: 30
# Pass any arg directly to yt-dlp's Python API # Pass any arg directly to yt-dlp's Python API
ytdl_options: # ytdl_options:
cookiefile: "/config/cookie.txt" # cookiefile: "/config/cookie.txt"
################################################################### ###################################################################
# Subscriptions nested under this will use the # Subscriptions nested under this will use the
@ -35,52 +35,54 @@ Plex TV Show by Date:
# Sets genre tag to "Documentaries" # Sets genre tag to "Documentaries"
= Documentaries: = Documentaries:
"NOVA PBS": "https://www.youtube.com/@novapbs" "NOVA PBS": "https://www.youtube.com/@novapbs"
"National Geographic": "https://www.youtube.com/@NatGeo" # "National Geographic": "https://www.youtube.com/@NatGeo"
"Cosmos - What If": "https://www.youtube.com/playlist?list=PLZdXRHYAVxTJno6oFF9nLGuwXNGYHmE8U" # "Cosmos - What If": "https://www.youtube.com/playlist?list=PLZdXRHYAVxTJno6oFF9nLGuwXNGYHmE8U"
# Sets genre tag to "Kids", "TV-Y" for content rating # Sets genre tag to "Kids", "TV-Y" for content rating
= Kids | = TV-Y: # = Kids | = TV-Y:
"Jake Trains": "https://www.youtube.com/@JakeTrains" # "Jake Trains": "https://www.youtube.com/@JakeTrains"
"Kids Toys Play": "https://www.youtube.com/@KidsToysPlayChannel" # "Kids Toys Play": "https://www.youtube.com/@KidsToysPlayChannel"
= Music: # = Music:
# TV show subscriptions can support multiple urls and store in the same TV Show # # TV show subscriptions can support multiple urls and store in the same TV Show
"Rick Beato": # "Rick Beato":
- "https://www.youtube.com/@RickBeato" # - "https://www.youtube.com/@RickBeato"
- "https://www.youtube.com/@rickbeato240" # - "https://www.youtube.com/@rickbeato240"
# Set genre tag to "News", use `Only Recent` preset to only store videos uploaded recently # Set genre tag to "News", use `Only Recent` preset to only store videos uploaded recently
= News | Only Recent: # = News | Only Recent:
"BBC News": "https://www.youtube.com/@BBCNews" # "BBC News": "https://www.youtube.com/@BBCNews"
################################################################### ###################################################################
# Subscriptions nested under these will use the various prebuilt # Subscriptions nested under these will use the various prebuilt
# music presets # music presets
YouTube Releases:
= Jazz: # Sets genre tag to "Jazz"
"Thelonious Monk": "https://www.youtube.com/@theloniousmonk3870/releases"
YouTube Full Albums: # YouTube Releases:
= Lofi: # = Jazz: # Sets genre tag to "Jazz"
"Game Chops": "https://www.youtube.com/playlist?list=PLBsm_SagFMmdWnCnrNtLjA9kzfrRkto4i" # "Thelonious Monk": "https://www.youtube.com/@theloniousmonk3870/releases"
SoundCloud Discography: # YouTube Full Albums:
= Chill Hop: # = Lofi:
"UKNOWY": "https://soundcloud.com/uknowymunich" # "Game Chops": "https://www.youtube.com/playlist?list=PLBsm_SagFMmdWnCnrNtLjA9kzfrRkto4i"
= Synthwave:
"Lazerdiscs Records": "https://soundcloud.com/lazerdiscsrecords"
"Earmake": "https://soundcloud.com/earmake"
Bandcamp: # SoundCloud Discography:
= Lofi: # = Chill Hop:
"Emily Hopkins": "https://emilyharpist.bandcamp.com/" # "UKNOWY": "https://soundcloud.com/uknowymunich"
# = Synthwave:
# "Lazerdiscs Records": "https://soundcloud.com/lazerdiscsrecords"
# "Earmake": "https://soundcloud.com/earmake"
# Bandcamp:
# = Lofi:
# "Emily Hopkins": "https://emilyharpist.bandcamp.com/"
################################################################### ###################################################################
# Can choose between: # Can choose between:
# - Plex Music Videos: # - Plex Music Videos:
# - Jellyfin Music Videos: # - Jellyfin Music Videos:
# - Kodi Music Videos: # - Kodi Music Videos:
"Plex Music Videos":
= Pop: # Sets genre tag to "Pop" # "Plex Music Videos":
"Rick Astley": "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc" # = Pop: # Sets genre tag to "Pop"
"Michael Jackson": "https://www.youtube.com/playlist?list=OLAK5uy_mnY03zP6abNWH929q2XhGzWD_2uKJ_n8E" # "Rick Astley": "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc"
# "Michael Jackson": "https://www.youtube.com/playlist?list=OLAK5uy_mnY03zP6abNWH929q2XhGzWD_2uKJ_n8E"

View file

@ -0,0 +1,15 @@
――――――――――――――――――――――――――――――――――――
██╗ ██╗████████╗██████╗ ██╗
╚██╗ ██╔╝╚══██╔══╝██╔══██╗██║
╚████╔╝ ██║ ██║ ██║██║
╚██╔╝ ██║ ██║ ██║██║
██║ ██║ ██████╔╝███████╗
╚═╝ ╚═╝ ╚═════╝ ╚══════╝
███████╗██╗ ██╗██████╗
██╔════╝██║ ██║██╔══██╗
███████╗██║ ██║██████╔╝
╚════██║██║ ██║██╔══██╗
███████║╚██████╔╝██████╔╝
╚══════╝ ╚═════╝ ╚═════╝
――――――――――――――――――――――――――――――――――――

46
docker/testing/Makefile Normal file
View file

@ -0,0 +1,46 @@
# Local building and testing of the Docker image variants.
# Defensive settings for make:
# https://tech.davis-hansson.com/p/make/
SHELL:=bash
.ONESHELL:
.SHELLFLAGS:=-eu -o pipefail -c
.SILENT:
.DELETE_ON_ERROR:
MAKEFLAGS+=--warn-undefined-variables
MAKEFLAGS+=--no-builtin-rules
export PS1?=$$
# Prefix echoed recipe commands with the recipe line number for debugging:
export PS4?=:$$LINENO+
VARIANTS=headless gui ubuntu
ROOT_PREREQS:=$(shell find ../root -type f)
# Finished with `$(shell)`, echo recipe commands going forward
.SHELLFLAGS+= -x
### Top-level targets:
.PHONY: all
all: build
.PHONY: build
build: $(VARIANTS:%=./build/ytdl-sub-%.log)
.PHONY: run
run: build $(VARIANTS:%=./volumes/ytdl-sub-%/)
docker compose up
### Real targets:
# Re-build the local images when changes require it.
./build/ytdl-sub-%.log: ../Dockerfile.% $(ROOT_PREREQS)
mkdir -pv "$(dir $(@))"
docker compose build "$(@:build/ytdl-sub-%.log=ytdl-sub-%)" |&
tee -a "$(@)"
# Ensure volumes are owned by the developer's normal user:
./volumes/ytdl-sub-%/:
mkdir -pv "$(@)"

View file

@ -1,13 +1,50 @@
services: services:
ytdl-sub-gui: ytdl-sub-gui:
image: ytdl-sub-gui:local build:
container_name: ytdl-sub-gui context: "../"
dockerfile: "./Dockerfile.gui"
image: "ytdl-sub-gui:local"
container_name: "ytdl-sub-gui"
environment: environment:
- PUID=1000 PUID: "1000"
- PGID=1000 PGID: "1000"
- TZ=America/Los_Angeles TZ: "America/Los_Angeles"
CRON_SCHEDULE: '*/1 * * * *'
CRON_RUN_ON_START: "true"
UPDATE_YT_DLP_ON_START: "stable"
volumes: volumes:
- ./volumes/ytdl-sub-gui:/config - "./volumes/ytdl-sub-gui/:/config/"
ports: ports:
- 8443:8443 - "8443:8443"
restart: unless-stopped restart: "unless-stopped"
ytdl-sub-headless:
build:
context: "../"
image: "ytdl-sub:local"
container_name: "ytdl-sub-headless"
environment:
PUID: "1000"
PGID: "1000"
TZ: "America/Los_Angeles"
CRON_SCHEDULE: '*/1 * * * *'
CRON_RUN_ON_START: "true"
UPDATE_YT_DLP_ON_START: "stable"
volumes:
- "./volumes/ytdl-sub-headless/:/config/"
restart: "unless-stopped"
ytdl-sub-ubuntu:
build:
context: "../"
dockerfile: "./Dockerfile.ubuntu"
image: "ytdl-sub-ubuntu:local"
container_name: "ytdl-sub-ubuntu"
environment:
PUID: "1000"
PGID: "1000"
TZ: "America/Los_Angeles"
CRON_SCHEDULE: '*/1 * * * *'
CRON_RUN_ON_START: "true"
UPDATE_YT_DLP_ON_START: "stable"
volumes:
- "./volumes/ytdl-sub-ubuntu/:/config/"
restart: "unless-stopped"

View file

@ -1,3 +1,10 @@
..
WARNING: This RST file is generated from docstrings in:
The respective function docstrings within ytdl_sub/config/config_validator.py
In order to make a change to this file, edit the respective docstring
and run `make docs`. This will automatically sync the Python RST-based
docstrings into this file. If the docstrings and RST file are out of sync,
it will fail TestDocGen tests in GitHub CI.
Configuration File Configuration File
================== ==================
@ -38,25 +45,38 @@ Enables modifying subscription files using info.json files using the argument
``--update-with-info-json``. This feature is still being tested and has the ability to ``--update-with-info-json``. This feature is still being tested and has the ability to
destroy files. Ensure you have a full backup before usage. You have been warned! destroy files. Ensure you have a full backup before usage. You have been warned!
``leaf_name``
Returns
-------
"name" from the first.element.of.the.name
ffmpeg_path ffmpeg_path
----------- -----------
Path to ffmpeg executable. Defaults to ``/usr/bin/ffmpeg`` for Linux, and Path to ffmpeg executable. (default ``/usr/bin/ffmpeg`` for Linux,
``ffmpeg.exe`` for Windows (in the same directory as ytdl-sub). ``./ffmpeg.exe`` in the same directory as ytdl-sub for Windows)
ffprobe_path ffprobe_path
------------ ------------
Path to ffprobe executable. Defaults to ``/usr/bin/ffprobe`` for Linux, and Path to ffprobe executable. (default ``/usr/bin/ffprobe`` for Linux,
``ffprobe.exe`` for Windows (in the same directory as ytdl-sub). ``./ffprobe.exe`` in the same directory as ytdl-sub for Windows)
file_name_max_bytes file_name_max_bytes
------------------- -------------------
Max file name size in bytes. Most OS's typically default to 255 bytes. Max file name size in bytes. Most OS's typically default to 255 bytes.
leaf_name
---------
Returns
-------
"name" from the first.element.of.the.name
lock_directory lock_directory
-------------- --------------
The directory to temporarily store file locks, which prevents multiple instances The directory to temporarily store file locks, which prevents multiple instances
of ``ytdl-sub`` from running. Note that file locks do not work on network-mounted of ``ytdl-sub`` from running. Note that file locks do not work on
directories. Ensure that this directory resides on the host machine. Defaults to ``/tmp``. network-mounted directories. Ensure that this directory resides on the host
machine. (default ``/tmp``)
persist_logs persist_logs
------------ ------------
@ -64,17 +84,27 @@ TODO(jessebannon) fill out
``keep_successful_logs`` ``keep_successful_logs``
Optional. Whether to store logs when downloading is successful. Defaults to True. If the ``persist_logs:`` key is in the configuration, then ``ytdl-sub`` *always*
writes log files for the subscription both for successful downloads and when it
encounters an error while downloading. When this key is ``False``, only write
log files for errors. (default ``True``)
``leaf_name``
Returns
-------
"name" from the first.element.of.the.name
``logs_directory`` ``logs_directory``
Required. The directory to store the logs in. Write log files to this directory with names like
``YYYY-mm-dd-HHMMSS.subscription_name.(success|error).log``. (required)
umask umask
----- -----
Umask (octal format) to apply to every created file. Defaults to "022". Umask in octal format to apply to every created file. (default ``022``)
working_directory working_directory
----------------- -----------------
The directory to temporarily store downloaded files before moving them into their final The directory to temporarily store downloaded files before moving them into their final
directory. Defaults to .ytdl-sub-working-directory directory. (default ``./.ytdl-sub-working-directory``)

View file

@ -2,11 +2,50 @@
Reference Reference
========= =========
This section contains direct references to the code of ``ytdl-sub`` and information on how it functions. This section contains direct references to the code of ``ytdl-sub`` and information on
how it functions.
Terminology
-----------
Must-know terminology:
- ``subscription``: URL(s) that you want to download with specific metadata
requirements.
- ``preset``: A media profile comprised of YAML configuration that can specify anything
from metadata layout, media quality, or any feature of ytdl-sub, to apply to
subscriptions. A preset can inherit other presets.
- ``prebuilt preset``: Presets that are included in ytdl-sub. These do most of the work
defining plugins, overrides, etc in order to make downloads ready for player
consumption.
- ``override``: Verb describing the act of overriding something in a preset. For
example, the TV Show presets practically expect you to *override* the URL variable to
tell ytdl-sub where to download from.
- ``override variables``: User-defined variables that are intended to *override*
something.
- ``subscription file``: The file to specify all of your subscriptions and some override
variables.
Intermediate terminology:
- ``plugin``: Modular logic to apply to a subscription. To use a plugin, it must be
defined in a preset.
- ``config file``: An optional file where you can define custom presets and other
advanced configuration.
- ``yt-dlp``: The underlying application that handles downloading for ytdl-sub.
Advanced terminology:
- ``entry variables``: Variables that derive from a downloaded yt-dlp entry (media).
- ``static variables``: Variables that do not have a dependency to entry variables.
- ``scripting``: Syntax that allows the use of entry variables, static variables, and
functions in override variables.
.. toctree:: .. toctree::
config_yaml config_yaml
subscription_yaml subscription_yaml
plugins plugins
scripting/index scripting/index
prebuilt_presets/index prebuilt_presets/index

View file

@ -1,3 +1,10 @@
..
WARNING: This RST file is generated from docstrings in:
The respective plugin files under src/ytdl_sub/plugins/
In order to make a change to this file, edit the respective docstring
and run `make docs`. This will automatically sync the Python RST-based
docstrings into this file. If the docstrings and RST file are out of sync,
it will fail TestDocGen tests in GitHub CI.
Plugins Plugins
======= =======
@ -29,6 +36,12 @@ Extracts audio from a video file.
this field can be set using an override variable to easily toggle whether this plugin this field can be set using an override variable to easily toggle whether this plugin
is enabled or not via Boolean. is enabled or not via Boolean.
``leaf_name``
Returns
-------
"name" from the first.element.of.the.name
``quality`` ``quality``
:expected type: Float :expected type: Float
@ -95,6 +108,12 @@ chapters and remove specific ones. Can also remove chapters using regex.
Defaults to False. Force keyframes at cuts when removing sections. This is slow due to Defaults to False. Force keyframes at cuts when removing sections. This is slow due to
needing a re-encode, but the resulting video may have fewer artifacts around the cuts. needing a re-encode, but the resulting video may have fewer artifacts around the cuts.
``leaf_name``
Returns
-------
"name" from the first.element.of.the.name
``remove_chapters_regex`` ``remove_chapters_regex``
:expected type: Optional[List[RegexString] :expected type: Optional[List[RegexString]
@ -130,9 +149,11 @@ Dates must adhere to a yt-dlp datetime. From their docs:
A string in the format YYYYMMDD or A string in the format YYYYMMDD or
(now|today|yesterday|date)[+-][0-9](microsecond|second|minute|hour|day|week|month|year)(s) (now|today|yesterday|date)[+-][0-9](microsecond|second|minute|hour|day|week|month|year)(s)
Valid examples are ``now-2weeks`` or ``20200101``. Can use override variables in this. Valid examples are ``now-2weeks`` or ``20200101``. Can use override variables in
Note that yt-dlp will round times to the closest day, meaning that `day` is the lowest this. Note that yt-dlp will round times to the closest day, meaning that `day` is
granularity possible. the lowest granularity possible. Also note that, considering time zones, it's best
to include a margin of an extra day on either side to be sure it includes the
intended download files.
:Usage: :Usage:
@ -141,18 +162,20 @@ granularity possible.
date_range: date_range:
before: "now" before: "now"
after: "today-2weeks" after: "today-2weeks"
breaks: True
type: "upload_date"
``after`` ``after``
:expected type: Optional[OverridesFormatter] :expected type: Optional[OverridesFormatter]
:description: :description:
Only download videos after this datetime. Only download videos after or on this datetime, inclusive.
``before`` ``before``
:expected type: Optional[OverridesFormatter] :expected type: Optional[OverridesFormatter]
:description: :description:
Only download videos before this datetime. Only download videos only before this datetime, not inclusive.
``breaks`` ``breaks``
@ -169,6 +192,19 @@ granularity possible.
this field can be set using an override variable to easily toggle whether this plugin this field can be set using an override variable to easily toggle whether this plugin
is enabled or not via Boolean. is enabled or not via Boolean.
``leaf_name``
Returns
-------
"name" from the first.element.of.the.name
``type``
:expected type: Optional[OverridesFormatter]
:description:
Which type of date to use. Must be either ``upload_date`` or ``release_date``.
Defaults to ``upload_date``.
---------------------------------------------------------------------------------------------------- ----------------------------------------------------------------------------------------------------
download download
@ -300,6 +336,12 @@ Also supports custom ffmpeg conversions:
The output file will use the extension specified in ``convert_to``. Post-processing args The output file will use the extension specified in ``convert_to``. Post-processing args
can still be set with ``convert_with`` set to ``yt-dlp``. can still be set with ``convert_with`` set to ``yt-dlp``.
``leaf_name``
Returns
-------
"name" from the first.element.of.the.name
---------------------------------------------------------------------------------------------------- ----------------------------------------------------------------------------------------------------
filter_exclude filter_exclude
@ -437,12 +479,18 @@ with a ``.nfo`` extension. You can add any values into the NFO.
``kodi_safe`` ``kodi_safe``
:expected type: Optional[Boolean] :expected type: OverridesBooleanFormatterValidator
:description: :description:
Defaults to False. Kodi does not support > 3-byte unicode characters, which include Defaults to False. Kodi does not support > 3-byte unicode characters, which include
emojis and some foreign language characters. Setting this to True will replace those emojis and some foreign language characters. Setting this to True will replace those
characters with '□'. characters with '□'.
``leaf_name``
Returns
-------
"name" from the first.element.of.the.name
``nfo_name`` ``nfo_name``
:expected type: EntryFormatter :expected type: EntryFormatter
@ -530,12 +578,18 @@ Usage:
``kodi_safe`` ``kodi_safe``
:expected type: Optional[Boolean] :expected type: OverridesBooleanFormatterValidator
:description: :description:
Defaults to False. Kodi does not support > 3-byte unicode characters, which include Defaults to False. Kodi does not support > 3-byte unicode characters, which include
emojis and some foreign language characters. Setting this to True will replace those emojis and some foreign language characters. Setting this to True will replace those
characters with '□'. characters with '□'.
``leaf_name``
Returns
-------
"name" from the first.element.of.the.name
``nfo_name`` ``nfo_name``
:expected type: EntryFormatter :expected type: EntryFormatter
@ -612,6 +666,8 @@ Defines where to output files and thumbnails after all post-processing has compl
maintain_download_archive: True maintain_download_archive: True
keep_files_before: now keep_files_before: now
keep_files_after: 19000101 keep_files_after: 19000101
keep_max_files: 1000
keep_files_date_eval: "{upload_date_standardized}"
``download_archive_name`` ``download_archive_name``
@ -657,6 +713,15 @@ Defines where to output files and thumbnails after all post-processing has compl
files before ``now``, which implies all files. Can be used in conjunction with files before ``now``, which implies all files. Can be used in conjunction with
``keep_max_files``. ``keep_max_files``.
``keep_files_date_eval``
:expected type: str
:description:
Uses this standardized date in the form of YYYY-MM-DD to record in the
download archive for a given entry. Subsequently, uses this value to
perform evaluation for keep_files_before/after and keep_max_files. Defaults
to the entry's upload_date_standardized variable.
``keep_max_files`` ``keep_max_files``
:expected type: Optional[OverridesFormatter] :expected type: Optional[OverridesFormatter]
@ -666,6 +731,12 @@ Defines where to output files and thumbnails after all post-processing has compl
Only keeps N most recently uploaded videos. If set to <= 0, ``keep_max_files`` will not be Only keeps N most recently uploaded videos. If set to <= 0, ``keep_max_files`` will not be
applied. Can be used in conjunction with ``keep_files_before`` and ``keep_files_after``. applied. Can be used in conjunction with ``keep_files_before`` and ``keep_files_after``.
``leaf_name``
Returns
-------
"name" from the first.element.of.the.name
``maintain_download_archive`` ``maintain_download_archive``
:expected type: Optional[Boolean] :expected type: Optional[Boolean]
@ -695,6 +766,14 @@ Defines where to output files and thumbnails after all post-processing has compl
:description: :description:
The output directory to store all media files downloaded. The output directory to store all media files downloaded.
``preserve_mtime``
:expected type: Optional[Boolean]
:description:
Preserve the video's original upload time as the file modification time.
When True, sets the file's mtime to match the video's upload_date from
yt-dlp metadata. Defaults to False.
``thumbnail_name`` ``thumbnail_name``
:expected type: Optional[EntryFormatter] :expected type: Optional[EntryFormatter]
@ -732,6 +811,16 @@ In addition, any override variable defined will automatically create a ``sanitiz
for use. In the example above, ``output_directory_sanitized`` will exist and perform for use. In the example above, ``output_directory_sanitized`` will exist and perform
sanitization on the value when used. sanitization on the value when used.
``dict_with_parsed_format_strings``
Returns dict with the parsed format strings.
``leaf_name``
Returns
-------
"name" from the first.element.of.the.name
---------------------------------------------------------------------------------------------------- ----------------------------------------------------------------------------------------------------
split_by_chapters split_by_chapters
@ -754,6 +843,12 @@ used with no modifications.
split_by_chapters: split_by_chapters:
when_no_chapters: "pass" when_no_chapters: "pass"
``leaf_name``
Returns
-------
"name" from the first.element.of.the.name
``when_no_chapters`` ``when_no_chapters``
:expected type: String :expected type: String
@ -769,6 +864,97 @@ used with no modifications.
---------------------------------------------------------------------------------------------------- ----------------------------------------------------------------------------------------------------
square_thumbnail
----------------
Whether to make thumbnails square. Supports both file and embedded-based thumbnails. Ideal
for representing audio albums.
:Usage:
.. code-block:: yaml
square_thumbnail: True
----------------------------------------------------------------------------------------------------
static_nfo_tags
---------------
Adds an NFO file for every entry, but does not link it to an entry in the download
archive. This is intended to produce ``season.nfo`` files in each season
directory. Each entry within a season will overwrite this file with its season
name. If the entry gets deleted from ytdl-sub, this file will remain since it's not
linked.
Usage:
.. code-block:: yaml
presets:
my_example_preset:
static_nfo_tags:
# required
nfo_name: "season.nfo"
nfo_root: "season"
tags:
title: "My custom season name!"
# optional
kodi_safe: False
``enable``
:expected type: Optional[OverridesFormatter]
:description:
Can typically be left undefined to always default to enable. For preset convenience,
this field can be set using an override variable to easily toggle whether this plugin
is enabled or not via Boolean.
``kodi_safe``
:expected type: OverridesBooleanFormatterValidator
:description:
Defaults to False. Kodi does not support > 3-byte unicode characters, which include
emojis and some foreign language characters. Setting this to True will replace those
characters with '□'.
``leaf_name``
Returns
-------
"name" from the first.element.of.the.name
``nfo_name``
:expected type: EntryFormatter
:description:
The NFO file name.
``nfo_root``
:expected type: EntryFormatter
:description:
The root tag of the NFO's XML. In the usage above, it would look like
.. code-block:: xml
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<season>
</season>
``tags``
:expected type: NfoTags
:description:
Tags within the nfo_root tag. In the usage above, it would look like
.. code-block:: xml
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<season>
<title>My custom season name!</title>
</season>
----------------------------------------------------------------------------------------------------
subtitles subtitles
--------- ---------
Defines how to download and store subtitles. Using this plugin creates two new variables: Defines how to download and store subtitles. Using this plugin creates two new variables:
@ -816,6 +1002,19 @@ It will set the respective language to the correct subtitle file.
Language code(s) to download for subtitles. Supports a single or list of multiple Language code(s) to download for subtitles. Supports a single or list of multiple
language codes. Defaults to only "en". language codes. Defaults to only "en".
``languages_required``
:expected type: Optional[List[String]]
:description:
Language code(s) that are required to be present for downloads to continue. If missing,
ytdl-sub will throw an error. NOTE: currently this only checks file-based subtitles.
``leaf_name``
Returns
-------
"name" from the first.element.of.the.name
``subtitles_name`` ``subtitles_name``
:expected type: Optional[EntryFormatter] :expected type: Optional[EntryFormatter]
@ -839,6 +1038,9 @@ Provides options to make ytdl-sub look more 'human-like' to protect from throttl
range-based values, a random number will be chosen within the range to avoid sleeps looking range-based values, a random number will be chosen within the range to avoid sleeps looking
scripted. scripted.
Range min and max values support static override variables within their definitions.
``sleep_per_download_s`` supports both static and override variables.
:Usage: :Usage:
.. code-block:: yaml .. code-block:: yaml
@ -846,6 +1048,9 @@ scripted.
presets: presets:
my_example_preset: my_example_preset:
throttle_protection: throttle_protection:
sleep_per_request_s:
min: 5.5
max: 10.4
sleep_per_download_s: sleep_per_download_s:
min: 2.2 min: 2.2
max: 10.8 max: 10.8
@ -865,6 +1070,12 @@ scripted.
this field can be set using an override variable to easily toggle whether this plugin this field can be set using an override variable to easily toggle whether this plugin
is enabled or not via Boolean. is enabled or not via Boolean.
``leaf_name``
Returns
-------
"name" from the first.element.of.the.name
``max_downloads_per_subscription`` ``max_downloads_per_subscription``
:expected type: Optional[Range] :expected type: Optional[Range]
@ -878,6 +1089,15 @@ scripted.
Number in seconds to sleep between each download. Does not include time it takes for Number in seconds to sleep between each download. Does not include time it takes for
ytdl-sub to perform post-processing. ytdl-sub to perform post-processing.
``sleep_per_request_s``
:expected type: Optional[Range]
:description:
Number in seconds to sleep between each request during metadata download. Note that
metadata download refers to the initial info.json download, not the actual audio/video
download for the entry. Also, yt-dlp only supports a single value at this time for this,
so will always use the max value.
``sleep_per_subscription_s`` ``sleep_per_subscription_s``
:expected type: Optional[Range] :expected type: Optional[Range]
@ -940,3 +1160,13 @@ for more details.
where each key is a ytdl argument. Include in the example are some popular ytdl_options. where each key is a ytdl argument. Include in the example are some popular ytdl_options.
``dict_with_parsed_format_strings``
Returns dict with the parsed format strings.
``leaf_name``
Returns
-------
"name" from the first.element.of.the.name

View file

@ -4,17 +4,30 @@ Common
.. highlight:: yaml .. highlight:: yaml
Filtering
-------------
.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/helpers/filtering.yaml Filter Keywords
---------------
.. literalinclude::
/../../src/ytdl_sub/prebuilt_presets/helpers/filter_keywords.yaml
Filter Duration
---------------
.. literalinclude::
/../../src/ytdl_sub/prebuilt_presets/helpers/filter_duration.yaml
Media Quality Media Quality
------------- -------------
.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/helpers/media_quality.yaml .. literalinclude::
/../../src/ytdl_sub/prebuilt_presets/helpers/media_quality.yaml
Only Recent Videos Only Recent Videos
------------------ ------------------
.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/helpers/download_deletion_options.yaml .. literalinclude::
/../../src/ytdl_sub/prebuilt_presets/helpers/download_deletion_options.yaml

View file

@ -2,11 +2,10 @@
Prebuilt Preset Reference Prebuilt Preset Reference
========================= =========================
This section contains the code for the prebuilt presets. If you just want to understand how to use the presets, check :doc:`this section instead</prebuilt_presets/index>`. This section contains the code for the prebuilt presets. If you just want to understand
how to use the presets, check :doc:`this section instead</prebuilt_presets/index>`.
.. toctree:: .. toctree::
common common
tv_show tv_show
music music

View file

@ -6,4 +6,5 @@ All audio music based presets inherit from ``_music_base``.
.. highlight:: yaml .. highlight:: yaml
.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/music/singles.yaml .. literalinclude::
/../../src/ytdl_sub/prebuilt_presets/music/singles.yaml

View file

@ -6,4 +6,5 @@ All TV show based presets inherit from ``_episode_base``.
.. highlight:: yaml .. highlight:: yaml
.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/tv_show/episode.yaml .. literalinclude::
/../../src/ytdl_sub/prebuilt_presets/tv_show/episode.yaml

View file

@ -1,3 +1,10 @@
..
WARNING: This RST file is generated from docstrings in:
src/ytdl_sub/entries/script/variable_definitions.py
In order to make a change to this file, edit the respective docstring
and run `make docs`. This will automatically sync the Python RST-based
docstrings into this file. If the docstrings and RST file are out of sync,
it will fail TestDocGen tests in GitHub CI.
Entry Variables Entry Variables
=============== ===============
@ -83,6 +90,13 @@ extractor_key
:description: :description:
The yt-dlp extractor key The yt-dlp extractor key
height
~~~~~~
:type: ``Integer``
:description:
Height in pixels of the video. If this value is unavailable (i.e. audio download), it
will default to 0.
ie_key ie_key
~~~~~~ ~~~~~~
:type: ``String`` :type: ``String``
@ -165,6 +179,13 @@ webpage_url
:description: :description:
The url to the webpage. The url to the webpage.
width
~~~~~
:type: ``Integer``
:description:
Width in pixels of the video. If this value is unavailable (i.e. audio download), it
will default to 0.
---------------------------------------------------------------------------------------------------- ----------------------------------------------------------------------------------------------------
Metadata Variables Metadata Variables
@ -659,3 +680,9 @@ ytdl_sub_input_url_index
:type: ``Integer`` :type: ``Integer``
:description: :description:
The index of the input URL as defined in the subscription, top-most being the 0th index. The index of the input URL as defined in the subscription, top-most being the 0th index.
ytdl_sub_keep_files_date_eval
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
:type: ``String``
:description:
The standardized date variable supplied in ``output_options.keep_files_date_eval``.

View file

@ -2,8 +2,9 @@
Scripting Scripting
========= =========
``ytdl-sub`` fields (file-names, tags, etc) are defined using variables and scripts. The links below ``ytdl-sub`` fields (file-names, tags, etc) are defined using variables and scripts. The
contain reference documentation for each built-in variable and scripting function. links below contain reference documentation for each built-in variable and scripting
function.
.. toctree:: .. toctree::
:maxdepth: 1 :maxdepth: 1
@ -13,6 +14,7 @@ contain reference documentation for each built-in variable and scripting functio
scripting_functions scripting_functions
scripting_types scripting_types
How it Works How it Works
------------ ------------
@ -44,22 +46,22 @@ We can use this instead of hard-coding it above:
output_directory: "/path/to/tv_shows/{subscription_name}" output_directory: "/path/to/tv_shows/{subscription_name}"
The syntax for variable usage is curly-braces with the variable name within it. Assuming The syntax for variable usage is curly-braces with the variable name within it. Assuming
our subscription is actually named "Custom YTDL-SUB TV Show", then ``ytdl-sub`` our subscription is actually named "Custom YTDL-SUB TV Show", then ``ytdl-sub`` will
will actually write to that directory. actually write to that directory.
Entry Variables Entry Variables
~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~
For context, an *entry* is a video or audio file downloaded from ``yt-dlp``. For context, an *entry* is a video or audio file downloaded from ``yt-dlp``. *Entry
*Entry variables* are variables that are derived from an entry's ``info.json`` file. This file variables* are variables that are derived from an entry's ``info.json`` file. This file
comes from ``yt-dlp`` and contains every piece of metadata that it scraped. comes from ``yt-dlp`` and contains every piece of metadata that it scraped.
These variables are not considered static since they change per entry download. There are a These variables are not considered static since they change per entry download. There
few fields in ``ytdl-sub`` (i.e. ``output_directory``) that must be static. For others, are a few fields in ``ytdl-sub`` (i.e. ``output_directory``) that must be static. For
we are free to use values that derive from an entry. others, we are free to use values that derive from an entry.
Suppose we want to customize the name of an entry's output file and thumbnail to include its Suppose we want to customize the name of an entry's output file and thumbnail to include
title in its name. We can do that using entry variables: its title in its name. We can do that using entry variables:
.. code-block:: yaml .. code-block:: yaml
@ -74,8 +76,8 @@ Creating Custom Variables
Suppose we want to include the date in our file names. This means we'd need to update Suppose we want to include the date in our file names. This means we'd need to update
both the ``file_name`` and ``thumbnail_name`` fields to include it. both the ``file_name`` and ``thumbnail_name`` fields to include it.
Instead, we can create a custom *override variable*. This is ``ytdl-sub``'s method Instead, we can create a custom *override variable*. This is ``ytdl-sub``'s method for
for creating and overriding custom variables. creating and overriding custom variables.
These are created in the ``overrides`` section. Let's take our above example and create These are created in the ``overrides`` section. Let's take our above example and create
a ``custom_file_name`` variable to use for the entry file and thumbnail fields: a ``custom_file_name`` variable to use for the entry file and thumbnail fields:
@ -97,9 +99,9 @@ For experienced ``yt-dlp`` scrapers, you may be thinking:
- What if the title has characters that do not play nice with my operating system? - What if the title has characters that do not play nice with my operating system?
``ytdl-sub`` is able to *sanitize* any variable, meaning it replaces any problematic characters ``ytdl-sub`` is able to *sanitize* any variable, meaning it replaces any problematic
with safe alternatives that can be used in file names. We can ensure our file names and directories characters with safe alternatives that can be used in file names. We can ensure our file
are safe by using: names and directories are safe by using:
.. code-block:: yaml .. code-block:: yaml
@ -116,16 +118,16 @@ Simply add a ``_sanitized`` suffix to any variable name to make it sanitized.
.. note:: .. note::
Make sure you do not sanitize custom variables that intentionally create directories, Make sure you do not sanitize custom variables that intentionally create directories,
(i.e. sanitizing ``/path/to/tv_shows/``) otherwise they will... be sanitized and not resolve to (i.e. sanitizing ``/path/to/tv_shows/``) otherwise they will... be sanitized and not
directories! resolve to directories!
Using Scripting Functions Using Scripting Functions
~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~
Let's suppose you are an avid command-line user, and like all of your file names to be Let's suppose you are an avid command-line user, and like all of your file names to be
``snake_cased_with_no_spaces``. We can use the ``snake_cased_with_no_spaces``. We can use the `replace
`replace <https://ytdl-sub.readthedocs.io/en/latest/config_reference/scripting/scripting_functions.html#replace>`_ <https://ytdl-sub.readthedocs.io/en/latest/config_reference/scripting/scripting_functions.html#replace>`_
*scripting function* to create and use a snake-cased title. *scripting function* to create and use a snake-cased title.
.. code-block:: yaml .. code-block:: yaml
@ -143,42 +145,48 @@ Let's suppose you are an avid command-line user, and like all of your file names
custom_file_name: "{upload_date_standardized}_{snake_cased_title_sanitized}" custom_file_name: "{upload_date_standardized}_{snake_cased_title_sanitized}"
Scripting functions are similar to variables - they must be used within curly-braces. Scripting functions are similar to variables - they must be used within curly-braces.
It is good practice to use ``>-`` when defining variables that use functions. It is YAML's way of It is good practice to use ``>-`` when defining variables that use functions. It is
saying: YAML's way of saying:
- Allow a string to be multi-lined, and do not include newlines before or after it. - Allow a string to be multi-lined, and do not include newlines before or after it.
See for yourself `here <https://yaml-online-parser.appspot.com/?yaml=output_options%3A%0A%20%20output_directory%3A%20%22%7Bsubscription_name_sanitized%7D%22%0A%20%20file_name%3A%20%22%7Bcustom_file_name%7D.%7Bext%7D%22%0A%20%20thumbnail_name%3A%20%22%7Bcustom_file_name%7D.%7Bthumbnail_ext%7D%22%0A%0Aoverrides%3A%0A%20%20snake_cased_title%3A%20%3E-%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%25replace%28%20title%2C%20%27%20%27%2C%20%27_%27%20%29%0A%20%20%20%20%7D%0A%20%20custom_file_name%3A%20%22%7Bupload_date_standardized%7D%20%7Bsnake_cased_title_sanitized%7D%22&type=json>`_. See for yourself `here
Any whitespace within curly-braces is okay since it will be parsed out. This is needed to make <https://yaml-online-parser.appspot.com/?yaml=output_options%3A%0A%20%20output_directory%3A%20%22%7Bsubscription_name_sanitized%7D%22%0A%20%20file_name%3A%20%22%7Bcustom_file_name%7D.%7Bext%7D%22%0A%20%20thumbnail_name%3A%20%22%7Bcustom_file_name%7D.%7Bthumbnail_ext%7D%22%0A%0Aoverrides%3A%0A%20%20snake_cased_title%3A%20%3E-%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%25replace%28%20title%2C%20%27%20%27%2C%20%27_%27%20%29%0A%20%20%20%20%7D%0A%20%20custom_file_name%3A%20%22%7Bupload_date_standardized%7D%20%7Bsnake_cased_title_sanitized%7D%22&type=json>`_.
scripting function usage readable. Any whitespace within curly-braces is okay since it will be parsed out. This is needed
to make scripting function usage readable.
.. important:: .. important::
It is important to use ``>-`` over other YAML new-line directives like ``>`` because they It is important to use ``>-`` over other YAML new-line directives like ``>`` because
add newlines before or after curly-braces, and will be included in your variable's output string. they add newlines before or after curly-braces, and will be included in your
variable's output string.
Advanced Scripting Advanced Scripting
------------------ ------------------
Accessing ``info.json`` Fields Accessing ``info.json`` Fields
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The entirety of an entry's ``info.json`` file resides in the
`Map <https://ytdl-sub.readthedocs.io/en/latest/config_reference/scripting/scripting_types.html#map>`_
variable
`entry_metadata <https://ytdl-sub.readthedocs.io/en/latest/config_reference/scripting/entry_variables.html#entry-metadata>`_.
Any field can be accessed by using the The entirety of an entry's ``info.json`` file resides in the `Map
`map_get <https://ytdl-sub.readthedocs.io/en/latest/config_reference/scripting/scripting_functions.html#map-get>`_ <https://ytdl-sub.readthedocs.io/en/latest/config_reference/scripting/scripting_types.html#map>`_
variable `entry_metadata
<https://ytdl-sub.readthedocs.io/en/latest/config_reference/scripting/entry_variables.html#entry-metadata>`_.
Any field can be accessed by using the `map_get
<https://ytdl-sub.readthedocs.io/en/latest/config_reference/scripting/scripting_functions.html#map-get>`_
function like so: function like so:
.. code-block:: yaml .. code-block:: yaml
:caption: Fetches the 'artist' value from the .info.json, returns null if it does not exist. :caption:
Fetches the 'artist' value from the .info.json, returns null if it does not exist.
artist: >- artist: >-
{ %map_get( entry_metadata, "artist", null ) } { %map_get( entry_metadata, "artist", null ) }
Creating Custom Functions Creating Custom Functions
~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~
Custom functions can be created in the overrides section using the following syntax: Custom functions can be created in the overrides section using the following syntax:
.. code-block:: yaml .. code-block:: yaml
@ -187,9 +195,9 @@ Custom functions can be created in the overrides section using the following syn
"%get_entry_metadata_field": >- "%get_entry_metadata_field": >-
{ %map_get( entry_metadata, $0, null ) } { %map_get( entry_metadata, $0, null ) }
Custom function definitions must have ``%`` as a prefix to the function name, be surrounded by Custom function definitions must have ``%`` as a prefix to the function name, be
quotes to make YAML parsing happy, and can support arguments using ``$0``, ``$1``, ... to indicate surrounded by quotes to make YAML parsing happy, and can support arguments using ``$0``,
their first argument, second argument, etc. ``$1``, ... to indicate their first argument, second argument, etc.
Using our new custom function, we can simply the ``artist`` variable definition above to: Using our new custom function, we can simply the ``artist`` variable definition above to:

View file

@ -1,3 +1,10 @@
..
WARNING: This RST file is generated from docstrings in:
The respective function files under src/ytdl_sub/script/functions/
In order to make a change to this file, edit the respective docstring
and run `make docs`. This will automatically sync the Python RST-based
docstrings into this file. If the docstrings and RST file are out of sync,
it will fail TestDocGen tests in GitHub CI.
Scripting Functions Scripting Functions
=================== ===================
@ -514,6 +521,13 @@ pow
:description: :description:
``**`` operator. Returns the exponential of the base and exponent value. ``**`` operator. Returns the exponential of the base and exponent value.
range
~~~~~
:spec: ``range(end: Integer, start: Optional[Integer], step: Optional[Integer]) -> Array``
:description:
Returns the desired range of Integers in the form of an Array.
sub sub
~~~ ~~~
:spec: ``sub(values: Numeric, ...) -> Numeric`` :spec: ``sub(values: Numeric, ...) -> Numeric``
@ -523,6 +537,37 @@ sub
---------------------------------------------------------------------------------------------------- ----------------------------------------------------------------------------------------------------
Print Functions
---------------
print
~~~~~
:spec: ``print(message: AnyArgument, passthrough: ReturnableArgument, level: Optional[Integer]) -> ReturnableArgument``
:description:
Log the ``message`` and return ``passthrough``. Optionally can pass level,
where < 0 is debug, 0 is info, 1 is warning, > 1 is error. (default ``0``)
print_if_false
~~~~~~~~~~~~~~
:spec: ``print_if_false(message: AnyArgument, passthrough: ReturnableArgument, level: Optional[Integer]) -> ReturnableArgument``
:description:
Log the ``message`` if ``passthrough`` evaluates to ``false``. Return
``passthrough``. Optionally can pass level, where < 0 is debug, 0 is info, 1
is warning, > 1 is error. (default ``0``)
print_if_true
~~~~~~~~~~~~~
:spec: ``print_if_true(message: AnyArgument, passthrough: ReturnableArgument, level: Optional[Integer]) -> ReturnableArgument``
:description:
Log the ``message`` if ``passthrough`` evaluates to ``true``. Return
``passthrough``. Optionally can pass level, where < 0 is debug, 0 is info, 1
is warning, > 1 is error. (default ``0``)
----------------------------------------------------------------------------------------------------
Regex Functions Regex Functions
--------------- ---------------
@ -634,7 +679,7 @@ capitalize
concat concat
~~~~~~ ~~~~~~
:spec: ``concat(values: String, ...) -> String`` :spec: ``concat(values: AnyArgument, ...) -> String``
:description: :description:
Concatenate multiple Strings into a single String. Concatenate multiple Strings into a single String.
@ -660,6 +705,24 @@ contains_any
:description: :description:
Returns true if any element in ``contains_array`` is in ``string``. False otherwise. Returns true if any element in ``contains_array`` is in ``string``. False otherwise.
join
~~~~
:spec: ``join(separator: String, array: Array) -> String``
:description:
Join all elements in the array together as a string, and insert the
separator between them.
:usage:
.. code-block:: python
{
%join( ", ", ["item1", "item2"] )
}
# "item1, item2"
lower lower
~~~~~ ~~~~~
:spec: ``lower(string: String) -> String`` :spec: ``lower(string: String) -> String``
@ -710,6 +773,23 @@ string
:description: :description:
Cast to String. Cast to String.
strip
~~~~~
:spec: ``strip(string: String) -> String``
:description:
Strip a string of all its whitespace at the beginning and end.
:usage:
.. code-block:: python
{
%trim(" delete the outer! ")
}
# "delete the outer!"
titlecase titlecase
~~~~~~~~~ ~~~~~~~~~
:spec: ``titlecase(string: String) -> String`` :spec: ``titlecase(string: String) -> String``

View file

@ -1,7 +1,8 @@
===============
Scripting Types Scripting Types
=============== ===============
Types Types
----- -----
@ -16,8 +17,9 @@ Strings are a series of characters surrounded by quotes.
.. note:: .. note::
For non-String types, they must be defined as parameters to scripting functions. This is because For non-String types, they must be defined as parameters to scripting functions. This
anything in a variable definition that is not within curly-braces gets evaluated as a String. is because anything in a variable definition that is not within curly-braces gets
evaluated as a String.
We can define Strings within curly-braces by setting them as parameters to a function: We can define Strings within curly-braces by setting them as parameters to a function:
@ -65,8 +67,8 @@ There are a few ways to make variables that use curly braces more compact, inclu
string_variable: "{ %string('This is a String variable') }" string_variable: "{ %string('This is a String variable') }"
In the case that you want to define a string variable that contains both single and double quotes, In the case that you want to define a string variable that contains both single and
triple-quotes can be used to avoid *closing* the String. double quotes, triple-quotes can be used to avoid *closing* the String.
.. tab-set:: .. tab-set::
@ -88,7 +90,8 @@ triple-quotes can be used to avoid *closing* the String.
%string("""This has both " and ' in it.""") %string("""This has both " and ' in it.""")
} }
If you want a plain string that contains literal curly braces, you can escape them like so: If you want a plain string that contains literal curly braces, you can escape them like
so:
.. code-block:: yaml .. code-block:: yaml
@ -185,8 +188,8 @@ A type is considered boolean if it spells out ``True`` or ``False``, case-insens
Array Array
~~~~~ ~~~~~
An Array contains multiple types of any kind, including nested Arrays and Maps. An Array contains multiple types of any kind, including nested Arrays and Maps. Arrays
Arrays are defined using brackets (``[ ]``), and are accessed using zero-based indexing. are defined using brackets (``[ ]``), and are accessed using zero-based indexing.
.. tab-set:: .. tab-set::
@ -227,8 +230,8 @@ Arrays are defined using brackets (``[ ]``), and are accessed using zero-based i
Map Map
~~~ ~~~
A Map is a key-value store, containing mappings between keys and values. A Map is a key-value store, containing mappings between keys and values. Maps are
Maps are defined using curly-braces (``{ }``), and are accessed using their keys. defined using curly-braces (``{ }``), and are accessed using their keys.
.. tab-set:: .. tab-set::
@ -267,6 +270,7 @@ Maps are defined using curly-braces (``{ }``), and are accessed using their keys
Null Null
~~~~ ~~~~
Null is represented by an empty String, and can be conveyed by spelling out ``null``, Null is represented by an empty String, and can be conveyed by spelling out ``null``,
case-insensitive. case-insensitive.
@ -297,7 +301,9 @@ Function Type-Hints
AnyArgument AnyArgument
~~~~~~~~~~~ ~~~~~~~~~~~
AnyArgument means any of the above Types are valid as input or output to a scripting function.
AnyArgument means any of the above Types are valid as input or output to a scripting
function.
.. note:: .. note::
@ -306,13 +312,15 @@ AnyArgument means any of the above Types are valid as input or output to a scrip
Numeric Numeric
~~~~~~~ ~~~~~~~
Numeric refers to either an Integer or Float. Numeric refers to either an Integer or Float.
Optional Optional
~~~~~~~~ ~~~~~~~~
Optional means a particular scripting function argument can be either provided or not included.
For example, the function Optional means a particular scripting function argument can be either provided or not
`map_get <https://ytdl-sub.readthedocs.io/en/latest/config_reference/scripting/scripting_functions.html#map-get>`_ included. For example, the function `map_get
<https://ytdl-sub.readthedocs.io/en/latest/config_reference/scripting/scripting_functions.html#map-get>`_
has an optional default value. Both of these usages are valid: has an optional default value. Both of these usages are valid:
.. tab-set:: .. tab-set::
@ -331,8 +339,9 @@ has an optional default value. Both of these usages are valid:
Lambda Lambda
~~~~~~ ~~~~~~
Lambda parameters are a reference to a function, and will call that lambda function
on the input. In this example, Lambda parameters are a reference to a function, and will call that lambda function on
the input. In this example,
.. code-block:: yaml .. code-block:: yaml
@ -341,20 +350,23 @@ on the input. In this example,
%array_apply( [ 1, 2, 3, 4], %string ) %array_apply( [ 1, 2, 3, 4], %string )
} }
We apply ``%string`` as a lambda function to We apply ``%string`` as a lambda function to `array_apply
`array_apply <https://ytdl-sub.readthedocs.io/en/latest/config_reference/scripting/scripting_functions.html#array-apply>`_, <https://ytdl-sub.readthedocs.io/en/latest/config_reference/scripting/scripting_functions.html#array-apply>`_,
which is called on every element in the input array. The output becomes ``["1", "2", "3", "4"]``. which is called on every element in the input array. The output becomes ``["1", "2",
"3", "4"]``.
This example has one input-argument being passed into the lambda. For other lambda-based functions This example has one input-argument being passed into the lambda. For other lambda-based
like `array_enumerate <https://ytdl-sub.readthedocs.io/en/latest/config_reference/scripting/scripting_functions.html#array-enumerate>`_, functions like `array_enumerate
<https://ytdl-sub.readthedocs.io/en/latest/config_reference/scripting/scripting_functions.html#array-enumerate>`_,
it expects the lambda function to have two input arguments. These are denoted using it expects the lambda function to have two input arguments. These are denoted using
``LambdaTwo``, ``LambdaThree``, etc within the function spec. ``LambdaTwo``, ``LambdaThree``, etc within the function spec.
LambdaReduce LambdaReduce
~~~~~~~~~~~~ ~~~~~~~~~~~~
LambdaReduce parameters are a reference to a function that will perform a *reduce* - an operation
that reduces an Array to a single value by calling the LambdaReduce function repeatedly on two LambdaReduce parameters are a reference to a function that will perform a *reduce* - an
elements in the Array until it is reduced to a single value. operation that reduces an Array to a single value by calling the LambdaReduce function
repeatedly on two elements in the Array until it is reduced to a single value.
In this example, In this example,
@ -365,11 +377,12 @@ In this example,
%array_reduce( [ 1, 2, 3, 4], %add ) %array_reduce( [ 1, 2, 3, 4], %add )
} }
We call We call `array_reduce
`array_reduce <https://ytdl-sub.readthedocs.io/en/latest/config_reference/scripting/scripting_functions.html#array-reduce>`_ <https://ytdl-sub.readthedocs.io/en/latest/config_reference/scripting/scripting_functions.html#array-reduce>`_
on the input array, using on the input array, using `add
`add <https://ytdl-sub.readthedocs.io/en/latest/config_reference/scripting/scripting_functions.html#add>`_ <https://ytdl-sub.readthedocs.io/en/latest/config_reference/scripting/scripting_functions.html#add>`_
as the LambdaReduce function. This will reduce the Array to a single value by internally calling as the LambdaReduce function. This will reduce the Array to a single value by internally
calling
- *reduce-call 1*: ``%add(1, 2) = 3`` (first two elements) - *reduce-call 1*: ``%add(1, 2) = 3`` (first two elements)
- *reduce-call 2*: ``%add(3, 3) = 6`` (output from first two and third element) - *reduce-call 2*: ``%add(3, 3) = 6`` (output from first two and third element)
@ -380,9 +393,10 @@ And evaluate to ``10``.
ReturnableArguments ReturnableArguments
~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~
Returnable arguments are used in conditional functions like Returnable arguments are used in conditional functions like `if
`if <https://ytdl-sub.readthedocs.io/en/latest/config_reference/scripting/scripting_functions.html#if>`_, <https://ytdl-sub.readthedocs.io/en/latest/config_reference/scripting/scripting_functions.html#if>`_,
which implies the argument passed into the function is the function's output. For example, which implies the argument passed into the function is the function's output. For
example,
.. code-block:: yaml .. code-block:: yaml
@ -391,4 +405,4 @@ which implies the argument passed into the function is the function's output. Fo
%if( True, "Return this if True", "Return this if False" ) %if( True, "Return this if True", "Return this if False" )
} }
is going to return ``"Return this if True"`` since the condition parameter is ``True``. is going to return ``"Return this if True"`` since the condition parameter is ``True``.

View file

@ -1,3 +1,10 @@
..
WARNING: This RST file is generated from docstrings in:
src/ytdl_sub/entries/variables/override_variables.py
In order to make a change to this file, edit the respective docstring
and run `make docs`. This will automatically sync the Python RST-based
docstrings into this file. If the docstrings and RST file are out of sync,
it will fail TestDocGen tests in GitHub CI.
Static Variables Static Variables
================ ================
@ -24,16 +31,21 @@ otherwise.
subscription_indent_i subscription_indent_i
~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~
For subscriptions in the form of For subscriptions where the ancestor keys contain the ``= ...`` prefix, the
variables ``subscription_indent_1``, ``subscription_indent_2``, and so on get
set to each subsequent value. For example, given the following subscriptions
file snippet:
.. code-block:: yaml .. code-block:: yaml
Preset | = Indent Value 1: Preset 1 | = Indent Value 1 | Preset 2:
= Indent Value 2: Preset 3 | = Indent Value 2 | Preset 4:
"Subscription Name": "https://..." "Subscription Name": "https://..."
``subscription_indent_1`` and ``subscription_indent_2`` get set to The ``{subscription_indent_1}`` variable will be ``Indent Value 1`` and
``Indent Value 1`` and ``Indent Value 2``. ``{subscription_indent_2}`` will be ``Indent Value 2``. The most common use of
these variables is to :doc:`set the genre and rating for subscriptions from the
YAML keys <../prebuilt_presets/tv_show>`.
subscription_map subscription_map
~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~

View file

@ -2,19 +2,22 @@
Subscription File Subscription File
================== ==================
A subscription file is designed to both define and organize many things A subscription file is designed to both define and organize many things to download in
to download in condensed YAML. condensed YAML.
.. hint:: .. hint::
Read the :ref:`getting started guide <guides/getting_started/index:Getting Started>` Read the :ref:`getting started guide <guides/getting_started/index:Getting Started>`
first before reviewing this section. first before reviewing this section.
File Preset File Preset
----------- -----------
Many examples show ``__preset__`` at the top. This is known as the *subscription file preset*.
It is where a single :ref:`preset <guides/getting_started/first_config:Custom Preset Definition>` Many examples show ``__preset__`` at the top. This is known as the *subscription file
can be defined that gets applied to each subscription within the file. preset*. It is where a single :ref:`preset <guides/getting_started/first_config:Custom
Preset Definition>` can be defined that gets applied to each subscription within the
file.
This is a good place to apply file-wide variables such as ``tv_show_directory`` or This is a good place to apply file-wide variables such as ``tv_show_directory`` or
supply a cookies file path. supply a cookies file path.
@ -22,15 +25,17 @@ supply a cookies file path.
.. code-block:: yaml .. code-block:: yaml
__preset__: __preset__:
# Variables that override defaults from `overrides:` for presets in YAML keys:
overrides: overrides:
tv_show_directory: "/tv_shows" tv_show_directory: "/tv_shows"
# Directly set plugin options:
ytdl_options: ytdl_options:
cookiefile: "/config/cookie.txt" cookiefile: "/config/cookie.txt"
Layout Layout
------ ------
A subscription file is comprised of YAML keys and values. Keys can be either A subscription file is comprised of YAML keys and values. Keys can be either
- a preset - a preset
@ -52,32 +57,33 @@ All three types of keys are used for the following:
- ``= News`` - an override value for genre - ``= News`` - an override value for genre
- ``Breaking News``, ``BBC News`` - The subscription names - ``Breaking News``, ``BBC News`` - The subscription names
The bottom-most keys, or leaf keys, should always be the subscription name. The lowest level, most indented keys should always be the subscription name. It is good
It is good practice to put subscription names in quotes to differentiate practice to put subscription names in quotes to differentiate between preset names and
between preset names and subscription names. subscription names.
Values should always be the subscription itself. The simplest form is Values should always be the subscription itself. The simplest form is just the
just the URL. Further sections will show more exotic examples that go beyond URL. Further sections will show more exotic examples that go beyond a single URL.
a single URL.
Inheritance Inheritance
----------- -----------
A subscription inherits every key above it. In the above example,
both ``Breaking News`` and ``BBC News`` inherits the ``Jellyfin TV Show by Date`` A subscription inherits every key above it. In the above example, both ``Breaking News``
preset and the ``= News`` override value. and ``BBC News`` inherits the ``Jellyfin TV Show by Date`` preset and the ``= News``
override value.
.. note:: .. note::
There are no limits or boundaries on how one structures There are no limits or boundaries on how one structures their presets. This
their presets. This flexibility is intended for subscription authors flexibility is intended for subscription authors to organize their downloads as they
to organize their downloads as they see fit. see fit.
Multi Keys Multi Keys
---------- ----------
Subscription keys support pipe syntax, or ``|``, which allows multiple
keys to be defined on a single line. The following is equivalent to the above Subscription keys support pipe syntax, or ``|``, which allows multiple keys to be
example: defined on a single line. The following is equivalent to the above example:
.. code-block:: yaml .. code-block:: yaml
@ -85,16 +91,16 @@ example:
"Breaking News": "https://www.youtube.com/@SomeBreakingNews" "Breaking News": "https://www.youtube.com/@SomeBreakingNews"
"BBC News": "https://www.youtube.com/@BBCNews" "BBC News": "https://www.youtube.com/@BBCNews"
Override Mode Override Mode
------------- -------------
Often times, it is convenient to set multiple override values for
a single subscription. We can put a preset in *override mode* by Often times, it is convenient to set multiple override values for a single
using tilda syntax, or ``~``. subscription. We can put a preset in *override mode* by using tilda syntax, or ``~``.
Suppose we want to apply the :ref:`Only Recent <prebuilt_presets/helpers:Only Recent>` Suppose we want to apply the :ref:`Only Recent <prebuilt_presets/helpers:Only Recent>`
preset to the above examples. But for ``BBC News`` specifically, we want to preset to the above examples. But for ``BBC News`` specifically, we want to set the date
set the date range to be different than the default ``2months`` value to range to be different than the default ``2months`` value to ``2weeks``.
``2weeks``.
We can change it as follows: We can change it as follows:
@ -109,13 +115,14 @@ We can change it as follows:
.. important:: .. important::
When using override mode, we need to set the ``url`` When using override mode, we need to set the ``url`` variable since we are no longer
variable since we are no longer using the simplified using the simplified *subscription_value*. For more info on how this works, read about
*subscription_value*. For more info on how this works, :ref:`subscription variables <config_reference/scripting/static_variables:Subscription
read about :ref:`subscription variables <config_reference/scripting/static_variables:Subscription Variables>`. Variables>`.
Map Mode Map Mode
-------- --------
Map mode is for highly advanced presets that benefit
from a more complex subscription definition. TODO: Show music video Map mode is for highly advanced presets that benefit from a more complex subscription
example here. definition. TODO: Show music video example here.

78
docs/source/debugging.rst Normal file
View file

@ -0,0 +1,78 @@
Debugging
=========
Run with ``--log-level debug`` to show all log messages, often too much information for
normal operation but useful when investigating a specific problem.
:ref:`ytdl-sub builds on yt-dlp <introduction:motivation>`, which is in itself a complex
tool. It performs an intricate and fragile task, web scraping, which in turn :ref:`is
subject to the whims of external services <guides/getting_started/index:minimize the
work to only what's necessary>` outside its control. Finally, because :ref:`ytdl-sub is
a lower-level tool <guides/getting_started/index:prerequisite knowledge>`, many users,
if not most, will have problems getting their configuration working and it can be
difficult to determine when the root cause is their configuration, just a limit imposed
by the services, or, least likely, a bug in one of the tools involved.
To expedite resolution and conserve the limited resources of both yourself and
volunteers, do as much investigation yourself as you can:
#. Start by assuming the issue is your configuration:
Review :doc:`the guides <./guides/index>` to confirm your understanding. Increase
output using the ``--log-level`` CLI option and read the output carefully for hints
and clues. Use those clues to `search the docs`_. Read :doc:`the reference docs
<./config_reference/index>` of the involved ``ytdl-sub`` components.
#. Try to determine if the issue is happening in ``yt-dlp`` or ``ytdl-sub``:
The user's configuration tells ``ytdl-sub`` how to run ``yt-dlp``. ``yt-dlp`` handles
all the web scraping and downloading. ``ytdl-sub`` then assembles the files and metadata
produced by ``yt-dlp`` and places them in your library.
If the issue is happening while scraping or downloading from the external service,
then it's happening in the running of ``yt-dlp``. Look for output showing failed
downloads, ``403`` errors, or signs of throttles. That doesn't mean it's a bug in
``yt-dlp``, it could be in how your configuration tells ``ytdl-sub`` to run
``yt-dlp`` or limits imposed by the service that are constantly changing, but you may
be able to find answers from other ``yt-dlp`` users running into similar issues.
See `the yt-dlp known issues`_ and `search their issues`_ for clues and hints. Read
the comments for more understanding, workarounds, and maybe even fixes. If you still
don't understand the cause after reading everything you can find there, try to find
help in `the yt-dlp Discord`_.
#. If the issue is happening in ``ytdl-sub``, reach out for help:
Once you've done everything you can to get your configuration working and you've
determined that the issue isn't happening in ``yt-dlp``, look for answers in
``ytdl-sub``:
#. Cut your configuration and subscriptions down to the minimum that reproduces the
issue.
#. Run with the ``--log-level debug`` CLI option and copy the full output.
#. `Search the ytdl-sub issues`_ using clues and hints from the output.
#. `Open a support post in Discord`_ with those details and all other relevant
details.
#. If someone from the Discord discussion directs you to, then `open a new issue`_
with those same details.
.. _`the yt-dlp known issues`:
https://github.com/yt-dlp/yt-dlp/wiki/FAQ#known-issues
.. _`search their issues`:
https://github.com/yt-dlp/yt-dlp/issues
.. _`the yt-dlp Discord`:
https://discord.gg/H5MNcFW63r
.. _`search the docs`:
https://ytdl-sub.readthedocs.io/en/latest/search.html
.. _`search the ytdl-sub issues`:
https://github.com/jmbannon/ytdl-sub/issues
.. _`open a support post in Discord`:
https://discord.com/channels/994270357957648404/1084886228266127460
.. _`open a new issue`:
https://github.com/jmbannon/ytdl-sub/issues/new

View file

@ -1,14 +1,25 @@
Deprecation Notices Deprecation Notices
=================== ===================
Dec 2025
--------
Override variables names can no longer be plugin names, to avoid the common pitfall of
defining a plugin underneath ``overrides``.
In the past, there was usage of a ``date_range`` override variable in a few example configs
that complimented the ``Only Recent`` preset. This overrride variable usage needs to be
replaced with ``only_recent_date_range``.
Sep 2024 Sep 2024
-------- --------
regex plugin regex plugin
~~~~~~~~~~~~ ~~~~~~~~~~~~
Regex plugin has been removed in favor of scripting. The function Regex plugin has been removed in favor of scripting. The function
:ref:`config_reference/scripting/scripting_functions:regex_capture_many` :ref:`config_reference/scripting/scripting_functions:regex_capture_many` has been
has been created to replicate the plugin's behavior. See the following converted example: created to replicate the plugin's behavior. See the following converted example:
.. code-block:: yaml .. code-block:: yaml
:caption: regex plugin :caption: regex plugin
@ -40,13 +51,16 @@ has been created to replicate the plugin's behavior. See the following converted
} }
track_title: "{%array_at(captured_track_title, 1)}" track_title: "{%array_at(captured_track_title, 1)}"
Oct 2023 Oct 2023
-------- --------
subscription preset and value subscription preset and value
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The use of ``__value__`` will go away in Dec 2023 in favor of the method found in The use of ``__value__`` will go away in Dec 2023 in favor of the method found in
:ref:`config_reference/subscription_yaml:Subscription File`. ``__preset__`` will still be supported for the time being. :ref:`config_reference/subscription_yaml:Subscription File`. ``__preset__`` will still
be supported for the time being.
July 2023 July 2023
--------- ---------
@ -54,8 +68,9 @@ July 2023
music_tags music_tags
~~~~~~~~~~ ~~~~~~~~~~
Music tags are getting simplified. ``tags`` will now reside directly under music_tags, and Music tags are getting simplified. ``tags`` will now reside directly under music_tags,
``embed_thumbnail`` is getting moved to its own plugin (supports video files as well). Convert from: and ``embed_thumbnail`` is getting moved to its own plugin (supports video files as
well). Convert from:
.. code-block:: yaml .. code-block:: yaml
@ -79,8 +94,8 @@ The old format will be removed in October 2023.
video_tags video_tags
~~~~~~~~~~ ~~~~~~~~~~
Video tags are getting simplified as well. ``tags`` will now reside directly under video_tags. Video tags are getting simplified as well. ``tags`` will now reside directly under
Convert from: video_tags. Convert from:
.. code-block:: yaml .. code-block:: yaml

View file

@ -2,45 +2,36 @@
FAQ FAQ
=== ===
Since ytdl-sub is relatively new to the public, there has not been many question asked yet. We will update this as more questions get asked. Since ytdl-sub is relatively new to the public, there has not been many question asked
yet. We will update this as more questions get asked.
.. contents:: Frequently Asked Questions .. contents:: Frequently Asked Questions
:depth: 3 :depth: 3
How do I... How do I...
----------- -----------
...remove the date in the video title? ...remove the date in the video title?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The :ref:`config_reference/prebuilt_presets/tv_show:TV Show` presets by default include the upload date in the ``episode_title`` The :ref:`config_reference/prebuilt_presets/tv_show:TV Show` presets by default include
override variable. This variable is used to set the title in things like the video metadata, NFO file, etc, which is the upload date in the ``episode_title`` override variable. This variable is used to set
subsequently read by media players. This can be overwritten as you see fit by redefining it: the title in things like the video metadata, NFO file, etc, which is subsequently read
by media players. This can be overwritten as you see fit by redefining it:
.. code-block:: yaml .. code-block:: yaml
overrides: overrides:
episode_title: "{title}" # Only sets the video title episode_title: "{title}" # Only sets the video title
...get support or reach out to contribute?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
If you need support, you can:
* :ytdl-sub-gh:`Open an issue on GitHub <issues/new>`
* `Join our Discord <https://discord.gg/v8j9RAHb4k>`_
If you would like to contribute, we're happy to accept any help, even non-coders! To find out how you can help this project, you can:
* `Join our Discord <https://discord.gg/v8j9RAHb4k>`_ and leave a comment in #development with where you think you can assist or what skills you would like to contribute.
* If you just want to fix one thing, you're welcome to :ytdl-sub-gh:`submit a pull request <compare>` with information on what issue you're resolving and it will be reviewed as soon as possible.
...download age-restricted YouTube videos? ...download age-restricted YouTube videos?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
See `yt-dl's recommended way <https://github.com/ytdl-org/youtube-dl#how-do-i-pass-cookies-to-youtube-dl>`_ to download your YouTube cookie, then add it to your :ref:`ytdl options <config_reference/plugins:ytdl_options>` section of your config: See `yt-dl's recommended way
<https://github.com/ytdl-org/youtube-dl#how-do-i-pass-cookies-to-youtube-dl>`_ to
download your YouTube cookie, then add it to your :ref:`ytdl options
<config_reference/plugins:ytdl_options>` section of your config:
.. code-block:: yaml .. code-block:: yaml
@ -50,7 +41,8 @@ See `yt-dl's recommended way <https://github.com/ytdl-org/youtube-dl#how-do-i-pa
...automate my downloads? ...automate my downloads?
~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~
:doc:`This page </guides/getting_started/automating_downloads>` shows how to set up ``ytdl-sub`` to run automatically on various platforms. :doc:`This page </guides/getting_started/automating_downloads>` shows how to set up
``ytdl-sub`` to run automatically on various platforms.
...download large channels? ...download large channels?
~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -62,25 +54,207 @@ See the prebuilt preset :doc:`chunk_initial_download </prebuilt_presets/helpers>
See the prebuilt preset :doc:`Filter Keywords </prebuilt_presets/helpers>`. See the prebuilt preset :doc:`Filter Keywords </prebuilt_presets/helpers>`.
...prevent creation of NFO file
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Creation of NFO files is done by the NFO tags plugin. It, as any other plugin, can be
disabled:
.. code-block:: yaml
nfo_tags:
enabled: False
...prevent download of images
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The :ref:`config_reference/prebuilt_presets/tv_show:TV Show` presets by default
downloads images corresponding to show and each episode. This can be prevented by
overriding following variables:
.. code-block:: yaml
overrides:
tv_show_fanart_file_name: "" # to stop creation of fanart.jpg in subscription
tv_show_poster_file_name: "" # to stop creation of poster.jpg in subscription
thumbnail_name: "" # to stop creation of episode thumbnails
...use only part of the media's title
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
ytdl-sub offers a range of functions that can be used to parse a subset of a title for
use in your media player. Consider the example:
* I want to remove "NOVA PBS - " from the title ``NOVA PBS - Hidden Cities All Around
Us``.
There are several solutions using ytdl-sub's scripting capabilities to override
``episode_title`` by manipulating the original media's ``title``.
.. code-block:: yaml
:caption: Replace exclusion with empty string
"~Nova PBS":
url: "https://www.youtube.com/@novapbs"
episode_title: >-
{
%replace( title, "NOVA PBS - ", "" )
}
.. code-block:: yaml
:caption: Split once using delimiter, grab last value in the split array.
"~Nova PBS":
url: "https://www.youtube.com/@novapbs"
episode_title: >-
{
%array_at( %split(title, " - ", 1), -1 )
}
.. code-block:: yaml
:caption:
Regex capture. Supports multiple capture strings and default values if captures
are unsuccessful.
"~Nova PBS":
url: "https://www.youtube.com/@novapbs"
captured_episode_title: >-
{
%regex_capture_many(
title,
[ "NOVA PBS - (.*)" ],
[ title ]
)
}
episode_title: >-
{ %array_at( captured_episode_title, 1 ) }
There is no single solution to this problem - it will vary case-by-case. See our full
suite of :ref:`scripting functions
<config_reference/scripting/scripting_functions:Scripting Functions>` to create your own
clever scraping mechanisms.
...force ytdl-sub to re-download a file
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Sometimes users may wish to replace a file already in the archive, for example, if the
current file is a lower resolution than desired, missing subtitles, corrupt, etc..
``ytdl-sub`` decides what files have already been downloaded by entries in :ref:`the
download archive file <config_reference/plugins:output_options>`,
``./.ytdl-sub-...-download-archive.json``, at the top of the subscription/series/show
:ref:`output directory <config_reference/plugins:output_options>` in the appropriate
``overrides: / ..._directory:`` library path, *and* the presence of the corresponding
downloaded files under the same path. To force ``ytdl-sub`` to re-download an entry both
need to be removed:
- Move aside the downloaded files:
Rename or move the downloaded files, including the associated files with the same
base/stem name, such as ``./*.nfo``, ``./*.info-json``, etc..
- Ensure ``ytdl-sub`` is not running and won't run, such as by cron:
``ytdl-sub`` loads the ``./.ytdl-sub-...-download-archive.json`` file early, keeps it
in memory, and writes it back out late. If it's running or starts running while you're
modifying that file, then your changes will be overwritten when it exits.
- Remove the ``./.ytdl-sub-...-download-archive.json`` JSON array item:
Search for the stem name, the basename without any extension or suffix, common to all
the downloaded files in this file and delete that whole entry, from the YouTube ID
string to the closing curly braces. Be ware of JSON traling commas.
- Run ``$ ytdl-sub sub`` again with the appropriate CLI plugin options:
In normal operation, :ref:`yt-dlp minimizes requests and the files considered for
download <guides/getting_started/index:minimize the work to only what's
necessary>`. To re-download, those options must be disabled or modified. Disable
:ref:`the 'break_on_existing' option <config_reference/plugins:ytdl_options>`, set
:ref:`the 'date_range:' plugin <config_reference/plugins:date_range>`, and :ref:`limit
the subscriptions <guides/getting_started/downloading:preview>` to
download only the files that you've renamed in the steps above.
Set the appropriate dates, :ref:`including a sufficient margin
<config_reference/plugins:date_range>`, and subscription name to include only the
files you've renamed, and re-run. For example, if you've renamed all the files from
2024 in the ``NOVA PBS`` subscription:
.. code-block:: shell
ytdl-sub --match="NOVA PBS" sub -o "\
--ytdl_options.break_on_existing False \
--date_range.after 20240101 \
--date_range.before 20250101 \
"
...download a file missing from the archive
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The root causes are unknown, but sometimes even after successful, complete runs, some
files will be missing from the archive. To attempt to download those missing files,
use `the same CLI options as re-downloading a file`_
.. _`the same CLI options as re-downloading a file`:
`...force ytdl-sub to re-download a file`_
...get support?
~~~~~~~~~~~~~~~
See :doc:`the debugging documentation <../debugging>`.
...reach out to contribute?
~~~~~~~~~~~~~~~~~~~~~~~~~~~
If you would like to contribute, we're happy to accept any help, including from
non-coders! To find out how you can help this project, you can:
- `Join our Discord <https://discord.gg/v8j9RAHb4k>`_ and leave a comment in
#development with where you think you can assist or what skills you would like to
contribute.
- If you just want to fix one thing, you're welcome to :ytdl-sub-gh:`submit a pull
request <compare>` with information on what issue you're resolving and it will be
reviewed as soon as possible.
There is a bug where... There is a bug where...
----------------------- -----------------------
...ytdl-sub is not downloading
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
...ytdl-sub is downloading at 360p or other lower quality
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
...ytdl-sub downloads 2-4 videos and then fails
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
These are often just limits imposed by the external services that are not bugs. There
may be little that can be done about them, but see :ref:`the '_throttle_protection'
preset <prebuilt_presets/helpers:_throttle_protection>` for more information.
...date_range is not downloading older videos after I changed the range ...date_range is not downloading older videos after I changed the range
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Your preset most likely has ``break_on_existing`` set to True, which will stop downloading additional metadata/videos if the video exists in your download archive. Set the following in your config to skip downloading videos that exist instead of stopping altogether. Your preset most likely has ``break_on_existing`` set to True, which will stop
downloading additional metadata/videos if the video exists in your download archive. Set
the following in your config to skip downloading videos that exist instead of stopping
altogether.
.. code-block:: yaml .. code-block:: yaml
ytdl_options: ytdl_options:
break_on_existing: False break_on_existing: False
After you download your new date_range duration, re-enable ``break_on_existing`` to speed up successive downloads. After you download your new date_range duration, re-enable ``break_on_existing`` to
speed up successive downloads.
...it is downloading non-English title and description metadata ...it is downloading non-English title and description metadata
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Most likely the video has a non-English language set to its 'native' language. You can tell yt-dlp to explicitly download English metadata using. Most likely the video has a non-English language set to its 'native' language. You can
tell yt-dlp to explicitly download English metadata using.
.. code-block:: yaml .. code-block:: yaml
@ -96,7 +270,9 @@ Most likely the video has a non-English language set to its 'native' language. Y
1. Set the following for your ytdl-sub library that has been added to Plex. 1. Set the following for your ytdl-sub library that has been added to Plex.
.. figure:: ../../images/plex_scanner_agent.png .. figure:: ../../images/plex_scanner_agent.png
:alt: The Plex library editor, under the advanced settings, showing the required options for Plex to show the TV shows correctly. :alt:
The Plex library editor, under the advanced settings, showing the required options
for Plex to show the TV shows correctly.
- **Scanner:** Plex Series Scanner - **Scanner:** Plex Series Scanner
- **Agent:** Personal Media shows - **Agent:** Personal Media shows
@ -104,7 +280,15 @@ Most likely the video has a non-English language set to its 'native' language. Y
- **Episode sorting:** Library default - **Episode sorting:** Library default
- **YES** Enable video preview thumbnails - **YES** Enable video preview thumbnails
2. Under **Settings** > **Agents**, confirm Plex Personal Media Shows/Movies scanner has **Local Media Assets** enabled. 2. Under **Settings** > **Agents**, confirm Plex Personal Media Shows/Movies scanner has
**Local Media Assets** enabled.
.. figure:: ../../images/plex_agent_sources.png .. figure:: ../../images/plex_agent_sources.png
:alt: The Plex Agents settings page has Local Media Assets enabled for Personal Media Shows and Movies tabs. :alt:
The Plex Agents settings page has Local Media Assets enabled for Personal Media
Shows and Movies tabs.
...ytdl-sub errors when downloading a 360p video with resolution assert
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
:ref:`See how to either ignore this specific video or disable resolution assertion entirely here. <resolution assert handling>`

View file

@ -1,8 +1,10 @@
Development and Contributing Development and Contributing
============================ ============================
Requirements Requirements
------------ ------------
- python >= 3.10 - python >= 3.10
- ffmpeg/ffprobe 4.4.5 (test checksums rely on this version) - ffmpeg/ffprobe 4.4.5 (test checksums rely on this version)
- make - make
@ -20,15 +22,18 @@ Local Install
pip install -e .\[test,lint,docs\] pip install -e .\[test,lint,docs\]
Linter Linter
------ ------
All source code contributed must be formatted to our linter specification.
Run the following to auto-format and check for any issues with your code: All source code contributed must be formatted to our linter specification. Run the
following to auto-format and check for any issues with your code:
.. code-block:: shell .. code-block:: shell
make lint make lint
Adding Documentation Adding Documentation
-------------------- --------------------
@ -36,18 +41,21 @@ Docs can be found in ``ytdl-sub/docs/source/``, and are built using the command:
.. code-block:: shell .. code-block:: shell
:caption: Viewable at http://localhost:63342/ytdl-sub/docs/build/html/index.html once built :caption:
Viewable at http://localhost:63342/ytdl-sub/docs/build/html/index.html once built
make docs make docs
Some of the documentation is built using doc-strings from the python source code. The above Some of the documentation is built using doc-strings from the python source code. The
command will rebuild those as well. above command will rebuild those as well.
Testing Testing
------- -------
Tests are written using pytest. Many of them evaluate checksums of output files to ensure no unintended
changes are introduced to the way ``ytdl-sub`` produces files. This checksum can be inaccurate for Tests are written using pytest. Many of them evaluate checksums of output files to
end-to-end tests, but are reliable for integration tests. ensure no unintended changes are introduced to the way ``ytdl-sub`` produces files. This
checksum can be inaccurate for end-to-end tests, but are reliable for integration tests.
If integration tests are failing, ensure... If integration tests are failing, ensure...
@ -55,26 +63,36 @@ If integration tests are failing, ensure...
- you are developing on Linux or Mac (have not tested windows yet) - you are developing on Linux or Mac (have not tested windows yet)
- your local ``ytdl-sub`` dependencies are up-to-date - your local ``ytdl-sub`` dependencies are up-to-date
Docker
------
Test changes to the Docker image variants locally:
.. code-block:: shell
cd ./docker/testing/
make -j run
See ``./docker/testing/docker-compose.yml`` for the Compose services for each image
variant.
IDE Setup IDE Setup
--------- ---------
PyCharm is our preferred IDE. The codebase is simple enough to where it's not required, but
is highly recommended. PyCharm is our preferred IDE. The codebase is simple enough to where it's not required,
but is highly recommended.
TODO: screenshots of configuration TODO: screenshots of configuration
Debugging
---------
Debug Logs
^^^^^^^^^^^^^^^
Run with ``--log-level debug`` to show all debug logs when running ytdl-sub.
Reproducing a Failing Subscription Reproducing a Failing Subscription
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ----------------------------------
Subscriptions will dump their entire *compiled* yaml at the beginning of exeuction
when using ``--log-level debug``. This can be copy-pasted into the file
``resources/file_fixtures/repro.yaml``.
Running the test ``e2e.test_debug_repro.TestReproduce.test_debug_log_repro`` Subscriptions will dump their entire *compiled* yaml at the beginning of exeuction
will fully reproduce that subscription in order to debug it. :doc:`when using '--log-level debug' <../../debugging>`. This can be copy-pasted into
the file ``resources/file_fixtures/repro.yaml``.
Running the test ``e2e.test_debug_repro.TestReproduce.test_debug_log_repro`` will fully
reproduce that subscription in order to debug it.

View file

@ -1,133 +1,46 @@
Automating Downloads Automating Downloads
==================== ====================
One of the key capabilities of ``ytdl-sub`` automating new downloads.
To take advantage of this, you must set up scheduling to execute the commands at some interval.
How you set up this scheduling depends on which version of ``ytdl-sub`` you downloaded.
:ref:`Guide for Docker and Unraid Containers <guides/getting_started/automating_downloads:docker and unraid>` :ref:`Guide for Docker and Unraid Containers <guides/getting_started/automating_downloads:docker and unraid>`
:ref:`Guide for Linux <guides/getting_started/automating_downloads:linux>` :ref:`Guide for Linux <guides/getting_started/automating_downloads:linux>`
:ref:`Guide for Windows <guides/getting_started/automating_downloads:windows>` :ref:`Guide for Windows <guides/getting_started/automating_downloads:windows>`
.. _cron tab manpage: https://man7.org/linux/man-pages/man5/crontab.5.html#EXAMPLE_CRON_FILE .. _cron scheduling syntax: https://crontab.guru/#0_*/6_*_*_*
.. _docker-unraid-setup: .. _docker-unraid-setup:
Docker and Unraid Docker and Unraid
----------------- -----------------
.. tab-set:: Cron is preconfigured in every ytdl-sub docker container. Enable by adding the following
ENV variables to your docker setup.
.. tab-item:: GUI Image
The script that will execute automatically is located at ``/config/ytdl-sub-configs/run_cron``.
Access your container at http://localhost:8443/, then in the GUI terminal run these commands: .. code-block:: yaml
.. code-block:: shell services:
ytdl-sub:
environment:
- CRON_SCHEDULE="0 */6 * * *"
- CRON_RUN_ON_START=false
echo '#!/bin/bash' > /config/ytdl-sub-configs/run_cron
echo "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" >> /config/ytdl-sub-configs/run_cron
echo "echo 'Cron started, running ytdl-sub...'" >> /config/ytdl-sub-configs/run_cron
echo "cd /config/ytdl-sub-configs" >> /config/ytdl-sub-configs/run_cron
echo "ytdl-sub --config=config.yaml sub subscriptions.yaml" >> /config/ytdl-sub-configs/run_cron
chmod +x /config/ytdl-sub-configs/run_cron
chown abc:abc /config/ytdl-sub-configs/run_cron
You can test the newly created script by running: - ``CRON_SCHEDULE`` follows the standard `cron scheduling syntax`_. The above value will
run the script once every 6 hours.
- ``CRON_RUN_ON_START`` toggles whether to run your cron script on container start in
addition to the cron schedule.
.. code-block:: shell The cron script will reside in the main directory with the file name ``cron``. Cron
logs should show when viewing the Docker logs.
/config/ytdl-sub-configs/run_cron
To create the cron definition, run the following command:
.. code-block:: shell
echo "# min hour day month weekday command" > /config/crontabs/abc
echo " 0 */6 * * * /config/ytdl-sub-configs/run_cron" >> /config/crontabs/abc
This will run the script every 6 hours. To run every hour, change ``*/6`` to ``*/1``, or to run once a day, change the same value to the hour (in 24hr format) that you want it to run at. See the `cron tab manpage`_ for more options.
.. attention::
The Docker container needs to be restarted for changes to take effect. Run `crontab -e` after to verify settings are correct.
.. tab-item:: Headless Image
.. _LinuxServer's Universal Cron mod: https://github.com/linuxserver/docker-mods/tree/universal-cron
The first step is to ensure you have `LinuxServer's Universal Cron mod`_ enabled via the environment variable. For the GUI image, this is already included (no need to add it).
.. code-block:: yaml
services:
ytdl-sub:
image: ghcr.io/jmbannon/ytdl-sub:latest
container_name: ytdl-sub
environment:
- PUID=1000
- PGID=1000
- TZ=America/Los_Angeles
- DOCKER_MODS=linuxserver/mods:universal-cron # <-- Make sure you have this!
volumes:
# ensure directories have user permissions
- </path/to/ytdl-sub/config>:/config
- </path/to/ytdl-sub/tv_shows>:/tv_shows
restart: unless-stopped
This line will tell your container to install and enable cron on start.
If you had to add this line, you will need to restart your container.
.. code-block:: shell
docker compose restart
The script that will execute automatically is located at ``/config/run_cron``.
Access your container from the terminal by running:
.. code-block:: shell
docker exec -itu abc ytdl-sub /bin/bash
then in the terminal run these commands:
.. code-block:: shell
echo '#!/bin/bash' > /config/run_cron
echo "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" >> /config/run_cron
echo "echo 'Cron started, running ytdl-sub...'" >> /config/run_cron
echo "cd /config" >> /config/run_cron
echo "ytdl-sub --config=config.yaml sub subscriptions.yaml" >> /config/run_cron
chmod +x /config/run_cron
chown abc:abc /config/run_cron
You can test the newly created script by running:
.. code-block::
/config/run_cron
To create the cron definition, run the following command:
.. code-block:: shell
echo "# min hour day month weekday command" > /config/crontabs/abc
echo " 0 */6 * * * /config/run_cron" >> /config/crontabs/abc
This will run the script every 6 hours. To run every hour, change ``*/6`` to ``*/1``, or to run once a day, change the same value to the hour (in 24hr format) that you want it to run at. See the `cron tab manpage`_ for more options.
.. attention::
The Docker container needs to be restarted for changes to take effect. Run `crontab -e` after to verify settings are correct.
.. _linux-setup: .. _linux-setup:
Linux Linux
----- -----
Must configure crontab manually, like so:
.. code-block:: shell .. code-block:: shell
@ -135,14 +48,22 @@ Linux
0 */6 * * * /config/run_cron 0 */6 * * * /config/run_cron
.. _windows-setup: .. _windows-setup:
Windows Windows
------- -------
To be tested (please contact code owner or join the discord server if you can test this out for us)
To be tested (please contact code owner or join the discord server if you can test this
out for us)
.. code-block:: powershell .. code-block:: powershell
ytdl-sub.exe --config \path\to\config\config.yaml sub \path\to\config\subscriptions.yaml ytdl-sub.exe --config \path\to\config\config.yaml sub \path\to\config\subscriptions.yaml
Next Steps
----------
Once you have a significant quantity of subscriptions or have use cases not served using
:doc:`YAML keys and the special characters <./subscriptions>`, it's time to start
:doc:`defining your own custom presets <./first_config>`.

View file

@ -0,0 +1,69 @@
Downloading
===========
Once you've :doc:`defined your subscriptions <./subscriptions>`, it's time to test your
configuration and try your first download. As a web scraping tool, :ref:`it's important
to minimize the requests sent to external services
<guides/getting_started/index:minimize the work to only what's necessary>` to avoid
triggering throttling or bans. Further, a full download of even one subscription can
take significant time. Test each change to your subscriptions carefully and quickly as
follows.
Preview
-------
Preview what ``ytdl-sub`` would do for this subscription. Run the :ref:`'sub'
sub-command <usage:subscriptions options>` with CLI options to restrict requests as much
as possible:
- Pull metadata and *simulate* a download without actually downloading any media files
using the ``--dry-run`` CLI option.
- Limit requests by narrowing the run to one subscription by giving a
subscription name to the ``--match`` CLI option.
- Stop after just a few downloads to further minimize requests and make testing faster
using the ``max_downloads`` setting from ``yt-dlp``.
Change to the directory containing your ``./subscriptions.yaml`` file and run with those
options:
.. code-block:: console
cd "/config/ytdl-sub-configs/"
ytdl-sub --dry-run --match="NOVA PBS" sub -o '--ytdl_options.max_downloads 3'
Examine the output carefully, investigate anything that doesn't look right and repeat
this step until everything looks right.
Review
------
Review the results of real downloads. Run it again without the ``--dry-run`` option to
actually download media and place the files in your library:
.. code-block:: console
ytdl-sub --match="NOVA PBS" sub -o '--ytdl_options.max_downloads 3'
Examine the output carefully again. Then examine how the resulting downloads work in
your library. Repeat with a larger value for ``max_downloads`` and examine the output
and downloads again.
Next Steps
----------
Once you're `previewed <preview_>`_ and `reviewed <review_>`_ successful downloads of
each of your subscriptions, you're ready to run a full download of all your
subscriptions. Run the sub-command without the CLI options you used to limit what
``ytdl-sub`` does while testing:
.. code-block:: console
ytdl-sub sub
If you're ready to let ``ytdl-sub`` run unattended, it's time to :doc:`automate
downloads <./automating_downloads>`.

View file

@ -33,7 +33,7 @@ how this works, and show-case how
:linenos: :linenos:
configuration: configuration:
working_directory: '/mnt/ssd/.ytdl-sub-downloads' working_directory: ".ytdl-sub-working-directory"
presets: presets:
TV Show: TV Show:
@ -55,28 +55,36 @@ how this works, and show-case how
max: 36 max: 36
overrides: overrides:
tv_show_directory: "/ytdl_sub_tv_shows" tv_show_directory: "/tv_shows"
TV Show Only Recent: TV Show Only Recent:
preset: preset:
- "TV Show" - "TV Show"
- "Only Recent" - "Only Recent"
Configuration Section Configuration Section
--------------------- ---------------------
The :ref:`configuration <config_reference/config_yaml:Configuration File>` section sets options for ytdl-sub execution. The :ref:`configuration <config_reference/config_yaml:Configuration File>` section sets
options for ytdl-sub execution. Most users should set the path where ``ytdl-sub``
temporarily stores downloaded data before assembling it and moving it into your
library. To avoid unnecessarily long large file renames, use a path on the same
filesystem as your library in the ``overrides: / *_directory:`` paths:
.. code-block:: yaml .. code-block:: yaml
:lineno-start: 1 :lineno-start: 1
configuration: configuration:
working_directory: '/mnt/ssd/.ytdl-sub-downloads' working_directory: ".ytdl-sub-working-directory"
Preset Section Preset Section
-------------- --------------
Underneath ``presets``, we define two custom presets with the names ``TV Show`` and ``TV Show Only Recent``. Underneath ``presets``, we define two custom presets with the names ``TV Show`` and ``TV
Show Only Recent``.
.. code-block:: yaml .. code-block:: yaml
@ -88,6 +96,7 @@ Underneath ``presets``, we define two custom presets with the names ``TV Show``
The indentation example above shows how to define multiple presets. The indentation example above shows how to define multiple presets.
Custom Preset Definition Custom Preset Definition
------------------------ ------------------------
@ -107,12 +116,15 @@ Before we break down the above ``TV Show`` preset, lets first outline a preset l
Presets can contain three important things: Presets can contain three important things:
1. ``preset`` section, which can inherit :ref:`prebuilt presets <config_reference/prebuilt_presets/index:Prebuilt Preset Reference>` 1. ``preset`` section, which can inherit :ref:`prebuilt presets
or other presets defined in your config. <config_reference/prebuilt_presets/index:Prebuilt Preset Reference>` or other presets
defined in your config.
2. :ref:`Plugin definitions <config_reference/plugins:Plugins>` 2. :ref:`Plugin definitions <config_reference/plugins:Plugins>`
3. :ref:`overrides <config_reference/plugins:overrides>`, which can override inherited preset variables 3. :ref:`overrides <config_reference/plugins:overrides>`, which can override inherited
preset variables
Presets do not have to define all of these, as we'll see in the ``TV Show Only Recent`` preset. Presets do not have to define all of these, as we'll see in the ``TV Show Only Recent``
preset.
Inheriting Presets Inheriting Presets
~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~
@ -125,14 +137,16 @@ Inheriting Presets
- "Jellyfin TV Show by Date" - "Jellyfin TV Show by Date"
- "Max 1080p" - "Max 1080p"
The following snippet shows that the ``TV Show`` preset will inherit all properties The following snippet shows that the ``TV Show`` preset will inherit all properties of
of the prebuilt presets ``Jellyfin TV Show by Date`` and ``Max 1080p`` in that order. the prebuilt presets ``Jellyfin TV Show by Date`` and ``Max 1080p`` in that order.
Order matters for preset inheritance. Bottom-most presets will override ones above them. Order matters for preset inheritance. Bottom-most presets will override ones above them.
It is highly advisable to use :ref:`prebuilt presets <config_reference/prebuilt_presets/index:Prebuilt Preset Reference>` as It is highly advisable to use :ref:`prebuilt presets
a starting point for custom preset building, as they do the work of preset building to ensure things show as expected <config_reference/prebuilt_presets/index:Prebuilt Preset Reference>` as a starting point
in their respective media players. Read on to see how to override prebuilt preset specifics such as title. for custom preset building, as they do the work of preset building to ensure things show
as expected in their respective media players. Read on to see how to override prebuilt
preset specifics such as title.
Defining Plugins Defining Plugins
~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~
@ -153,17 +167,18 @@ Defining Plugins
min: 10 min: 10
max: 36 max: 36
Our ``TV Show`` sets two plugins, :ref:`throttle_protection <config_reference/plugins:throttle_protection>` and Our ``TV Show`` sets two plugins, :ref:`throttle_protection
:ref:`embed_thumbnail <config_reference/plugins:embed_thumbnail>`. Each plugin's documentation shows the respective <config_reference/plugins:throttle_protection>` and :ref:`embed_thumbnail
fields that they support. <config_reference/plugins:embed_thumbnail>`. Each plugin's documentation shows the
respective fields that they support.
If an inherited preset defines the same plugin, the custom preset will use 'merge-and-append' strategy to If an inherited preset defines the same plugin, the custom preset will use
combine their definitions. What this means is: 'merge-and-append' strategy to combine their definitions. What this means is:
1. If the field is a map (i.e. has sub-params like ``sleep_per_download_s`` above) or array, it will try to merge them
2. If both the inherited preset and custom preset set the same exact field and value (i.e. ``embed_thumbnail``)
the custom preset will overwrite it
1. If the field is a map (i.e. has sub-params like ``sleep_per_download_s`` above) or
array, it will try to merge them
2. If both the inherited preset and custom preset set the same exact field and value
(i.e. ``embed_thumbnail``) the custom preset will overwrite it
Setting Override Variables Setting Override Variables
~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -174,27 +189,31 @@ Setting Override Variables
overrides: overrides:
tv_show_directory: "/ytdl_sub_tv_shows" tv_show_directory: "/ytdl_sub_tv_shows"
All override variables reside underneath the :ref:`overrides <config_reference/plugins:overrides>` section. All override variables reside underneath the :ref:`overrides
<config_reference/plugins:overrides>` section.
It is important to remember that individual subscriptions can override specific override variables. It is important to remember that individual subscriptions can override specific override
When defining variables in a preset, it is best practice to define them with the intention that variables. When defining variables in a preset, it is best practice to define them with
the intention that
1. All subscriptions will use its value them 1. All subscriptions will use its value them
2. Use them as placeholders to perform other logic, then have subscriptions or child presets 2. Use them as placeholders to perform other logic, then have subscriptions or child
define their specific value presets define their specific value
For simplicity, we'll focus on (1) for now. The above snippet sets the ``tv_show_directory`` For simplicity, we'll focus on (1) for now. The above snippet sets the
variable to a file path. This variable name is specific to the prebuilt TV show presets. ``tv_show_directory`` variable to a file path. This variable name is specific to the
prebuilt TV show presets.
See the :ref:`prebuilt preset reference <config_reference/prebuilt_presets/index:Prebuilt Preset Reference>` See the :ref:`prebuilt preset reference
to see all available variables that are overridable. <config_reference/prebuilt_presets/index:Prebuilt Preset Reference>` to see all
available variables that are overridable.
Using Custom Presets in Subscriptions Using Custom Presets in Subscriptions
-------------------------------------- --------------------------------------
Subscription files can use custom presets just like any other prebuilt preset. Subscription files can use custom presets just like any other prebuilt preset. Below
Below shows a complete subscription file using the above two custom presets. shows a complete subscription file using the above two custom presets.
.. code-block:: yaml .. code-block:: yaml
@ -212,11 +231,12 @@ Below shows a complete subscription file using the above two custom presets.
Notice how we do not need to define ``tv_show_directory`` in the ``__preset__`` section Notice how we do not need to define ``tv_show_directory`` in the ``__preset__`` section
like in prior examples. This is because our custom presets do the work of defining it. like in prior examples. This is because our custom presets do the work of defining it.
Reference Custom Config in the CLI Reference Custom Config in the CLI
---------------------------------- ----------------------------------
Be sure to tell ytdl-sub to use your config by using the argument Be sure to tell ytdl-sub to use your config by using the argument ``--config
``--config /path/to/config.yaml``. /path/to/config.yaml``.
If you run ytdl-sub in the same directory, and the config file is named ``config.yaml``, it will If you run ytdl-sub in the same directory, and the config file is named ``config.yaml``,
use it by default. it will use it by default.

View file

@ -1,50 +0,0 @@
Initial Download
================
Once you have a ``subscriptions.yaml`` file created, you can perform your first
download. Access ``ytdl-sub``, navigate to the directory containing your ``subscriptions.yaml``
file.
Dry Run
-------
Performing a dry run is important when applying any change to your subscriptions to
ensure output looks as expected. Dry runs will pull metadata to *simulate* a download
without actually downloading the media file.
.. code-block:: shell
ytdl-sub --dry-run sub subscriptions.yaml
Faster Iteration Cycle
----------------------
Testing subscriptions can take quite some time to perform a full download.
This can be speed up by applying an override via command-line to set max number
of downloads.
.. code-block:: shell
ytdl-sub --dry-run sub subscriptions.yaml -o '--ytdl_options.max_downloads 3'
Having many subscriptions could still make this dry run take a while. A subset of
subscriptions can be dry ran using a match.
.. code-block:: shell
:caption: Only run subscriptions that have PBS in their names
ytdl-sub --dry-run sub subscriptions.yaml -o '--ytdl_options.max_downloads 3' --match PBS
Downloading
-----------
Once the subscriptions file is validated, a download can be performed by omitting the dry run argument.
.. code-block:: shell
ytdl-sub sub subscriptions.yaml
Multiple subscription file names can be provided to perform a download on all of them. A single file
named ``subscriptions.yaml`` does not require a file name specification since it will
look for that file name by default, making the following command valid.
.. code-block:: shell
ytdl-sub sub

View file

@ -1,141 +0,0 @@
Initial Subscription
====================
Your first subscription file should look something like this:
.. code-block:: yaml
:linenos:
__preset__:
overrides:
tv_show_directory: "/tv_shows"
music_directory: "/music"
# Can choose between:
# - Plex TV Show by Date:
# - Jellyfin TV Show by Date:
# - Kodi TV Show by Date:
#
Jellyfin TV Show by Date:
= Documentaries:
"NOVA PBS": "https://www.youtube.com/@novapbs"
= Kids | = TV-Y:
"Jake Trains": "https://www.youtube.com/@JakeTrains"
YouTube Releases:
= Jazz: # Sets genre tag to "Jazz"
"Thelonious Monk": "https://www.youtube.com/@theloniousmonk3870/releases"
YouTube Full Albums:
= Lofi:
"Game Chops": "https://www.youtube.com/playlist?list=PLBsm_SagFMmdWnCnrNtLjA9kzfrRkto4i"
Let's break this down:
.. code-block:: yaml
:lineno-start: 1
__preset__:
overrides:
tv_show_directory: "/tv_shows"
music_directory: "/music"
The first :ref:`__preset__ <config_reference/subscription_yaml:File Preset>` section is where we
can set modifications that apply to every subscription in this file.
This snippet specifically adds two :ref:`override <config_reference/plugins:Overrides>` variables,
which are used by the presets below.
.. note::
It is tempting to put any override underneath ``overrides``. Keep in mind that this section
is solely for variable defining. Other :ref:`plugins <config_reference/plugins:Plugins>` need to be
set at the same indentation level as ``overrides``, not within it.
-------------------------------------
.. code-block:: yaml
:lineno-start: 6
# Can choose between:
# - Plex TV Show by Date:
# - Jellyfin TV Show by Date:
# - Kodi TV Show by Date:
#
Lines 6-10 are comments that get ignored when parsing YAML since they are prefixed with ``#``.
It is good practice to leave informative comments in your config or subscription files to remind
yourself of various things.
-------------------------------------
.. code-block:: yaml
:lineno-start: 11
Jellyfin TV Show by Date:
On line 11, we set the key to ``Jellyfin TV Show by Date``. This is a
:ref:`prebuilt preset <prebuilt_presets/index:prebuilt presets>` that configures
subscriptions to look like TV shows in the Jellyfin media player (can be changed to
one of the presets outlined in the comment above). Setting it as a YAML key implies that all
subscriptions underneath it will *inherit* this preset.
This preset expects the variable ``tv_show_directory`` to be set, which we do above.
-------------------------------------
.. code-block:: yaml
:lineno-start: 11
Jellyfin TV Show by Date:
= Documentaries:
Line 12 sets the key to ``= Documentaries``. When keys are prefixed with ``=``, it means we are
setting the genre. This value will get written to the respective metadata tags for both TV show
and music presets.
Behind the scenes, this sets the override variable ``subscription_indent_1``. Further documentation
can be found here for
:ref:`subscription syntax <config_reference/subscription_yaml:Subscription File>` and
:ref:`subscription variables <config_reference/scripting/static_variables:Subscription Variables>`.
-------------------------------------
.. code-block:: yaml
:lineno-start: 11
Jellyfin TV Show by Date:
= Documentaries:
"NOVA PBS": "https://www.youtube.com/@novapbs"
Line 13 is where we define our first subscription. We set the subscription name to ``NOVA PBS``,
and the subscription value to ``https://www.youtube.com/@novapbs``.
To see how presets ingest subscription definitions, refer to the
:ref:`preset references <config_reference/prebuilt_presets/tv_show:TV Show>`,
we can see that ``{subscription_name}`` is used to set the ``tv_show_name`` variable.
-------------------------------------
.. code-block:: yaml
:lineno-start: 11
Jellyfin TV Show by Date:
= Documentaries:
"NOVA PBS": "https://www.youtube.com/@novapbs"
= Kids | = TV-Y:
"Jake Trains": "https://www.youtube.com/@JakeTrains"
Line 15 underneath ``Jellyfin TV Show by Date``, but at the same level as ``= Documentaries``.
This means we'll inherit the TV show preset, but not the documentaries indent variable. We instead
set the indent variables to ``= Kids | = TV-Y``. This sets two indent variables. We can set
multiple presets and/or indent variables on the same key by using ``|`` as a separator.
Referring to the
:ref:`TV show preset reference <config_reference/prebuilt_presets/tv_show:TV Show>`, the first
two indent variables map to the TV show genre and TV show content rating.
The above info should be enough to understand the rest of the subscription file.

View file

@ -1,57 +1,157 @@
Getting Started Getting Started
=============== ===============
Prerequisite Knowledge Prerequisite Knowledge
---------------------- ----------------------
In order to use ``ytdl-sub`` in any of the forms listed in these docs, you will need some basic knowledge. Using ``ytdl-sub`` requires some technical knowledge. You must be able to:
Be sure that you: - do `basic CLI shell navigation`_
☑ Can navigate directories in a command line interface (or CLI) - read and write `YAML text files`_
☑ Have a basic understanding of YAML syntax If you plan on using a :ref:`Docker headless image variant
<guides/install/docker:headless image>` of ``ytdl-sub``, you can:
If you plan on using the headless image of ``ytdl-sub``, you: - use ``$ nano /config/...`` to edit configuration files inside the container
☑ Can use ``nano`` or ``vim`` to edit OR - or bind mount ``/config/`` as a Docker volume and use the editor of your choice from
the host
☑ Can mount the config directory somewhere you can open it using gui text editors Soon, it's time to start configuring ``ytdl-sub``. We provide a :doc:`./quick_start`
with rigid, rote instructions on how to get a minimal configuration up and running, but
if that serves all your needs, then you're probably better off with :ref:`one of the
more user-friendly yt-dlp wrappers available <introduction:motivation>`. As a lower
level tool with no GUI, most ``ytdl-sub`` users will need to understand at least some of
how ``ytdl-sub`` works, how it "thinks". So before you start configuring ``ytdl-sub``,
`read on <architecture>`_ to learn how ``ytdl-sub`` works.
Additional useful (but not required) knowledge: .. _`basic CLI shell navigation`:
☑ Understanding how :yt-dlp:`\ ` works https://developer.mozilla.org/en-US/docs/Learn_web_development/Getting_started/Environment_setup/Command_line
.. _`YAML text files`: http://thomasloven.com/blog/2018/08/YAML-For-Nonprogrammers/
Terminology
-----------
Must-know terminology:
- ``subscription``: URL(s) that you want to download with specific metadata requirements. Architecture
- ``preset``: A media profile comprised of YAML configuration that can specify anything from metadata layout, media quality, or any feature of ytdl-sub, to apply to subscriptions. A preset can inherit other presets. ------------
- ``prebuilt preset``: Presets that are included in ytdl-sub. These do most of the work defining plugins, overrides, etc in order to make downloads ready for player consumption.
- ``override``: Verb describing the act of overriding something in a preset. For example, the TV Show presets practically expect you to *override* the URL variable to tell ytdl-sub where to download from.
- ``override variables``: User-defined variables that are intended to *override* something.
- ``subscription file``: The file to specify all of your subscriptions and some override variables.
Intermediate terminology: For most users, ``ytdl-sub`` works as follows:
- ``plugin``: Modular logic to apply to a subscription. To use a plugin, it must be defined in a preset. Subscriptions use presets
- ``config file``: An optional file where you can define custom presets and other advanced configuration. ~~~~~~~~~~~~~~~~~~~~~~~~~
- ``yt-dlp``: The underlying application that handles downloading for ytdl-sub.
Advanced terminology: Run ``$ ytdl-sub sub`` to read :doc:`a subscription file <./subscriptions>` that defines
what subscriptions to download and place into your media library. Each subscription
selects which :doc:`presets <../../prebuilt_presets/index>` to apply. Those presets
configure how each subscription is downloaded and placed in the media library.
- ``entry variables``: Variables that derive from a downloaded yt-dlp entry (media). Presets configure plugins
- ``static variables``: Variables that do not have a dependency to entry variables. ~~~~~~~~~~~~~~~~~~~~~~~~~
- ``scripting``: Syntax that allows the use of entry variables, static variables, and functions in override variables.
:doc:`A preset <../../prebuilt_presets/index>` is effectively a set of plugin
configurations. Specifically, a preset consists of:
- base presets that it inherits from and extends
- plugin configurations
When a preset has multiple base presets and more than one of those base presets
configures the same keys for a plugin, the later/lower base preset overrides the plugin
key configurations of earlier/higher base presets. Similarly, when the preset configures
the same keys for a plugin that one of its base plugins configures, the preset
configuration overrides the base presets.
Plugins do the work
~~~~~~~~~~~~~~~~~~~
``ytdl-sub`` applies the plugins that the presets configure when it downloads a
subscription. :doc:`The plugins <../../config_reference/plugins>` control how to run
``yt-dlp``, which media in the subscription to download, how to collect and format
metadata for those media, how to place the resulting files into your media library, and
more.
Presets and subscriptions accept overrides
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Presets accept override keys and values and the preset uses those overrides to modify
their plugin configurations. Similarly, individual subscriptions can supply overrides of
their presets for just that subscription.
Subscriptions are grouped by indentation
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Most subscriptions have more in common with each other than not. Thus, defining the
presets and overrides for each subscription would result in mostly repetition and would
multiply the burden of management for the user. The more subscriptions the more work.
To avoid this redundant work, and so that the subscription configurations describe the
intent of the user, subscriptions are nested/indented under parent/ancestor keys that
define their shared configuration. To support this, ``ytdl-sub`` uses special handling
of the ancestor YAML keys above each subscription. A subscription is the most
nested/indented/descendant key that specifies the URLs for that subscription. The
ancestor keys above that subscription describe the shared presets of that subscription
and all the other descendant subscriptions under them.
Genres are also more often shared between subscriptions than not. To accommodate that
reality, the ancestor keys of subscriptions may also use :ref:`the special '= ...'
prefix to pass specific overrides
<config_reference/scripting/static_variables:subscription_indent_i>` supported by the
preset. By convention in the pre-built media type presets, the first ``= ...`` value
specifies the genre for all descendant subscriptions.
Finally, ancestor keys may use :ref:`the '... | ...' special character
<config_reference/subscription_yaml:multi keys>` to combine multiple presets and/or
genres for the descendant subscriptions beneath.
The configuration file extends pre-defined presets
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Users define additional presets in :doc:`their configuration file <./first_config>` that
they then use in most of their subscriptions. Most user-defined presets extend the
:doc:`../../prebuilt_presets/index` provided by ``ytdl-sub``.
Minimize the work to only what's necessary
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Throttling and bans are a core problem for any web scraping tool, perhaps even more so
for ``yt-dlp``, and no good actor *wants* to be an onerous burden on a
service. Similarly, many web scraping use cases involve very large sets of data that are
too big to process as a whole for performance. It's important to narrow the amount of
data considered and minimize requests.
To these ends, most presets tell ``yt-dlp`` not to consider files before the most
recently downloaded file using :ref:`the 'break_on_existing' option
<config_reference/plugins:ytdl_options>`. Similarly, and particularly for huge channels
or playlists, most users should use either :ref:`an 'Only Recent' preset
<prebuilt_presets/helpers:only recent>` and/or :ref:`the 'Chunk Downloads' preset
<prebuilt_presets/helpers:chunk downloads>` to restrict the number of downloads
considered.
Caveats
~~~~~~~
Some of these descriptions are not technically complete. For example, a subscription may
use no preset at all and will just run ``yt-dlp`` without any customization or post
processing. The subscriptions file has special support for :ref:`overriding the presets
of all subscriptions in the file <config_reference/subscription_yaml:file preset>`. The
configuration file supports :ref:`a few special options
<config_reference/config_yaml:Configuration File>` that are not about defining presets. See
:doc:`the reference documentation <../../config_reference/index>` for technically
complete details, but for almost all of the use cases served by ``ytdl-sub``, the above
is accurate and representative.
Next Steps
----------
With ``ytdl-sub`` installed and the above understood, the next step is to :doc:`start
adding subscriptions <./subscriptions>`.
Ready to Start?
---------------
Now that you've completed your install of ``ytdl-sub``, it's time to get started.
It is recommended to go through the below sections in order to fully grasp ytdl-sub.
.. toctree:: .. toctree::
:maxdepth: 2 :hidden:
first_sub subscriptions
first_download downloading
automating_downloads automating_downloads
first_config first_config
quick_start

View file

@ -0,0 +1,54 @@
Quick Start
===========
:ref:`Again <guides/getting_started/index:prerequisite knowledge>`, if the following
serves all your needs, then you're probably better off with :ref:`one of the more
user-friendly yt-dlp wrappers available <introduction:motivation>`. If you still want to
get ``ytdl-sub`` up and running quickly and without understanding, then follow these
instructions to the letter.
#. Install using :ref:`the official Docker GUI image variant <guides/install/docker:gui
image>`.
#. Update the paths for your media library:
Edit :ref:`the subscriptions file <guides/install/docker:configuration>`. Near the
top, under ``__preset__:`` and then ``overrides:``, update the values under the
``*_directory:`` keys with the correct paths for your media library *as they appear
inside the container*.
#. Select your media library software:
Change the ``Plex TV Show by Date:`` *key itself* to the preset for your media
library software. See the comment above for the available options.
#. Select the genre:
Under the library software preset key from the previous step, change the ``=
Documentaries`` *key itself* to the genre for this subscription prefixed with ``=
...``. When adding other subscriptions that have the same genre, place them under the
same key.
#. Update the subscription name and URL:
Under the genre key from the previous step, update the ``"NOVA PBS":`` key to the
directory name the downloaded files should be placed beneath. This directory will be
created under the ``tv_show_directory:`` from step #2. Then update the
``"https://www.youtube.com/@novapbs"`` value to the URL of the channel or playlist
for this subscription.
#. :ref:`Preview <guides/getting_started/downloading:preview>` and :ref:`Review
<guides/getting_started/downloading:review>` the subscription.
#. Add the rest of your subscriptions:
Repeat steps #3-6 for each of your subscriptions. Be sure to repeat the preview and
review steps for each subscription. In general, move slowly and carefully review
everything. It's best to catch issues early :ref:`to avoid repeating downloads and to
minimize requests <guides/getting_started/index:minimize the work to only what's
necessary>`.
#. Automate downloads:
:ref:`Set up ytdl-sub to run periodically
<guides/getting_started/automating_downloads:docker and unraid>`.

View file

@ -0,0 +1,167 @@
Subscriptions
=============
Once you understand :ref:`how ytdl-sub works
<guides/getting_started/index:architecture>`, it's time to start writing your
:doc:`../../config_reference/subscription_yaml`.
Media library paths
-------------------
Everyone's media library may use different paths so ``ytdl-sub`` can't provide
defaults. Tell ``ytdl-sub`` where to put your media using :ref:`overrides
<guides/getting_started/index:presets and subscriptions accept overrides>`:
.. code-block:: yaml
:caption: subscriptions.yaml
:emphasize-lines: 3-
__preset__:
overrides:
tv_show_directory: "/tv_shows"
music_directory: "/music"
music_video_directory: "/music_videos"
See the reference documentation for details about :ref:`the '__preset__:' special key
<config_reference/subscription_yaml:file preset>`.
Media library software and media types
--------------------------------------
Different media library software, such as `Jellyfin`_, `Kodi`_, Plex, or Emby, have
different requirements for where media files are placed, how those files are named, how
metadata is formatted, and more. Those software also have different requirements for
different types of media, such as shows/series, music, music videos, etc.. Use
:doc:`prebuilt presets <../../prebuilt_presets/index>` in :ref:`YAML keys
<guides/getting_started/index:subscriptions are grouped by indentation>` to tell
``ytdl-sub`` which media library software and media type to process downloaded files
for.
The actual subscription is defined in the lowest indentation level YAML keys. The
example below defines a subscription named ``NOVA PBS`` to archive downloads from the
entries in the ``https://www.youtube.com/@novapbs`` URL.
.. code-block:: yaml
:caption: subscriptions.yaml
:emphasize-lines: 1,4
Jellyfin TV Show by Date:
"NOVA PBS": "https://www.youtube.com/@novapbs"
Bandcamp:
"Emily Hopkins": "https://emilyharpist.bandcamp.com/"
.. _`Jellyfin`:
https://jellyfin.org/
.. _`Kodi`:
https://kodi.tv/
Which entries
-------------
The :doc:`helper presets <../../prebuilt_presets/helpers>` also provide support for
controlling which entries are downloaded and archived. These presets are intended to be
combined with the library software and media type presets.
Combine presets using :ref:`the '.. | ...' special character
<guides/getting_started/index:subscriptions are grouped by indentation>` in the YAML
keys:
.. code-block:: yaml
:caption: subscriptions.yaml
:emphasize-lines: 2,6
# Only download entries whose upload date is within the past 2 months:
Kodi TV Show by Date | Only Recent:
"NOVA PBS": "https://www.youtube.com/@novapbs"
# Only download 20 entries per run:
Soundcloud Discography | Chunk Downloads:
"UKNOWY": "https://soundcloud.com/uknowymunich"
What format, quality, or resolution
-----------------------------------
The :doc:`media quality presets <../../prebuilt_presets/media_quality>` provide support
for controlling which ``yt-dlp`` media "format" to download, such as ``1080p`` video
resolution or ``320k`` audio bitrate.
Users may also group and combine presets :ref:`using the YAML hierarchy
<guides/getting_started/index:subscriptions are grouped by indentation>`. Subscriptions
merge all the presets from their ancestor YAML keys. The hierarchy indentation depth may
be as deep as needed to group your subscriptions for easy maintenance:
.. code-block:: yaml
:caption: subscriptions.yaml
:emphasize-lines: 3,7,12
Jellyfin TV Show by Date | Only Recent:
# Download the highest resolution available:
Max Video Quality:
"NOVA PBS": "https://www.youtube.com/@novapbs"
"National Geographic": "https://www.youtube.com/@NatGeo"
# Download the highest resolution available that is 720p or less:
Max 720p:
"Cosmos - What If": "https://www.youtube.com/playlist?list=PLZdXRHYAVxTJno6oFF9nLGuwXNGYHmE8U"
Soundcloud Discography | Chunk Downloads:
# Only download audio using the Opus codec, not MP3 or other codecs:
Max Opus Quality:
"UKNOWY": "https://soundcloud.com/uknowymunich"
Genre and rating metadata
-------------------------
Presets may also support using arbitrary values from :ref:`YAML keys prefixed with '=
...' <guides/getting_started/index:subscriptions are grouped by indentation>`. The ``=
...`` prefix may be used at any indentation depth and may also be combined with presets
and other ``= ...`` values using the ``... | ...`` special character to best group your
subscriptions.
:ref:`By convention <config_reference/scripting/static_variables:subscription_indent_i>`
in the built-in library software and media type presets, the first ``= ...`` value
specifies the genre for all descendant subscriptions. For the ``TV Show ...`` presets,
the second ``= ...`` value specifies the rating for all descendant subscriptions:
.. code-block:: yaml
:caption: subscriptions.yaml
:emphasize-lines: 1,3
= Kids:
Jellyfin TV Show by Date | = TV-Y:
"Jake Trains": "https://www.youtube.com/@JakeTrains"
"Kids Toys Play": "https://www.youtube.com/@KidsToysPlayChannel"
Soundcloud Discography:
"Foo Kids Band": "https://soundcloud.com/foo-kids-band"
Override variables for one subscription
---------------------------------------
Most variable overrides aren't actually specific to just one subscription and should be
set in :doc:`your own custom presets <./first_config>`. But use :ref:`the override mode
'~...' prefix <config_reference/subscription_yaml:override mode>` when an override is
specific to only one subscription and will never be shared with another:
.. code-block:: yaml
:caption: subscriptions.yaml
:emphasize-lines: 2-
Jellyfin TV Show by Date:
"~NOVA PBS":
url: "https://www.youtube.com/@novapbs"
tv_show_directory: "/media/Unique/Series/Path"
Next Steps
----------
Once you've defined your subscriptions, it's time to :doc:`test your configuration and
try your first download <./downloading>`.

View file

@ -2,13 +2,15 @@
Environment Agnostic Environment Agnostic
==================== ====================
The PIP install method is not recommended; use of this method may cause unintended requirement conflicts if you have other locally installed apps that depend on ffmpeg. The PIP install method is not recommended; use of this method may cause unintended
requirement conflicts if you have other locally installed apps that depend on ffmpeg.
PIP Install PIP Install
-------------- --------------
You can install our
`PyPI package <https://pypi.org/project/ytdl-sub/>`_. You can install our `PyPI package <https://pypi.org/project/ytdl-sub/>`_. Both ffmpeg
Both ffmpeg and Python 3.10 or greater are required. and Python 3.10 or greater are required.
.. code-block:: bash .. code-block:: bash
@ -17,10 +19,13 @@ Both ffmpeg and Python 3.10 or greater are required.
Install for Development Install for Development
======================= =======================
These environment-agnostic methods of installing ``ytdl-sub`` are meant for local development of ``ytdl-sub``. If you want to contribute your changes, please read :doc:`/guides/development/index`. These environment-agnostic methods of installing ``ytdl-sub`` are meant for local
development of ``ytdl-sub``. If you want to contribute your changes, please read
:doc:`/guides/development/index`.
Local Install Local Install
-------------- --------------
With a Python 3.10 virtual environment, you can clone and install the repo. With a Python 3.10 virtual environment, you can clone and install the repo.
.. code-block:: bash .. code-block:: bash
@ -32,12 +37,13 @@ With a Python 3.10 virtual environment, you can clone and install the repo.
Local Docker Build Local Docker Build
------------------- -------------------
Run ``make docker`` in the root directory of this repo to build the image. This
will build the python wheel and install it in the Dockerfile. Run ``make docker`` in the root directory of this repo to build the image. This will
build the python wheel and install it in the Dockerfile.
.. code-block:: bash .. code-block:: bash
git clone https://github.com/jmbannon/ytdl-sub.git git clone https://github.com/jmbannon/ytdl-sub.git
cd ytdl-sub cd ytdl-sub
make docker make docker

View file

@ -2,142 +2,98 @@
Docker Docker
====== ======
For automating ``subscriptions.yaml`` downloads to pull new media, see :ref:`this page <guides/getting_started/automating_downloads:docker and unraid>` on how to set up a cron job in any of the docker containers. The ``ytdl-sub`` Docker images use :lsio:`LSIO-based images <\ >` and install ytdl-sub
on top. There are two flavors or variants to choose from. For a more user-friendly
experience editing the `configuration`_, we recommend the `GUI image`_
variant. :ref:`Docker Compose <guides/install/docker:install with docker compose>` is
the recommended way of managing a ``ytdl-sub`` docker container. See :ref:`Automating
Downloads <guides/getting_started/automating_downloads:docker and unraid>` for how to
automate running ``ytdl-sub`` in a container running either variant.
The ``ytdl-sub`` Docker images use :lsio:`LSIO-based images <\ >` and install ytdl-sub on top. There are two flavors to choose from.
.. margin::
.. tip::
The recommended docker image is the GUI image.
:ref:`Docker Compose <guides/install/docker:install with docker compose>` is the recommended way of setting up a ``ytdl-sub`` docker container.
GUI Image GUI Image
--------- ---------
The GUI image uses LSIO's :lsio-gh:`docker-code-server image <\ >` for its base image. More info on other code-server environment variables can be found within its documentation. The GUI image is based on LSIO's :lsio-gh:`docker-code-server` to provide you full
management of ``ytdl-sub``, such as file editing and terminal access, all within your
browser using the VS Code web UI. See its documentation regarding environment variables
and other details. Once running, open `the web UI`_ to edit the `configuration`_ and run
``ytdl-sub``.
.. _`the web UI`: http://localhost:8443
After starting, the code-server will be running at http://localhost:8443. Open this page in a browser to access and interact with ``ytdl-sub``.
Headless Image Headless Image
-------------- --------------
The headless image uses LSIO's :lsio-gh:`docker-baseimage-alpine image <\ >` for its base image. Execute the following command to access and interact with ``ytdl-sub``: The headless image is based on LSIO's :lsio-gh:`docker-baseimage-alpine`. Once running,
the default command just starts services including cron for :ref:`Automating Downloads
<guides/getting_started/automating_downloads:docker and unraid>` but otherwise doesn't
run ``ytdl-sub``. You may run arbitrary ``ytdl-sub`` commands using the
``--rm --user="${PUID}:${PGID}" --entrypoint="ytdl-sub"`` options to either ``$ docker
run`` or ``$ docker compose run``. Overriding the image's ``ENTRYPOINT`` is important so
that cron doesn't run ``ytdl-sub`` while you're running it manually.
.. code-block:: bash For example::
$ docker compose run --rm --user="${PUID}:${PGID}" --entrypoint="ytdl-sub" ytdl-sub sub
docker exec -u abc -it ytdl-sub /bin/bash
Install with Docker Compose Install with Docker Compose
--------------------------- ---------------------------
Docker Compose is an easy "set it and forget it" install method. Follow the instructions below to create a ``compose.yaml`` file for your chosen ``ytdl-sub`` image. Docker Compose provides a declarative way to configure and orchestrate containers which
makes them easier to manage and re-use. Create a ``compose.yaml`` file in your project
.. margin:: directory such as:
.. important::
Set the PUID and PGID to the UID and GID associated with the user you want to own the downloaded files. Setting these values to root UID and GID may create issues with your media managers.
.. tab-set::
.. tab-item:: GUI Image
.. code-block:: yaml
:caption: compose.yaml
services:
ytdl-sub:
image: ghcr.io/jmbannon/ytdl-sub-gui:latest
container_name: ytdl-sub
environment:
- PUID=1000
- PGID=1000
- TZ=America/Los_Angeles
volumes:
- <path/to/ytdl-sub/config>:/config
- <path/to/tv_shows>:/tv_shows # optional
- <path/to/movies>:/movies # optional
- <path/to/music_videos>:/music_videos # optional
- <path/to/music>:/music # optional
ports:
- 8443:8443
restart: unless-stopped
.. tab-item:: Headless Image
.. code-block:: yaml
:caption: compose.yaml
services:
ytdl-sub:
image: ghcr.io/jmbannon/ytdl-sub:latest
container_name: ytdl-sub
environment:
- PUID=1000
- PGID=1000
- TZ=America/Los_Angeles
- DOCKER_MODS=linuxserver/mods:universal-cron
volumes:
- <path/to/ytdl-sub/config>:/config
- <path/to/tv_shows>:/tv_shows # optional
- <path/to/movies>:/movies # optional
- <path/to/music_videos>:/music_videos # optional
- <path/to/music>:/music # optional
restart: unless-stopped
Device Passthrough
~~~~~~~~~~~~~~~~~~~
For CPU or GPU passthrough, you must use either the GUI image or the headless Ubuntu image
``ghcr.io/jmbannon/ytdl-sub:ubuntu-latest``.
The docker-compose examples use the GUI image.
CPU Passthrough
^^^^^^^^^^^^^^^
.. code-block:: yaml .. code-block:: yaml
:emphasize-lines: 5-6 :caption: compose.yaml
:caption: compose.yaml
services: services:
ytdl-sub: ytdl-sub:
image: ghcr.io/jmbannon/ytdl-sub-gui:latest # The GUI image variant:
container_name: ytdl-sub image: ghcr.io/jmbannon/ytdl-sub-gui:latest
devices: # Or use the headless image variant:
- /dev/dri:/dev/dri # CPU passthrough # image: ghcr.io/jmbannon/ytdl-sub:latest
restart: unless-stopped # For CPU/GPU passthrough, use the GUI image above or the headless Ubuntu image:
# image: ghcr.io/jmbannon/ytdl-sub:ubuntu-latest
container_name: ytdl-sub
restart: unless-stopped
environment:
- TZ=America/Los_Angeles
# Set these as appropriate so your users can access the downloaded files in
# your library:
- PUID=1000
- PGID=1000
# Optionally passthrough your NVidia GPU:
# - NVIDIA_DRIVER_CAPABILITIES=all
# - NVIDIA_VISIBLE_DEVICES=all
volumes:
- <path/to/ytdl-sub/config>:/config
- <path/to/tv_shows>:/tv_shows # optional
- <path/to/movies>:/movies # optional
- <path/to/music_videos>:/music_videos # optional
- <path/to/music>:/music # optional
# Not necessary for the headless image variant:
ports:
- 8443:8443
# Optionally passthrough the CPU for hardware acceleration:
# devices:
# - /dev/dri:/dev/dri
# Optionally passthrough the GPU:
# deploy:
# resources:
# reservations:
# devices:
# - capabilities: ["gpu"]
GPU Passthrough
^^^^^^^^^^^^^^^
.. Awe
.. code-block:: yaml
:caption: compose.yaml
:emphasize-lines: 5-13
services:
ytdl-sub:
image: ghcr.io/jmbannon/ytdl-sub-gui:latest
container_name: ytdl-sub
environment:
- ..
- NVIDIA_DRIVER_CAPABILITIES=all # Nvidia ENV args
- NVIDIA_VISIBLE_DEVICES=all
deploy:
resources:
reservations:
devices:
- capabilities: ["gpu"] # GPU passthrough
restart: unless-stopped
Docker CLI Docker CLI
---------- ----------
If you prefer to only run the container once, you can use the CLI command instead. The following command is for the gui image, and will not restart if it comes down for any reason. See `the Docker reference <https://docs.docker.com/engine/reference/run/>`_ for further information on the parameters and other options you can use. You can run the container on an ad-hoc basis without Docker Compose using the Docker CLI
instead. It will not restart if stopped for any reason, including rebooting the
host. The following command is for the gui image:
.. code-block:: bash .. code-block:: bash
@ -152,4 +108,32 @@ If you prefer to only run the container once, you can use the CLI command instea
-v <OPTIONAL/path/to/movies>:/movies \ -v <OPTIONAL/path/to/movies>:/movies \
-v <OPTIONAL/path/to/music_videos>:/music_videos \ -v <OPTIONAL/path/to/music_videos>:/music_videos \
-v <OPTIONAL/path/to/music>:/music \ -v <OPTIONAL/path/to/music>:/music \
ghcr.io/jmbannon/ytdl-sub-gui:latest ghcr.io/jmbannon/ytdl-sub-gui:latest
See `the Docker reference <https://docs.docker.com/engine/reference/run/>`_ for further
details.
Environment Variables
---------------------
``ytdl-sub`` docker images support the following environment variables.
.. csv-table:: Docker Environment Variables
:header: "Name", "Supported Values", "Description"
:widths: 15, 10, 60
"``PUID``", "integer", "User ID"
"``PGID``", "integer", "Group ID"
"``TZ``", "timezone", "Optional. Timezone to use in the logs. For supported values, see this `list <https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List>`_. "
"``CRON_SCHEDULE``", "cron schedule `format <https://crontab.guru/#0_*/6_*_*_*>`_", "Optional. Schedule to run the ``cron`` file in ytdl-sub's container. More info :ref:`here <guides/getting_started/automating_downloads:docker and unraid>`."
"``CRON_RUN_ON_START``", "true/false", "Optional. Whether to run the cron script on container start."
"``UPDATE_YT_DLP_ON_START``", "stable/nightly/master", "Optional. Whether to update yt-dlp to the latest configured version on container start."
For the GUI image, you can set LSIO's underlying code-server `env variables <https://docs.linuxserver.io/images/docker-code-server/#environment-variables-e>`_ as well."
Configuration
-------------
In these examples, the configuration files will be at
``<path/to/ytdl-sub/config>/config.yaml`` and
``<path/to/ytdl-sub/config>/subscriptions.yaml``. Start the container the first time to
populate those files with default examples.

View file

@ -1,5 +1,6 @@
Install by Platform Install by Platform
=================== ===================
``ytdl-sub`` can be installed on the following platforms. ``ytdl-sub`` can be installed on the following platforms.
All installations require a 64-bit CPU. 32-bit is not supported. All installations require a 64-bit CPU. 32-bit is not supported.
@ -8,7 +9,8 @@ All installations require a 64-bit CPU. 32-bit is not supported.
.. tip:: .. tip::
The recommended install method of ``ytdl-sub`` is one of our :doc:`docker containers </guides/install/docker>`. For install on Unraid, check out our :unraid:`unraid community apps <community/apps?q=ytdl-sub#r>`. The recommended install method of ``ytdl-sub`` is one of our :doc:`docker containers
</guides/install/docker>`.
:doc:`/guides/install/docker` :doc:`/guides/install/docker`
@ -20,9 +22,8 @@ All installations require a 64-bit CPU. 32-bit is not supported.
:doc:`/guides/install/agnostic` :doc:`/guides/install/agnostic`
Once you've completed your installation, please refer to the
:doc:`../getting_started/index` guide for next steps
Once you've completed your installation, please refer to the :doc:`../getting_started/index` guide for next steps
.. toctree:: .. toctree::
:hidden: :hidden:

View file

@ -2,8 +2,8 @@
Linux Linux
===== =====
``ytdl-sub`` should be installable using any Linux package manager, and requires ffmpeg to be installed. ``ytdl-sub`` should be installable using any Linux package manager, and requires ffmpeg
to be installed.
.. tab-set:: .. tab-set::
@ -15,7 +15,8 @@ Linux
chmod +x ytdl-sub chmod +x ytdl-sub
./ytdl-sub -h ./ytdl-sub -h
You can also install using yt-dlp's ffmpeg builds. This ensures your ffmpeg is up to date: You can also install using yt-dlp's ffmpeg builds. This ensures your ffmpeg is up to
date:
.. code-block:: bash .. code-block:: bash
@ -36,7 +37,8 @@ Linux
chmod +x ytdl-sub chmod +x ytdl-sub
./ytdl-sub -h ./ytdl-sub -h
You can also install using yt-dlp's ffmpeg builds. This ensures your ffmpeg is up to date: You can also install using yt-dlp's ffmpeg builds. This ensures your ffmpeg is up to
date:
.. code-block:: bash .. code-block:: bash

View file

@ -1,16 +1,29 @@
======
Unraid Unraid
-------------- ======
You can install our :unraid:`unraid community apps <community/apps?q=ytdl-sub#r>` through the `Unraid Community Apps plugin <https://unraid.net/community/apps>`_.
You can install our :unraid:`unraid community apps <community/apps?q=ytdl-sub#r>`
through the `Unraid Community Apps plugin <https://unraid.net/community/apps>`_.
If you installed the ``ytdl-sub-gui`` app, the code-server will be running at http://localhost:8443 (replace ``localhost`` with the IP of the computer running Unraid if you aren't trying to access ``ytdl-sub`` on that computer). Open this page in a browser to access and interact with ``ytdl-sub``. If you installed the ``ytdl-sub-gui`` app, the code-server will be running at
http://localhost:8443 (replace ``localhost`` with the IP of the computer running Unraid
if you aren't trying to access ``ytdl-sub`` on that computer). Open this page in a
browser to access and interact with ``ytdl-sub``.
If you installed the ``ytdl-sub`` app (headless), open the normal app-specific console to access and interact with ``ytdl-sub``. Once open, you must first run ``su abc -s /bin/bash`` to change to the non-root user. You can confirm that this command worked by running ``whoami`` and verifying that the result is ``abc``. If you installed the ``ytdl-sub`` app (headless), open the normal app-specific console
to access and interact with ``ytdl-sub``. Once open, you must first run ``su abc -s
/bin/bash`` to change to the non-root user. You can confirm that this command worked by
running ``whoami`` and verifying that the result is ``abc``.
.. warning::
.. warning:: If you use the below option to access the ``ytdl-sub`` console, be sure to run ``su
abc -s /bin/bash`` first thing. You can confirm that this command worked by running
``whoami`` and verifying that the result is ``abc``. Do **NOT** run ``ytdl-sub`` as
the root user! Running as root will set the owner of all modified files to root,
which prevents most media managers and players from accessing the files.
If you use the below option to access the ``ytdl-sub`` console, be sure to run ``su abc -s /bin/bash`` first thing. You can confirm that this command worked by running ``whoami`` and verifying that the result is ``abc``. Do **NOT** run ``ytdl-sub`` as the root user! Running as root will set the owner of all modified files to root, which prevents most media managers and players from accessing the files. .. figure:: ../../../images/unraid_badconsole.png
:alt:
.. figure:: ../../../images/unraid_badconsole.png The Unraid community app plugin GUI, with an arrow pointing at the "Console"
:alt: The Unraid community app plugin GUI, with an arrow pointing at the "Console" option in the dropdown after selecting ytdl-sub-gui option in the dropdown after selecting ytdl-sub-gui

View file

@ -1,5 +1,7 @@
=======
Windows Windows
-------------- =======
From powershell, run: From powershell, run:
.. code-block:: powershell .. code-block:: powershell
@ -12,4 +14,4 @@ From powershell, run:
# Download ytdl-sub # Download ytdl-sub
curl.exe -L -o ytdl-sub.exe https://github.com/jmbannon/ytdl-sub/releases/latest/download/ytdl-sub.exe curl.exe -L -o ytdl-sub.exe https://github.com/jmbannon/ytdl-sub/releases/latest/download/ytdl-sub.exe
ytdl-sub.exe -h ytdl-sub.exe -h

View file

@ -10,7 +10,6 @@ ytdl-sub User Guide
prebuilt_presets/index prebuilt_presets/index
usage usage
config_reference/index config_reference/index
debugging
faq/index faq/index
deprecation_notices deprecation_notices
.. note:: The docs are heavily work-in-progress. Please bear with us while we're under construction!

View file

@ -8,10 +8,23 @@ What is ytdl-sub?
.. _plex: https://github.com/plexinc/pms-docker .. _plex: https://github.com/plexinc/pms-docker
.. _emby: https://github.com/plexinc/pms-docker .. _emby: https://github.com/plexinc/pms-docker
``ytdl-sub`` is a command-line tool that downloads media via `yt-dlp`_ and prepares it for your favorite media player (`Kodi`_, `Jellyfin`_, `Plex`_, `Emby`_, modern music players). ``ytdl-sub`` is a command-line tool that builds on and orchestrates `yt-dlp`_ to
download media from YouTube and/or other online services. It provides a declarative,
expressive YAML configuration system that allows you to describe which media to download
and how it should appear in your media library servers and applications such as
`Jellyfin`_, `Plex`_, `Emby`_, `Kodi`_, modern music players, etc..
Visual examples To these ends, ``ytdl-sub``:
===============
- wraps and runs `yt-dlp`_, per your configuration to:
- download the media, remux and/or optionally transcode it
- prepares additional metadata both embedded and in external files
- renames the resulting files
- places them in your library
.. figure:: https://user-images.githubusercontent.com/10107080/182677243-b4184e51-9780-4094-bd40-ea4ff58555d0.PNG .. figure:: https://user-images.githubusercontent.com/10107080/182677243-b4184e51-9780-4094-bd40-ea4ff58555d0.PNG
:alt: The Jellyfin web interface, showing the thumbnails of various YouTube shows. :alt: The Jellyfin web interface, showing the thumbnails of various YouTube shows.
@ -34,10 +47,52 @@ Visual examples
SoundCloud albums and singles in MusicBee SoundCloud albums and singles in MusicBee
Why ytdl-sub? Motivation
------------- ----------
There is a lack of open-source tools to download media and generate metadata to play it in these players. Most solutions involve using multiple tools or bash scripts to achieve this. ``ytdl-sub`` aims to consolidate all of this logic into a single easy-to-use application that can run automatically once configured.
`yt-dlp`_ has grown into a well maintained, central repository of the intricate,
inscrutable, and extensive technical knowledge required to automate downloading media
from online services. When those services change their APIs or otherwise change
behavior, `yt-dlp`_ is the central, low-level tool to update. It does a best-in-class
job at that task, and it does that job more effectively by narrowing focus to just that.
As much knowledge as it encapsulates and as well as it does that, it still requires a
great deal of additional knowledge to make its output accessible to end-users. Mostly
this gap is about extracting and formatting metadata and correctly placing the resulting
output files in a media library.
A number of tools, applications, and other projects have grown up around that central
`yt-dlp`_ pillar to fill in those gaps, and this project was one of the early
entrants. Many are `full-featured services that provide web UIs`_ including some that
`provide media player web UIs`_. Most of those other projects necessarily narrow their
scope to provide a more polished and integrated user experience.
Similarly, ``ytdl-sub`` can run automatically to accomplish the same goals, but aims to
serve users that need lower-level control and/or have use cases not covered by the more
narrow scope of those other projects. To some degree, this makes this project
intrinsically less user friendly and requires more technical experience or learning.
Want something that "Just Works", try one of the other projects; we recommend
`Pinchflat`_ as the next step towards that end. Want to download from more than just
YouTube? Don't like the other restrictions inherent in the goals of those other
projects? Have unique use cases? Then dig in, learn, and we hope ``ytdl-sub`` gives you
enough rope and `a foot-gun`_ to get you there.
.. _`full-featured services that provide web UIs`:
https://github.com/kieraneglin/pinchflat
.. _`provide media player web UIs`:
https://www.tubearchivist.com/
.. _`Pinchflat`: `full-featured services that provide web UIs`_
.. _`a foot-gun`: https://en.wiktionary.org/wiki/footgun
Why download instead of stream? Why download instead of stream?
------------------------------- -------------------------------
We believe it is important to download what you like because there is no guarantee it will stay online forever. We also believe it is important to download it in such a way that it is easy to consume. Most solutions today force you to watch/listen to your downloaded content via file system or web browser. ``ytdl-sub`` aims to format downloaded content for any media player.
Most of the tools in this `yt-dlp`_ ecosystem serve a similar set of larger, more
general use cases, and so does ``ytdl-sub``:
- Don't rely on profit-driven corporate persons to keep more obscure content available.
- Even if they do, don't depend on them to make it possible to use it in different ways.
- Even when you pay, don't count on them not inserting ads later.
- Regardless, don't depend on them to curate content for yourself and/or your family.
- Free yourself and/or your family from what the algorithm would feed them next.

View file

@ -1,3 +1,6 @@
============== ==============
Helper Presets Helper Presets
============== ==============
@ -6,11 +9,13 @@ Helper Presets
See how to apply helper presets :doc:`here </prebuilt_presets/index>` See how to apply helper presets :doc:`here </prebuilt_presets/index>`
Only Recent Only Recent
----------- -----------
To only download a recent number of videos, apply the ``Only Recent`` preset. Once a video's To only download a recent number of videos, apply the ``Only Recent`` preset. Once a
upload date is outside of the range, or you hit max files, older videos will be deleted automatically. video's upload date is outside of the range, or you hit max files, older videos will be
deleted automatically.
.. code-block:: yaml .. code-block:: yaml
@ -31,9 +36,12 @@ To prevent deletion of files, use the preset ``Only Recent Archive`` instead.
Filter Keywords Filter Keywords
--------------- ---------------
``Filter Keywords`` can include or exclude media with any of the listed keywords. Both keywords and title/description are lower-cased before filtering. ``Filter Keywords`` can include or exclude media with any of the listed keywords. Both
keywords and title/description are lower-cased before filtering.
Default behavior for Keyword evaluation is ANY, meaning the filter will succeed if any of the keywords are present. This can be set to ANY or ALL using the respective ``_eval`` variable. Default behavior for Keyword evaluation is ANY, meaning the filter will succeed if any
of the keywords are present. This can be set to ANY or ALL using the respective
``_eval`` variable.
Supports the following override variables: Supports the following override variables:
@ -71,17 +79,49 @@ Supports the following override variables:
- "maple leafs" - "maple leafs"
- "highlights" - "highlights"
Filter Duration
---------------
``Filter Duration`` can include or exclude media based on its duration.
Supports the following override variables:
* ``filter_duration_min_s``
* ``filter_duration_max_s``
.. tip::
Use the `~` tilda subscription mode to set a subscription's list override variables.
Tilda mode allows override variables to be set directly underneath it.
.. code-block:: yaml
Plex TV Show by Date | Filter Duration:
= Documentaries:
"~NOVA PBS":
url: "https://www.youtube.com/@novapbs"
filter_duration_min_s: 120 # Only download videos at least 2m long
= Sports:
"~Maple Leafs Highlights":
url: "https://www.youtube.com/@NHL"
filter_duration_max_s: 180 # Only get highlight videos less than 3m long
Chunk Downloads Chunk Downloads
--------------- ---------------
If you are archiving a large channel, ``ytdl-sub`` will try pulling each video's metadata from newest to oldest before If you are archiving a large channel, ``ytdl-sub`` will try pulling each video's
starting any downloads. It is a long process and not ideal. A better method is to chunk the process by using the metadata from newest to oldest before starting any downloads. It is a long process and
following preset: not ideal. A better method is to chunk the process by using the following preset:
``Chunk Downloads`` ``Chunk Downloads``
It will download videos starting from the oldest one, and only download 20 at a time by default. You can It will download videos starting from the oldest one, and only download 20 at a time by
change this number by setting the override variable ``chunk_max_downloads``. default. You can change this number by setting the override variable
``chunk_max_downloads``.
.. code-block:: yaml .. code-block:: yaml
@ -100,5 +140,98 @@ change this number by setting the override variable ``chunk_max_downloads``.
= Documentaries: = Documentaries:
"Cosmos - What If": "https://www.youtube.com/playlist?list=PLZdXRHYAVxTJno6oFF9nLGuwXNGYHmE8U" "Cosmos - What If": "https://www.youtube.com/playlist?list=PLZdXRHYAVxTJno6oFF9nLGuwXNGYHmE8U"
Once the entire channel is downloaded, remove the usage of this preset. It will then pull metadata from newest to Once the entire channel is downloaded, remove the usage of this preset. It will then
oldest again, and stop once it reaches a video that has already been downloaded. pull metadata from newest to oldest again, and stop once it reaches a video that has
already been downloaded.
_throttle_protection
--------------------
.. note::
This preset is already a base preset of those higher-level presets that require it,
so users seldom need to use it directly, for example, unless they're writing presets
from scratch.
This preset is primarily a sensible default configuration of :ref:`the
'throttle_protection' plugin <config_reference/plugins:throttle_protection>` along with
an override to disable the plugin:
.. code-block:: yaml
overrides:
# Disable throttle protection:
enable_throttle_protection: false
In addition to throttling by denying download requests, some services also throttle
downloads by only allowing downloads of the lowest resolution quality. At the time of
writing, only YouTube does this by allowing only 360p downloads when throttled. To work
around this kind of throttling, this preset includes :ref:`an assertion
<config_reference/scripting/scripting_functions:error functions>` that will stop
downloading when ``ytdl-sub`` downloads a video at 360p or lower. It supports the
following overrides:
.. code-block:: yaml
overrides:
# Disable resolution quality throttle protection:
enable_resolution_assert: false
# Change the resolution below which to assume downloading is throttled:
resolution_assert_height_gte: 720
.. _resolution assert handling:
Handling Low Quality Videos
~~~~~~~~~~~~~~~~~~~~~~~~~~~
A side effect from throttle protection's resolution assert is, if the only resolution available is 360p or lower, it will
error. You can either disable resolution assert entirely (see above), or ignore specific titles in the subscription
using the ``resolution_assert_ignore_titles`` variable. Add a subset of the title (case-sensitive) as a list entry
to your subscription, like so:
.. code-block:: yaml
# use tilda mode to set override variables to the subscription
"~My Subscription":
url: "https://youtube.com/@channel"
resolution_assert_ignore_titles:
- "This 360p Video Title"
_url
----
All prebuilt presets share the same internal ``_multi_url`` preset which comes equipped with
a few available customizations.
Sibling Metadata
~~~~~~~~~~~~~~~~
*Sibling* refers to any entry within the same *playlist*. For channel downloads, this would
imply **every** video that gets downloaded since yt-dlp treats the channel as the *playlist*.
Setting the variable ``include_sibling_metadata`` will include all sibling metadata within
each individual entry's metadata. This is used specifically for music presets. When downloading
a playlist as an album for example, it will take the max year amongst all the other sibling's metadata
to have a consistent album year that can be used in file or directory naming.
Webpage URL
~~~~~~~~~~~
``ytdl-sub`` performs downloads in two stages.
1. Metadata scrape from the original URL
2. Individual entry downloads
For step 2, ``ytdl-sub`` will use the ``webpage_url`` variable by default for the input URL to yt-dlp.
This can be modified in case it's not working as expected by using the variable ``modified_webpage_url``.
Example:
.. code-block:: yaml
:caption:
Removes yt-dlp smuggle data from the URL
overrides:
modified_webpage_url: >-
{ %regex_sub("#__youtubedl_smuggle=.*", "", webpage_url) }

View file

@ -7,11 +7,13 @@ media in various players.
.. hint:: .. hint::
Apply multiple presets to your subscriptions using pipes. Pipes can define multiple presets and values Apply multiple presets to your subscriptions using pipes. Pipes can define multiple
on the same line to apply to all subscriptions nested below them. presets and values on the same line to apply to all subscriptions nested below them.
.. code-block:: yaml .. code-block:: yaml
:caption: Applies Max Video Quality preset to all TV shows, and Chunk Downloads preset to some :caption:
Applies Max Video Quality preset to all TV shows, and Chunk Downloads preset to
some
Plex TV Show by Date | Max Video Quality: Plex TV Show by Date | Max Video Quality:
@ -22,9 +24,8 @@ media in various players.
= Documentaries: = Documentaries:
"Cosmos - What If": "https://www.youtube.com/playlist?list=PLZdXRHYAVxTJno6oFF9nLGuwXNGYHmE8U" "Cosmos - What If": "https://www.youtube.com/playlist?list=PLZdXRHYAVxTJno6oFF9nLGuwXNGYHmE8U"
For advanced users, you can review the prebuilt preset definitions :doc:`here
For advanced users, you can review the prebuilt preset </config_reference/prebuilt_presets/index>`.
definitions :doc:`here </config_reference/prebuilt_presets/index>`.
.. toctree:: .. toctree::
:titlesonly: :titlesonly:
@ -33,4 +34,4 @@ definitions :doc:`here </config_reference/prebuilt_presets/index>`.
music music
music_videos music_videos
media_quality media_quality
helpers helpers

View file

@ -6,8 +6,10 @@ Media Quality Presets
See how to apply media quality presets :doc:`here </prebuilt_presets/index>` See how to apply media quality presets :doc:`here </prebuilt_presets/index>`
Video Video
----- -----
The following presets set video quality specifications to yt-dlp. The following presets set video quality specifications to yt-dlp.
- ``Max Video Quality`` - ``Max Video Quality``
@ -17,10 +19,12 @@ The following presets set video quality specifications to yt-dlp.
- ``Max 720p`` - ``Max 720p``
- ``Max 480p`` - ``Max 480p``
Audio Audio
----- -----
The following presets set audio quality specifications to yt-dlp.
These assume you are only extracting audio (no video). The following presets set audio quality specifications to yt-dlp. These assume you are
only extracting audio (no video).
- ``Max Audio Quality``, format is determined by the source - ``Max Audio Quality``, format is determined by the source
- ``Max MP3 Quality`` - ``Max MP3 Quality``

View file

@ -2,21 +2,16 @@
Music Presets Music Presets
============= =============
Music downloadable by yt-dlp comes in many flavors. ``ytdl-sub`` offers a suite Music downloadable by yt-dlp comes in many flavors. ``ytdl-sub`` offers a suite of
of various presets for handling some of the most popular forms of uploaded music various presets for handling some of the most popular forms of uploaded music content.
content.
.. hint::
The subscription *value* (denoted by =) will set the genre tag for all music scraped under its key
for all music presets.
YouTube Releases YouTube Releases
---------------- ----------------
Many artists, especially those auto-uploaded as ``Topics`` in YouTube have a section on Many artists, especially those auto-uploaded as ``Topics`` in YouTube have a section on
their channel named "Releases", or "Albums and Singles". The ``YouTube Releases`` preset aims to their channel named "Releases", or "Albums and Singles". The ``YouTube Releases`` preset
scrape this *playlist of playlists*. aims to scrape this *playlist of playlists*.
Playlists are recognized as the album, and videos within it are tracks. Playlists are recognized as the album, and videos within it are tracks.
@ -26,8 +21,8 @@ Playlists are recognized as the album, and videos within it are tracks.
= Jazz: # Sets genre tag to "Jazz" = Jazz: # Sets genre tag to "Jazz"
"Thelonious Monk": "https://www.youtube.com/@officialtheloniousmonk/releases" "Thelonious Monk": "https://www.youtube.com/@officialtheloniousmonk/releases"
If you are only interested in a subset of albums, you can provide their playlists as separate values in the form If you are only interested in a subset of albums, you can provide their playlists as
of an array, like so: separate values in the form of an array, like so:
.. code-block:: yaml .. code-block:: yaml
@ -37,11 +32,13 @@ of an array, like so:
- "https://www.youtube.com/playlist?list=OLAK5uy_lcqINwfzkw73TPnAt6MlpB6V0gM9VzQu8" # Monk on Monk - "https://www.youtube.com/playlist?list=OLAK5uy_lcqINwfzkw73TPnAt6MlpB6V0gM9VzQu8" # Monk on Monk
- "https://www.youtube.com/playlist?list=OLAK5uy_nhuvjuZOO3yLIWCbQzbiWfyzkGapSIuYw" # Late Night Thelonious Monk - "https://www.youtube.com/playlist?list=OLAK5uy_nhuvjuZOO3yLIWCbQzbiWfyzkGapSIuYw" # Late Night Thelonious Monk
YouTube Full Albums YouTube Full Albums
------------------- -------------------
In many cases, albums are uploaded to YouTube as a single video, where each track as separated by either
chapters or timestamps in a description. The ``YouTube Full Albums`` preset will take each video and split In many cases, albums are uploaded to YouTube as a single video, where each track as
it by the chapters to form an album. separated by either chapters or timestamps in a description. The ``YouTube Full Albums``
preset will take each video and split it by the chapters to form an album.
Videos are recognized as the album, and chapters within it are tracks. Videos are recognized as the album, and chapters within it are tracks.
@ -51,8 +48,8 @@ Videos are recognized as the album, and chapters within it are tracks.
= Lofi: = Lofi:
"Game Chops": "https://www.youtube.com/playlist?list=PLBsm_SagFMmdWnCnrNtLjA9kzfrRkto4i" "Game Chops": "https://www.youtube.com/playlist?list=PLBsm_SagFMmdWnCnrNtLjA9kzfrRkto4i"
If you are only interested in a subset of albums, you can provide their video as separate values in the form If you are only interested in a subset of albums, you can provide their video as
of an array, like so: separate values in the form of an array, like so:
.. code-block:: yaml .. code-block:: yaml
@ -62,11 +59,14 @@ of an array, like so:
- "https://www.youtube.com/watch?v=m7vBrD7LMLI" # Zelda & Sleep Ensemble Collection - "https://www.youtube.com/watch?v=m7vBrD7LMLI" # Zelda & Sleep Ensemble Collection
- "https://www.youtube.com/watch?v=w0XebCwSpKI" # Study Buddy ~ video game lofi mix - "https://www.youtube.com/watch?v=w0XebCwSpKI" # Study Buddy ~ video game lofi mix
Soundcloud Discography Soundcloud Discography
---------------------- ----------------------
SoundCloud tracks can be uploaded as either a single, part of an album, or a collaboration
with another artist. At this time, ``SoundCloud Discography`` only scrapes singles and albums. SoundCloud tracks can be uploaded as either a single, part of an album, or a
It will attempt to group tracks into albums before falling back to single format. collaboration with another artist. At this time, ``SoundCloud Discography`` only scrapes
singles and albums. It will attempt to group tracks into albums before falling back to
single format.
.. code-block:: yaml .. code-block:: yaml
@ -77,8 +77,10 @@ It will attempt to group tracks into albums before falling back to single format
"Lazerdiscs Records": "https://soundcloud.com/lazerdiscsrecords" "Lazerdiscs Records": "https://soundcloud.com/lazerdiscsrecords"
"Earmake": "https://soundcloud.com/earmake" "Earmake": "https://soundcloud.com/earmake"
Bandcamp Bandcamp
-------- --------
Bandcamp albums and singles can be scraped using the ``Bandcamp`` preset. Bandcamp albums and singles can be scraped using the ``Bandcamp`` preset.
.. code-block:: yaml .. code-block:: yaml

View file

@ -2,4 +2,4 @@
Music Video Presets Music Video Presets
=================== ===================
WIP WIP

View file

@ -2,49 +2,63 @@
TV Show Presets TV Show Presets
=============== ===============
Player-Specific Presets
=======================
``ytdl-sub`` provides player-specific versions of certain presets, which apply settings to optimize the downloads for that player. Player-Specific Presets
-----------------------
``ytdl-sub`` provides player-specific versions of certain presets, which apply settings
to optimize the downloads for that player.
The following actions are taken based on the indicated player: The following actions are taken based on the indicated player:
Kodi
~~~~
* Everything that the Jellyfin version does
* Enables ``kodi_safe`` NFOs, replacing 4-byte unicode characters that break kodi with
````
Jellyfin Jellyfin
-------- ~~~~~~~~
* Places any season-specific poster art in the main show folder * Places any season-specific poster art in the main show folder
* Generates NFO tags * Generates NFO tags
Kodi Emby
-------- ~~~~
* Everything that the Jellyfin version does
* Enables ``kodi_safe`` NFOs, replacing 4-byte unicode characters that break kodi with ```` * Places any season-specific poster art in the main show folder
* Generates NFO tags
* For named seasons, creates a ``season.nfo`` file per season
Plex Plex
-------- ~~~~~~~~
* :ref:`Special sanitization <config_reference/scripting/entry_variables:title_sanitized_plex>` of numbers so Plex doesn't recognize numbers that are part of the title as the episode number
* :ref:`Special sanitization
<config_reference/scripting/entry_variables:title_sanitized_plex>` of numbers so Plex
doesn't recognize numbers that are part of the title as the episode number
* Converts all downloaded videos to the mp4 format * Converts all downloaded videos to the mp4 format
* Places any season-specific poster art into the season folder * Places any season-specific poster art into the season folder
---------------------------------------------- ----------------------------------------------
Generic Presets
===============
There are two main methods for downloading and formatting videos as a TV show.
TV Show by Date TV Show by Date
--------------- ---------------
TV Show by Date will organize something like a YouTube channel or playlist into a tv show, where seasons and episodes are organized using upload date. TV Show by Date will organize something like a YouTube channel or playlist into a tv
show, where seasons and episodes are organized using upload date.
Example Example
~~~~~~~ ~~~~~~~
Must define ``tv_show_directory``. Available presets: Must define ``tv_show_directory``. Available presets:
* ``"Kodi TV Show by Date"`` * ``Kodi TV Show by Date``
* ``"Jellyfin TV Show by Date"`` * ``Jellyfin TV Show by Date``
* ``"Plex TV Show by Date"`` * ``Emby TV Show by Date``
* ``Plex TV Show by Date``
.. code-block:: yaml .. code-block:: yaml
@ -74,47 +88,89 @@ Must define ``tv_show_directory``. Available presets:
Advanced Usage Advanced Usage
~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~
If you prefer a different organization method, you can instead apply multiple presets to your subscriptions. If you prefer a different season/episode organization method, you can set the following
override variables.
You will need a base of one of the below: .. code-block:: yaml
* ``kodi_tv_show_by_date`` __preset__:
* ``jellyfin_tv_show_by_date`` overrides:
* ``plex_tv_show_by_date`` tv_show_directory: "/tv_shows"
tv_show_by_date_season_ordering: "upload-year-month"
tv_show_by_date_episode_ordering: "upload-day"
And then add one of these: Or for a specific preset
* ``season_by_year__episode_by_month_day`` .. code-block:: yaml
* ``season_by_year_month__episode_by_day``
* ``season_by_year__episode_by_month_day_reversed``
* Episode numbers are reversed, meaning more recent episodes appear at the top of a season by having a lower value.
* ``season_by_year__episode_by_download_index``
* Episodes are numbered by the download order. NOTE that this is fetched using the length of the download archive. Do not use if you intend to remove old videos.
"~Kids Toys Play":
url: "https://www.youtube.com/@KidsToysPlayChannel"
tv_show_by_date_season_ordering: "upload-year-month"
tv_show_by_date_episode_ordering: "upload-day"
The following are supported. Be sure the combined season + episode ordering include the
year, month, day, i.e. upload-year + upload-month-day.
Season Ordering
"""""""""""""""
``tv_show_by_date_season_ordering`` supports one of the following:
* ``upload-year`` (default)
* ``upload-year-month``
* ``release-year``
* ``release-year-month``
Episode Ordering
""""""""""""""""
``tv_show_by_date_episode_ordering`` supports one of the following:
* ``upload-month-day`` (default)
* ``upload-month-day-reversed``
* Reversed means more recent episodes appear at the top of a season by having a lower
value.
* ``upload-day``
* ``release-day``
* ``release-month-day``
* ``release-month-day-reversed``
* ``download-index``
* Episodes are numbered by the download order. **NOTE**: this is fetched using the
length of the download archive. Do not use if you intend to remove old videos.
TV Show by Date presets use the following for defaults:
.. code-block:: yaml
tv_show_by_date_season_ordering: "upload-year"
tv_show_by_date_episode_ordering: "upload-month-day"
TV Show Collection TV Show Collection
------------------ ------------------
TV Show Collections set each URL as its own season. If a video belongs to multiple URLs TV Show Collections set each URL as its own season. If a video belongs to multiple URLs
(i.e. a channel and a channel's playlist), the video will only download once and reside in (i.e. a channel and a channel's playlist), the video will only download once and reside
the higher-numbered season. in the higher-numbered season.
Two main use cases of a collection are: Two main use cases of a collection are:
1. Organize a YouTube channel TV show where Season 1 contains any video 1. Organize a YouTube channel TV show where Season 1 contains any video not in a
not in a 'season playlist', Season 2 for 'Playlist A', Season 3 for 'season playlist', Season 2 for 'Playlist A', Season 3 for 'Playlist B', etc.
'Playlist B', etc. 2. Organize one or more YouTube channels/playlists, where each season represents a
2. Organize one or more YouTube channels/playlists, where each season separate channel/playlist.
represents a separate channel/playlist.
Today, ytdl-supports up to 40 seasons with 11 URLs per season.
Example Example
~~~~~~~ ~~~~~~~
Must define ``tv_show_directory``. Available presets: Must define ``tv_show_directory``. Available presets:
* ``"Kodi TV Show Collection"`` * ``Kodi TV Show Collection``
* ``"Jellyfin TV Show Collection"`` * ``Jellyfin TV Show Collection``
* ``"Plex TV Show Collection"`` * ``Emby TV Show Collection``
* ``Plex TV Show Collection``
.. code-block:: yaml .. code-block:: yaml
@ -131,22 +187,69 @@ Must define ``tv_show_directory``. Available presets:
s02_name: "Covers" s02_name: "Covers"
s02_url: "https://www.youtube.com/playlist?list=PLE62gWlWZk5NWVAVuf0Lm9jdv_-_KXs0W" s02_url: "https://www.youtube.com/playlist?list=PLE62gWlWZk5NWVAVuf0Lm9jdv_-_KXs0W"
Other notable features include:
* TV show poster info is pulled from the first URL in s01.
* Duplicate videos in different URLs (channel /videos vs playlist) will not download twice.
* The video will attributed to the season with the highest number.
* Individual seasons support both single and multi URL.
* s00 is supported for specials.
.. code-block:: yaml
"~Beyond the Guitar":
s00_name: "Specials"
s00_url:
- "https://www.youtube.com/watch?v=vXzguOdulAI"
- "https://www.youtube.com/watch?v=IGwYDvaGAz0"
s01_name: "Videos"
s01_url:
- "https://www.youtube.com/c/BeyondTheGuitar"
- "https://www.youtube.com/@BeyondTheGuitarAcademy"
s02_name: "Covers"
s02_url: "https://www.youtube.com/playlist?list=PLE62gWlWZk5NWVAVuf0Lm9jdv_-_KXs0W"
Advanced Usage Advanced Usage
~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~
If you prefer a different organization method, you can instead apply multiple presets to your subscriptions. If you prefer a different episode organization method, you can set the following
override variables.
You will need a base of one of the below: .. code-block:: yaml
* ``kodi_tv_show_collection`` __preset__:
* ``jellyfin_tv_show_collection`` overrides:
* ``plex_tv_show_collection`` tv_show_directory: "/tv_shows"
tv_show_collection_episode_ordering: "release-year-month-day"
And then add one of these: Or for a specific preset
* ``season_by_collection__episode_by_year_month_day`` .. code-block:: yaml
* ``season_by_collection__episode_by_year_month_day_reversed``
* ``season_by_collection__episode_by_playlist_index`` "~Beyond the Guitar":
tv_show_collection_episode_ordering: "release-year-month-day"
* Only use playlist_index episode formatting for playlists that will be fully downloaded once and never again. Otherwise, indices can change. s01_name: "Videos"
* ``season_by_collection__episode_by_playlist_index_reversed`` s01_url: "https://www.youtube.com/c/BeyondTheGuitar"
s02_name: "Covers"
s02_url: "https://www.youtube.com/playlist?list=PLE62gWlWZk5NWVAVuf0Lm9jdv_-_KXs0W"
The following are supported.
Episode Ordering
""""""""""""""""
``tv_show_collection_episode_ordering`` supports one of the following:
* ``upload-year-month-day`` (default)
* ``upload-year-month-day-reversed``
* ``release-year-month-day``
* ``release-year-month-day-reversed``
* ``playlist-index``
* Only use ``playlist-index`` episode formatting for playlists that will be fully
downloaded once and never again. Otherwise, indices can change.
* ``playlist-index-reversed``
TV Show Collection presets use upload-year-month-day as the default.

View file

@ -1,5 +1,5 @@
Usage Usage
======= =====
.. code-block:: .. code-block::
@ -7,10 +7,12 @@ Usage
For Windows users, it would be ``ytdl-sub.exe`` For Windows users, it would be ``ytdl-sub.exe``
General Options General Options
--------------- ---------------
General options must be specified before the command (i.e. ``sub``). CLI options common to all sub-commands. Must be specified before the sub-command, for
example ``$ ytdl-sub --dry-run sub ...``:
.. code-block:: text .. code-block:: text
@ -20,24 +22,30 @@ General options must be specified before the command (i.e. ``sub``).
path to the config yaml, uses config.yaml if not provided path to the config yaml, uses config.yaml if not provided
-d, --dry-run preview what a download would output, does not perform any video downloads or writes to output directories -d, --dry-run preview what a download would output, does not perform any video downloads or writes to output directories
-l quiet|info|verbose|debug, --log-level quiet|info|verbose|debug -l quiet|info|verbose|debug, --log-level quiet|info|verbose|debug
level of logs to print to console, defaults to info level of logs to print to console, defaults to verbose
-t TRANSACTIONPATH, --transaction-log TRANSACTIONPATH -t TRANSACTIONPATH, --transaction-log TRANSACTIONPATH
path to store the transaction log output of all files added, modified, deleted path to store the transaction log output of all files added, modified, deleted
-st, --suppress-transaction-log -st, --suppress-transaction-log
do not output transaction logs to console or file do not output transaction logs to console or file
-nc, --suppress-colors
do not use colors in ytdl-sub output
-m MATCH [MATCH ...], --match MATCH [MATCH ...] -m MATCH [MATCH ...], --match MATCH [MATCH ...]
match subscription names to one or more substrings, and only run those subscriptions match subscription names to one or more substrings, and only run those subscriptions
Sub Options
----------- Subscriptions Options
Download all subscriptions specified in each ``SUBPATH``. ---------------------
Download all subscriptions specified in each :doc:`subscriptions file
<./guides/getting_started/subscriptions>`.
.. code-block:: .. code-block::
ytdl-sub [GENERAL OPTIONS] sub [SUBPATH ...] ytdl-sub [GENERAL OPTIONS] sub [SUBPATH ...]
``SUBPATH`` is one or more paths to subscription files, uses ``subscriptions.yaml`` if not provided. ``SUBPATH`` is one or more paths to subscription files and defaults to
It will use the config specified by ``--config``, or ``config.yaml`` if not provided. ``./subscriptions.yaml`` if none are given. It will use the config specified by
``--config``, or ``./config.yaml``, if not provided.
.. code-block:: text .. code-block:: text
:caption: Additional Options :caption: Additional Options
@ -47,16 +55,19 @@ It will use the config specified by ``--config``, or ``config.yaml`` if not prov
-o DL_OVERRIDE, --dl-override DL_OVERRIDE -o DL_OVERRIDE, --dl-override DL_OVERRIDE
override all subscription config values using `dl` syntax, i.e. --dl-override='--ytdl_options.max_downloads 3' override all subscription config values using `dl` syntax, i.e. --dl-override='--ytdl_options.max_downloads 3'
Download Options Download Options
----------------- ----------------
Download a single subscription in the form of CLI arguments.
Download a single subscription in the form of CLI arguments instead of from :doc:`a
subscriptions file <./guides/getting_started/subscriptions>`:
.. code-block:: .. code-block::
ytdl-sub [GENERAL OPTIONS] dl [SUBSCRIPTION ARGUMENTS] ytdl-sub [GENERAL OPTIONS] dl [SUBSCRIPTION ARGUMENTS]
``SUBSCRIPTION ARGUMENTS`` are exactly the same as YAML arguments, but use periods (``.``) instead ``SUBSCRIPTION ARGUMENTS`` are the same as YAML arguments, but use periods (``.``)
of indents for specifying YAML from the CLI. For example, you can represent this subscription: instead of indents. For example, you can represent this subscription:
.. code-block:: yaml .. code-block:: yaml
@ -76,11 +87,15 @@ Using the command:
--overrides.tv_show_name "Rick A" \ --overrides.tv_show_name "Rick A" \
--overrides.url: "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw" --overrides.url: "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw"
See how to shorten commands using See how to shorten commands using `download aliases
`download aliases <https://ytdl-sub.readthedocs.io/en/latest/config_reference/config_yaml.html#ytdl_sub.config.config_validator.ConfigOptions.dl_aliases>`_. <https://ytdl-sub.readthedocs.io/en/latest/config_reference/config_yaml.html#ytdl_sub.config.config_validator.ConfigOptions.dl_aliases>`_.
View Options View Options
----------------- ------------
Preview the source variables for a given URL. Helpful to create new subscriptions:
.. code-block:: .. code-block::
ytdl-sub view [-sc] [URL] ytdl-sub view [-sc] [URL]
@ -91,5 +106,10 @@ View Options
-sc, --split-chapters -sc, --split-chapters
View source variables after splitting by chapters View source variables after splitting by chapters
CLI to SUB Options
------------------
Convert yt-dlp cli arguments to ytdl-sub `ytdl_options` arguments.
Preview the source variables for a given URL. Helps when creating new configs. .. code-block::
ytdl-sub cli-to-sub [YT-DLP ARGS]

View file

@ -1,9 +1,9 @@
############################################################################### ###############################################################################
# Top-level configurations to apply umask and persist error logs # Top-level configurations to apply umask and write log files
configuration: configuration:
umask: "002" umask: "002"
persist_logs: persist_logs:
logs_directory: './logs' logs_directory: '/config/logs'
keep_successful_logs: False keep_successful_logs: False
presets: presets:
@ -98,4 +98,4 @@ presets:
overrides: overrides:
only_recent_date_range: "2months" only_recent_date_range: "2months"
only_recent_max_files: 30 only_recent_max_files: 30

View file

@ -24,6 +24,6 @@ TV Show Only Recent:
# to set only for that subscriptions # to set only for that subscriptions
"~BBC News": "~BBC News":
url: "https://www.youtube.com/@BBCNews" # use url2, url3, ... for multi-url in this form url: "https://www.youtube.com/@BBCNews" # use url2, url3, ... for multi-url in this form
date_range: "2weeks" only_recent_date_range: "2weeks"
"Frontline PBS": "https://www.youtube.com/@frontline" "Frontline PBS": "https://www.youtube.com/@frontline"
"Whitehouse": "https://www.bitchute.com/channel/zWsYVmCOu4JA/" # Supports non-YT sites "Whitehouse": "https://www.bitchute.com/channel/zWsYVmCOu4JA/" # Supports non-YT sites

View file

@ -31,6 +31,7 @@ __preset__:
# Choose the player you intend to use by setting the top-level key to be either: # Choose the player you intend to use by setting the top-level key to be either:
# - Plex TV Show by Date: # - Plex TV Show by Date:
# - Jellyfin TV Show by Date: # - Jellyfin TV Show by Date:
# - Emby TV Show by Date:
# - Kodi TV Show by Date: # - Kodi TV Show by Date:
Plex TV Show by Date: Plex TV Show by Date:
@ -64,6 +65,7 @@ Plex TV Show by Date:
# Choose the player you intend to use by setting the top-level key to be either: # Choose the player you intend to use by setting the top-level key to be either:
# - Plex TV Show Collection: # - Plex TV Show Collection:
# - Jellyfin TV Show Collection: # - Jellyfin TV Show Collection:
# - Emby TV Show Collection:
# - Kodi TV Show Collection: # - Kodi TV Show Collection:
Plex TV Show Collection: Plex TV Show Collection:
= Music: = Music:

View file

@ -15,7 +15,7 @@ classifiers = [
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
] ]
dependencies = [ dependencies = [
"yt-dlp[default]==2025.1.26", "yt-dlp[default]==2026.1.29",
"colorama~=0.4", "colorama~=0.4",
"mergedeep~=1.3", "mergedeep~=1.3",
"mediafile~=0.12", "mediafile~=0.12",
@ -44,15 +44,15 @@ where = ["src"]
test = [ test = [
"coverage[toml]>=6.3,<8.0", "coverage[toml]>=6.3,<8.0",
"pytest>=7.2,<9.0", "pytest>=7.2,<9.0",
"pytest-rerunfailures>=14,<16", "pytest-rerunfailures>=14,<17",
] ]
lint = [ lint = [
"black==24.10.0", "black==24.10.0",
"isort==5.13.2", "isort==7.0.0",
"pylint==3.3.4", "pylint==4.0.1",
] ]
docs = [ docs = [
"sphinx>=7,<9", "sphinx>=7,<10",
"sphinx-rtd-theme>=2,<4", "sphinx-rtd-theme>=2,<4",
"sphinx-book-theme~=1.0", "sphinx-book-theme~=1.0",
"sphinx-copybutton~=0.5", "sphinx-copybutton~=0.5",

View file

@ -12,6 +12,7 @@ from yt_dlp.utils import sanitize_filename
from ytdl_sub.cli.output_summary import output_summary from ytdl_sub.cli.output_summary import output_summary
from ytdl_sub.cli.output_transaction_log import _maybe_validate_transaction_log_file from ytdl_sub.cli.output_transaction_log import _maybe_validate_transaction_log_file
from ytdl_sub.cli.output_transaction_log import output_transaction_log from ytdl_sub.cli.output_transaction_log import output_transaction_log
from ytdl_sub.cli.parsers.cli_to_sub import print_cli_to_sub
from ytdl_sub.cli.parsers.dl import DownloadArgsParser from ytdl_sub.cli.parsers.dl import DownloadArgsParser
from ytdl_sub.cli.parsers.main import DEFAULT_CONFIG_FILE_NAME from ytdl_sub.cli.parsers.main import DEFAULT_CONFIG_FILE_NAME
from ytdl_sub.cli.parsers.main import parser from ytdl_sub.cli.parsers.main import parser
@ -206,6 +207,10 @@ def main() -> List[Subscription]:
args, extra_args = parser.parse_known_args() args, extra_args = parser.parse_known_args()
if args.subparser == "cli-to-sub":
print_cli_to_sub(args=extra_args)
return []
# Load the config # Load the config
if args.config: if args.config:
config = ConfigFile.from_file_path(args.config) config = ConfigFile.from_file_path(args.config)
@ -263,7 +268,7 @@ def main() -> List[Subscription]:
_view_url_from_cli(config=config, url=args.url, split_chapters=args.split_chapters) _view_url_from_cli(config=config, url=args.url, split_chapters=args.split_chapters)
) )
else: else:
raise ValidationException("Must provide one of the commands: sub, dl, view") raise ValidationException("Must provide one of the commands: sub, dl, view, cli-to-sub")
if not args.suppress_transaction_log: if not args.suppress_transaction_log:
output_transaction_log( output_transaction_log(
@ -271,6 +276,6 @@ def main() -> List[Subscription]:
transaction_log_file_path=args.transaction_log, transaction_log_file_path=args.transaction_log,
) )
output_summary(subscriptions) output_summary(subscriptions, suppress_colors=args.suppress_colors)
return subscriptions return subscriptions

View file

@ -8,16 +8,16 @@ from ytdl_sub.utils.logger import Logger
logger = Logger.get() logger = Logger.get()
def _green(value: str) -> str: def _green(value: str, suppress_colors: bool = False) -> str:
return Fore.GREEN + value + Fore.RESET return value if suppress_colors else Fore.GREEN + value + Fore.RESET
def _red(value: str) -> str: def _red(value: str, suppress_colors: bool = False) -> str:
return Fore.RED + value + Fore.RESET return value if suppress_colors else Fore.RED + value + Fore.RESET
def _no_color(value: str) -> str: def _no_color(value: str, suppress_colors: bool = False) -> str:
return Fore.RESET + value + Fore.RESET return value if suppress_colors else Fore.RESET + value + Fore.RESET
def _str_int(value: int) -> str: def _str_int(value: int) -> str:
@ -26,21 +26,23 @@ def _str_int(value: int) -> str:
return str(value) return str(value)
def _color_int(value: int) -> str: def _color_int(value: int, suppress_colors: bool = False) -> str:
str_int = _str_int(value) str_int = _str_int(value)
if value > 0: if value > 0:
return _green(str_int) return _green(str_int, suppress_colors)
if value < 0: if value < 0:
return _red(str_int) return _red(str_int, suppress_colors)
return _no_color(str_int) return _no_color(str_int, suppress_colors)
def output_summary(subscriptions: List[Subscription]) -> None: def output_summary(subscriptions: List[Subscription], suppress_colors: bool) -> None:
""" """
Parameters Parameters
---------- ----------
subscriptions subscriptions
Processed subscriptions Processed subscriptions
suppress_colors
Whether to have color or not
Returns Returns
------- -------
@ -65,21 +67,21 @@ def output_summary(subscriptions: List[Subscription]) -> None:
# Initialize widths to 0 # Initialize widths to 0
width_sub_name: int = max(len(sub.name) for sub in subscriptions) + 4 # aesthetics width_sub_name: int = max(len(sub.name) for sub in subscriptions) + 4 # aesthetics
width_num_entries_added: int = len(_color_int(total_added)) width_num_entries_added: int = len(_color_int(total_added, suppress_colors))
width_num_entries_modified: int = len(_color_int(total_modified)) width_num_entries_modified: int = len(_color_int(total_modified, suppress_colors))
width_num_entries_removed: int = len(_color_int(total_removed)) width_num_entries_removed: int = len(_color_int(total_removed, suppress_colors))
width_num_entries: int = len(str(total_entries)) + 4 # aesthetics width_num_entries: int = len(str(total_entries)) + 4 # aesthetics
# Build the summary # Build the summary
for subscription in subscriptions: for subscription in subscriptions:
num_entries_added = _color_int(subscription.num_entries_added) num_entries_added = _color_int(subscription.num_entries_added, suppress_colors)
num_entries_modified = _color_int(subscription.num_entries_modified) num_entries_modified = _color_int(subscription.num_entries_modified, suppress_colors)
num_entries_removed = _color_int(subscription.num_entries_removed * -1) num_entries_removed = _color_int(subscription.num_entries_removed * -1, suppress_colors)
num_entries = str(subscription.num_entries) num_entries = str(subscription.num_entries)
status = ( status = (
_red(subscription.exception.__class__.__name__) _red(subscription.exception.__class__.__name__, suppress_colors)
if subscription.exception if subscription.exception
else _green("") else _green("", suppress_colors)
) )
summary.append( summary.append(
@ -92,14 +94,16 @@ def output_summary(subscriptions: List[Subscription]) -> None:
) )
total_errors_str = ( total_errors_str = (
_green("Success") if total_errors == 0 else _red(f"Error{'s' if total_errors > 1 else ''}") _green("Success", suppress_colors)
if total_errors == 0
else _red(f"Error{'s' if total_errors > 1 else ''}", suppress_colors)
) )
summary.append( summary.append(
f"{total_subs_str:<{width_sub_name}} " f"{total_subs_str:<{width_sub_name}} "
f"{_color_int(total_added):>{width_num_entries_added}} " f"{_color_int(total_added, suppress_colors):>{width_num_entries_added}} "
f"{_color_int(total_modified):>{width_num_entries_modified}} " f"{_color_int(total_modified, suppress_colors):>{width_num_entries_modified}} "
f"{_color_int(total_removed):>{width_num_entries_removed}} " f"{_color_int(total_removed * -1, suppress_colors):>{width_num_entries_removed}} "
f"{total_entries:>{width_num_entries}} " f"{total_entries:>{width_num_entries}} "
f"{total_errors_str}" f"{total_errors_str}"
) )

View file

@ -0,0 +1,64 @@
from typing import List
import yt_dlp
import yt_dlp.options
from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.yaml import dump_yaml
logger = Logger.get()
# pylint: disable=missing-function-docstring
##############################################################
# --- BEGIN ----
# Copy of https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py
create_parser = yt_dlp.options.create_parser
def parse_patched_options(opts):
patched_parser = create_parser()
patched_parser.defaults.update(
{
"ignoreerrors": False,
"retries": 0,
"fragment_retries": 0,
"extract_flat": False,
"concat_playlist": "never",
"update_self": False,
}
)
yt_dlp.options.create_parser = lambda: patched_parser
try:
return yt_dlp.parse_options(opts)
finally:
yt_dlp.options.create_parser = create_parser
default_opts = parse_patched_options([]).ydl_opts
def cli_to_api(opts, cli_defaults=False):
opts = (yt_dlp.parse_options if cli_defaults else parse_patched_options)(opts).ydl_opts
diff = {k: v for k, v in opts.items() if default_opts[k] != v}
if "postprocessors" in diff:
diff["postprocessors"] = [
pp for pp in diff["postprocessors"] if pp not in default_opts["postprocessors"]
]
return diff
# --- END ----
##############################################################
def print_cli_to_sub(args: List[str]) -> None:
api_args = cli_to_api(args)
if not api_args:
logger.info("Does not resolve to any yt-dlp args")
return
print(dump_yaml({"ytdl_options": api_args}))

View file

@ -44,6 +44,7 @@ class MainArguments:
short="-m", short="-m",
long="--match", long="--match",
) )
SUPPRESS_COLORS = CLIArgument(short="-nc", long="--suppress-colors")
@classmethod @classmethod
def all(cls) -> List[CLIArgument]: def all(cls) -> List[CLIArgument]:
@ -59,6 +60,7 @@ class MainArguments:
cls.TRANSACTION_LOG, cls.TRANSACTION_LOG,
cls.SUPPRESS_TRANSACTION_LOG, cls.SUPPRESS_TRANSACTION_LOG,
cls.MATCH, cls.MATCH,
cls.SUPPRESS_COLORS,
] ]
@classmethod @classmethod
@ -109,8 +111,8 @@ def _add_shared_arguments(arg_parser: argparse.ArgumentParser, suppress_defaults
MainArguments.LOG_LEVEL.long, MainArguments.LOG_LEVEL.long,
metavar="|".join(LoggerLevels.names()), metavar="|".join(LoggerLevels.names()),
type=str, type=str,
help="level of logs to print to console, defaults to info", help="level of logs to print to console, defaults to verbose",
default=argparse.SUPPRESS if suppress_defaults else LoggerLevels.INFO.name, default=argparse.SUPPRESS if suppress_defaults else LoggerLevels.VERBOSE.name,
choices=LoggerLevels.names(), choices=LoggerLevels.names(),
dest="ytdl_sub_log_level", dest="ytdl_sub_log_level",
) )
@ -129,6 +131,13 @@ def _add_shared_arguments(arg_parser: argparse.ArgumentParser, suppress_defaults
help="do not output transaction logs to console or file", help="do not output transaction logs to console or file",
default=argparse.SUPPRESS if suppress_defaults else False, default=argparse.SUPPRESS if suppress_defaults else False,
) )
arg_parser.add_argument(
MainArguments.SUPPRESS_COLORS.short,
MainArguments.SUPPRESS_COLORS.long,
action="store_true",
help="do not use colors in ytdl-sub output",
default=argparse.SUPPRESS if suppress_defaults else False,
)
arg_parser.add_argument( arg_parser.add_argument(
MainArguments.MATCH.short, MainArguments.MATCH.short,
MainArguments.MATCH.long, MainArguments.MATCH.long,
@ -212,3 +221,7 @@ view_parser.add_argument(
help="View source variables after splitting by chapters", help="View source variables after splitting by chapters",
) )
view_parser.add_argument("url", help="URL to view source variables for") view_parser.add_argument("url", help="URL to view source variables for")
###################################################################################################
# CLI-TO-SUB PARSER
cli_to_sub_parser = subparsers.add_parser("cli-to-sub")

View file

@ -12,6 +12,8 @@ from ytdl_sub.config.defaults import DEFAULT_FFPROBE_PATH
from ytdl_sub.config.defaults import DEFAULT_LOCK_DIRECTORY from ytdl_sub.config.defaults import DEFAULT_LOCK_DIRECTORY
from ytdl_sub.config.defaults import MAX_FILE_NAME_BYTES from ytdl_sub.config.defaults import MAX_FILE_NAME_BYTES
from ytdl_sub.prebuilt_presets import PREBUILT_PRESETS from ytdl_sub.prebuilt_presets import PREBUILT_PRESETS
from ytdl_sub.utils.exceptions import SubscriptionPermissionError
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.validators.file_path_validators import FFmpegFileValidator from ytdl_sub.validators.file_path_validators import FFmpegFileValidator
from ytdl_sub.validators.file_path_validators import FFprobeFileValidator from ytdl_sub.validators.file_path_validators import FFprobeFileValidator
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
@ -75,7 +77,8 @@ class PersistLogsValidator(StrictDictValidator):
@property @property
def logs_directory(self) -> str: def logs_directory(self) -> str:
""" """
Required. The directory to store the logs in. Write log files to this directory with names like
``YYYY-mm-dd-HHMMSS.subscription_name.(success|error).log``. (required)
""" """
return self._logs_directory.value return self._logs_directory.value
@ -100,7 +103,10 @@ class PersistLogsValidator(StrictDictValidator):
@property @property
def keep_successful_logs(self) -> bool: def keep_successful_logs(self) -> bool:
""" """
Optional. Whether to store logs when downloading is successful. Defaults to True. If the ``persist_logs:`` key is in the configuration, then ``ytdl-sub`` *always*
writes log files for the subscription both for successful downloads and when it
encounters an error while downloading. When this key is ``False``, only write
log files for errors. (default ``True``)
""" """
return self._keep_successful_logs.value return self._keep_successful_logs.value
@ -155,11 +161,17 @@ class ConfigOptions(StrictDictValidator):
key="file_name_max_bytes", validator=IntValidator, default=MAX_FILE_NAME_BYTES key="file_name_max_bytes", validator=IntValidator, default=MAX_FILE_NAME_BYTES
) )
if not FileHandler.is_path_writable(self.working_directory):
raise SubscriptionPermissionError(
"ytdl-sub does not have permissions to the working directory: "
f"{self.working_directory}"
)
@property @property
def working_directory(self) -> str: def working_directory(self) -> str:
""" """
The directory to temporarily store downloaded files before moving them into their final The directory to temporarily store downloaded files before moving them into their final
directory. Defaults to .ytdl-sub-working-directory directory. (default ``./.ytdl-sub-working-directory``)
""" """
# Expands tildas to actual paths, use native os sep # Expands tildas to actual paths, use native os sep
return os.path.expanduser(self._working_directory.value.replace(posixpath.sep, os.sep)) return os.path.expanduser(self._working_directory.value.replace(posixpath.sep, os.sep))
@ -167,7 +179,7 @@ class ConfigOptions(StrictDictValidator):
@property @property
def umask(self) -> Optional[str]: def umask(self) -> Optional[str]:
""" """
Umask (octal format) to apply to every created file. Defaults to "022". Umask in octal format to apply to every created file. (default ``022``)
""" """
return self._umask.value return self._umask.value
@ -226,24 +238,25 @@ class ConfigOptions(StrictDictValidator):
def lock_directory(self) -> str: def lock_directory(self) -> str:
""" """
The directory to temporarily store file locks, which prevents multiple instances The directory to temporarily store file locks, which prevents multiple instances
of ``ytdl-sub`` from running. Note that file locks do not work on network-mounted of ``ytdl-sub`` from running. Note that file locks do not work on
directories. Ensure that this directory resides on the host machine. Defaults to ``/tmp``. network-mounted directories. Ensure that this directory resides on the host
machine. (default ``/tmp``)
""" """
return self._lock_directory.value return self._lock_directory.value
@property @property
def ffmpeg_path(self) -> str: def ffmpeg_path(self) -> str:
""" """
Path to ffmpeg executable. Defaults to ``/usr/bin/ffmpeg`` for Linux, and Path to ffmpeg executable. (default ``/usr/bin/ffmpeg`` for Linux,
``ffmpeg.exe`` for Windows (in the same directory as ytdl-sub). ``./ffmpeg.exe`` in the same directory as ytdl-sub for Windows)
""" """
return self._ffmpeg_path.value return self._ffmpeg_path.value
@property @property
def ffprobe_path(self) -> str: def ffprobe_path(self) -> str:
""" """
Path to ffprobe executable. Defaults to ``/usr/bin/ffprobe`` for Linux, and Path to ffprobe executable. (default ``/usr/bin/ffprobe`` for Linux,
``ffprobe.exe`` for Windows (in the same directory as ytdl-sub). ``./ffprobe.exe`` in the same directory as ytdl-sub for Windows)
""" """
return self._ffprobe_path.value return self._ffprobe_path.value

View file

@ -2,6 +2,8 @@ import os
from ytdl_sub.utils.system import IS_WINDOWS from ytdl_sub.utils.system import IS_WINDOWS
# pylint: disable=invalid-name
def _existing_path(*paths: str) -> str: def _existing_path(*paths: str) -> str:
""" """
@ -22,7 +24,7 @@ if IS_WINDOWS:
MAX_FILE_NAME_BYTES = 255 MAX_FILE_NAME_BYTES = 255
else: else:
DEFAULT_LOCK_DIRECTORY = "/tmp" DEFAULT_LOCK_DIRECTORY = ".ytdl-sub-lock"
DEFAULT_FFMPEG_PATH = os.getenv( DEFAULT_FFMPEG_PATH = os.getenv(
"YTDL_SUB_FFMPEG_PATH", _existing_path("/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg") "YTDL_SUB_FFMPEG_PATH", _existing_path("/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg")
) )

View file

@ -1,9 +1,10 @@
from typing import Any from typing import Any
from typing import Dict from typing import Dict
from typing import Iterable
from typing import Optional from typing import Optional
from typing import Set from typing import Set
from typing import Type
import mergedeep from typing import TypeVar
from ytdl_sub.entries.entry import Entry from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.script.variable_definitions import VARIABLES from ytdl_sub.entries.script.variable_definitions import VARIABLES
@ -11,17 +12,21 @@ from ytdl_sub.entries.variables.override_variables import REQUIRED_OVERRIDE_VARI
from ytdl_sub.entries.variables.override_variables import OverrideHelpers from ytdl_sub.entries.variables.override_variables import OverrideHelpers
from ytdl_sub.script.parser import parse from ytdl_sub.script.parser import parse
from ytdl_sub.script.script import Script from ytdl_sub.script.script import Script
from ytdl_sub.script.types.function import BuiltInFunction
from ytdl_sub.script.types.resolvable import Resolvable from ytdl_sub.script.types.resolvable import Resolvable
from ytdl_sub.script.types.resolvable import String
from ytdl_sub.script.types.syntax_tree import SyntaxTree
from ytdl_sub.script.utils.exceptions import ScriptVariableNotResolved from ytdl_sub.script.utils.exceptions import ScriptVariableNotResolved
from ytdl_sub.utils.exceptions import InvalidVariableNameException from ytdl_sub.utils.exceptions import InvalidVariableNameException
from ytdl_sub.utils.exceptions import StringFormattingException from ytdl_sub.utils.exceptions import StringFormattingException
from ytdl_sub.utils.exceptions import ValidationException from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.script import ScriptUtils from ytdl_sub.utils.script import ScriptUtils
from ytdl_sub.utils.scriptable import Scriptable from ytdl_sub.utils.scriptable import Scriptable
from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
from ytdl_sub.validators.string_formatter_validators import UnstructuredDictFormatterValidator from ytdl_sub.validators.string_formatter_validators import UnstructuredDictFormatterValidator
ExpectedT = TypeVar("ExpectedT")
class Overrides(UnstructuredDictFormatterValidator, Scriptable): class Overrides(UnstructuredDictFormatterValidator, Scriptable):
""" """
@ -88,6 +93,24 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
return True return True
def ensure_variable_names_not_a_plugin(self, plugin_names: Iterable[str]) -> None:
"""
Throws an error if an override variable or function has the same name as a
preset key. This is to avoid confusion when accidentally defining things in
overrides that are meant to be in the preset.
"""
for name in self.keys:
if name.startswith("%"):
name = name[1:]
if name in plugin_names:
raise self._validation_exception(
f"Override variable with name {name} cannot be used since it is"
" the name of a plugin. Perhaps you meant to define it as a plugin? If so,"
" indent it left to make it at the same level as overrides.",
exception_class=InvalidVariableNameException,
)
def ensure_variable_name_valid(self, name: str) -> None: def ensure_variable_name_valid(self, name: str) -> None:
""" """
Ensures the variable name does not collide with any entry variables or built-in functions. Ensures the variable name does not collide with any entry variables or built-in functions.
@ -115,29 +138,35 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
) )
def initial_variables( def initial_variables(
self, unresolved_variables: Optional[Dict[str, str]] = None self, unresolved_variables: Optional[Dict[str, SyntaxTree]] = None
) -> Dict[str, str]: ) -> Dict[str, SyntaxTree]:
""" """
Returns Returns
------- -------
Variables and format strings for all Override variables + additional variables (Optional) Variables and format strings for all Override variables + additional variables (Optional)
""" """
initial_variables: Dict[str, str] = {} initial_variables: Dict[str, SyntaxTree] = self.dict_with_parsed_format_strings
mergedeep.merge( if unresolved_variables:
initial_variables, initial_variables |= unresolved_variables
self.dict_with_format_strings, return ScriptUtils.add_sanitized_parsed_variables(initial_variables)
unresolved_variables if unresolved_variables else {},
)
return ScriptUtils.add_sanitized_variables(initial_variables)
def initialize_script(self, unresolved_variables: Set[str]) -> "Overrides": def initialize_script(self, unresolved_variables: Set[str]) -> "Overrides":
""" """
Initialize the override script with any unresolved variables Initialize the override script with any unresolved variables
""" """
self.script.add( self.script.add_parsed(
self.initial_variables( self.initial_variables(
unresolved_variables={ unresolved_variables={
var_name: f"{{%throw('Plugin variable {var_name} has not been created yet')}}" var_name: SyntaxTree(
ast=[
BuiltInFunction(
name="throw",
args=[
String(f"Plugin variable {var_name} has not been created yet")
],
)
]
)
for var_name in unresolved_variables for var_name in unresolved_variables
} }
) )
@ -158,10 +187,15 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
script = entry.script script = entry.script
unresolvable = entry.unresolvable unresolvable = entry.unresolvable
# Update the script internally so long as we are not supplying overrides
# that could alter the script with one-off state
update = function_overrides is None
try: try:
return script.resolve_once( return script.resolve_once(
dict({"tmp_var": formatter.format_string}, **(function_overrides or {})), dict({"tmp_var": formatter.format_string}, **(function_overrides or {})),
unresolvable=unresolvable, unresolvable=unresolvable,
update=update,
)["tmp_var"] )["tmp_var"]
except ScriptVariableNotResolved as exc: except ScriptVariableNotResolved as exc:
raise StringFormattingException( raise StringFormattingException(
@ -176,7 +210,8 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
formatter: StringFormatterValidator, formatter: StringFormatterValidator,
entry: Optional[Entry] = None, entry: Optional[Entry] = None,
function_overrides: Optional[Dict[str, str]] = None, function_overrides: Optional[Dict[str, str]] = None,
) -> str: expected_type: Type[ExpectedT] = str,
) -> ExpectedT:
""" """
Parameters Parameters
---------- ----------
@ -186,6 +221,8 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
Optional. Entry to add source variables to the formatter Optional. Entry to add source variables to the formatter
function_overrides function_overrides
Optional. Explicit values to override the overrides themselves and source variables Optional. Explicit values to override the overrides themselves and source variables
expected_type
The expected type that should return. Defaults to string.
Returns Returns
------- -------
@ -196,28 +233,15 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
StringFormattingException StringFormattingException
If the formatter that is trying to be resolved cannot If the formatter that is trying to be resolved cannot
""" """
return formatter.post_process( out = formatter.post_process(
str( self._apply_to_resolvable(
self._apply_to_resolvable( formatter=formatter, entry=entry, function_overrides=function_overrides
formatter=formatter, entry=entry, function_overrides=function_overrides ).native
)
)
) )
def apply_overrides_formatter_to_native( if not isinstance(out, expected_type):
self, raise StringFormattingException(
formatter: OverridesStringFormatterValidator, f"Expected type {expected_type.__name__}, but received '{out.__class__.__name__}'"
) -> Any: )
"""
Parameters
----------
formatter
Overrides formatter to apply
Returns return out
-------
The native python form of the resolved variable
"""
return self._apply_to_resolvable(
formatter=formatter, entry=None, function_overrides=None
).native

View file

@ -13,7 +13,6 @@ from ytdl_sub.config.validators.options import OptionsValidatorT
from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator
from ytdl_sub.entries.entry import Entry from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.script import ScriptUtils
from ytdl_sub.ytdl_additions.enhanced_download_archive import DownloadArchiver from ytdl_sub.ytdl_additions.enhanced_download_archive import DownloadArchiver
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
@ -49,9 +48,7 @@ class Plugin(BasePlugin[OptionsValidatorT], Generic[OptionsValidatorT], ABC):
Returns True if enabled, False if disabled. Returns True if enabled, False if disabled.
""" """
if isinstance(self.plugin_options, ToggleableOptionsDictValidator): if isinstance(self.plugin_options, ToggleableOptionsDictValidator):
return ScriptUtils.bool_formatter_output( return self.overrides.apply_formatter(self.plugin_options.enable, expected_type=bool)
self.overrides.apply_formatter(self.plugin_options.enable)
)
return True return True
def ytdl_options_match_filters(self) -> Tuple[List[str], List[str]]: def ytdl_options_match_filters(self) -> Tuple[List[str], List[str]]:
@ -122,6 +119,17 @@ class Plugin(BasePlugin[OptionsValidatorT], Generic[OptionsValidatorT], ABC):
""" """
return None return None
def post_completion_entry(self, file_metadata: FileMetadata) -> None:
"""
After the entry file is moved to its final location, run this hook.
Parameters
----------
file_metadata
Metadata about the completed entry's file download
"""
return None
def post_process_subscription(self): def post_process_subscription(self):
""" """
After all downloaded files have been post-processed, apply a subscription-wide post process After all downloaded files have been post-processed, apply a subscription-wide post process

View file

@ -24,6 +24,8 @@ from ytdl_sub.plugins.music_tags import MusicTagsPlugin
from ytdl_sub.plugins.nfo_tags import NfoTagsPlugin from ytdl_sub.plugins.nfo_tags import NfoTagsPlugin
from ytdl_sub.plugins.output_directory_nfo_tags import OutputDirectoryNfoTagsPlugin from ytdl_sub.plugins.output_directory_nfo_tags import OutputDirectoryNfoTagsPlugin
from ytdl_sub.plugins.split_by_chapters import SplitByChaptersPlugin from ytdl_sub.plugins.split_by_chapters import SplitByChaptersPlugin
from ytdl_sub.plugins.square_thumbnail import SquareThumbnailPlugin
from ytdl_sub.plugins.static_nfo_tags import StaticNfoTagsPlugin
from ytdl_sub.plugins.subtitles import SubtitlesPlugin from ytdl_sub.plugins.subtitles import SubtitlesPlugin
from ytdl_sub.plugins.throttle_protection import ThrottleProtectionPlugin from ytdl_sub.plugins.throttle_protection import ThrottleProtectionPlugin
from ytdl_sub.plugins.video_tags import VideoTagsPlugin from ytdl_sub.plugins.video_tags import VideoTagsPlugin
@ -39,6 +41,7 @@ class PluginMapping:
"audio_extract": AudioExtractPlugin, "audio_extract": AudioExtractPlugin,
"date_range": DateRangePlugin, "date_range": DateRangePlugin,
"embed_thumbnail": EmbedThumbnailPlugin, "embed_thumbnail": EmbedThumbnailPlugin,
"square_thumbnail": SquareThumbnailPlugin,
"file_convert": FileConvertPlugin, "file_convert": FileConvertPlugin,
"format": FormatPlugin, "format": FormatPlugin,
"match_filters": MatchFiltersPlugin, "match_filters": MatchFiltersPlugin,
@ -46,6 +49,7 @@ class PluginMapping:
"video_tags": VideoTagsPlugin, "video_tags": VideoTagsPlugin,
"nfo_tags": NfoTagsPlugin, "nfo_tags": NfoTagsPlugin,
"output_directory_nfo_tags": OutputDirectoryNfoTagsPlugin, "output_directory_nfo_tags": OutputDirectoryNfoTagsPlugin,
"static_nfo_tags": StaticNfoTagsPlugin,
"subtitles": SubtitlesPlugin, "subtitles": SubtitlesPlugin,
"chapters": ChaptersPlugin, "chapters": ChaptersPlugin,
"split_by_chapters": SplitByChaptersPlugin, "split_by_chapters": SplitByChaptersPlugin,
@ -83,9 +87,17 @@ class PluginMapping:
MusicTagsPlugin, MusicTagsPlugin,
VideoTagsPlugin, VideoTagsPlugin,
NfoTagsPlugin, NfoTagsPlugin,
StaticNfoTagsPlugin,
SquareThumbnailPlugin,
EmbedThumbnailPlugin, EmbedThumbnailPlugin,
] ]
_ORDER_POST_COMPLETION: List[Type[Plugin]] = [
# Throttle protection should always be last
# to not sleep over other logic
ThrottleProtectionPlugin
]
@classmethod @classmethod
def _order_by( def _order_by(
cls, plugin_types: List[Type[Plugin]], operation: PluginOperation cls, plugin_types: List[Type[Plugin]], operation: PluginOperation
@ -96,6 +108,8 @@ class PluginMapping:
ordering = cls._ORDER_MODIFY_ENTRY ordering = cls._ORDER_MODIFY_ENTRY
elif operation == PluginOperation.POST_PROCESS: elif operation == PluginOperation.POST_PROCESS:
ordering = cls._ORDER_POST_PROCESS ordering = cls._ORDER_POST_PROCESS
elif operation == PluginOperation.POST_COMPLETION:
ordering = cls._ORDER_POST_COMPLETION
else: else:
raise ValueError("PluginOperation does not support ordering") raise ValueError("PluginOperation does not support ordering")

View file

@ -6,3 +6,4 @@ class PluginOperation(Enum):
MODIFY_ENTRY_METADATA = 0 MODIFY_ENTRY_METADATA = 0
MODIFY_ENTRY = 1 MODIFY_ENTRY = 1
POST_PROCESS = 2 POST_PROCESS = 2
POST_COMPLETION = 3

View file

@ -1,5 +1,7 @@
from typing import Iterable
from typing import List from typing import List
from typing import Optional from typing import Optional
from typing import Set
from typing import Tuple from typing import Tuple
from typing import Type from typing import Type
@ -44,3 +46,34 @@ class PresetPlugins:
if plugin_type in plugin_option_types: if plugin_type in plugin_option_types:
return self.plugin_options[plugin_option_types.index(plugin_type)] return self.plugin_options[plugin_option_types.index(plugin_type)]
return None return None
def get_added_and_modified_variables(
self, additional_options: List[OptionsValidator]
) -> Iterable[Tuple[OptionsValidator, Set[str], Set[str]]]:
"""
Iterates and returns the plugin options, added variables, modified variables
"""
for plugin_options in self.plugin_options + additional_options:
added_variables: Set[str] = set()
modified_variables: Set[str] = set()
for plugin_added_variables in plugin_options.added_variables(
unresolved_variables=set(),
).values():
added_variables |= set(plugin_added_variables)
for plugin_modified_variables in plugin_options.modified_variables().values():
modified_variables = plugin_modified_variables
yield plugin_options, added_variables, modified_variables
def get_all_variables(self, additional_options: List[OptionsValidator]) -> Set[str]:
"""
Returns set of all added and modified variables' names.
"""
all_variables: Set[str] = set()
for _, added, modified in self.get_added_and_modified_variables(additional_options):
all_variables.update(added)
all_variables.update(modified)
return all_variables

View file

@ -2,6 +2,7 @@ import copy
from typing import Any from typing import Any
from typing import Dict from typing import Dict
from typing import List from typing import List
from typing import Set
from mergedeep import mergedeep from mergedeep import mergedeep
@ -11,7 +12,6 @@ from ytdl_sub.config.plugin.plugin_mapping import PluginMapping
from ytdl_sub.config.plugin.preset_plugins import PresetPlugins from ytdl_sub.config.plugin.preset_plugins import PresetPlugins
from ytdl_sub.config.preset_options import OutputOptions from ytdl_sub.config.preset_options import OutputOptions
from ytdl_sub.config.preset_options import YTDLOptions from ytdl_sub.config.preset_options import YTDLOptions
from ytdl_sub.config.validators.variable_validation import VariableValidation
from ytdl_sub.downloaders.url.validators import MultiUrlValidator from ytdl_sub.downloaders.url.validators import MultiUrlValidator
from ytdl_sub.prebuilt_presets import PREBUILT_PRESET_NAMES from ytdl_sub.prebuilt_presets import PREBUILT_PRESET_NAMES
from ytdl_sub.prebuilt_presets import PUBLISHED_PRESET_NAMES from ytdl_sub.prebuilt_presets import PUBLISHED_PRESET_NAMES
@ -172,6 +172,37 @@ class Preset(_PresetShell):
mergedeep.merge({}, *reversed(presets_to_merge), strategy=mergedeep.Strategy.ADDITIVE) mergedeep.merge({}, *reversed(presets_to_merge), strategy=mergedeep.Strategy.ADDITIVE)
) )
def _initialize_overrides_script(self, overrides: Overrides) -> Overrides:
"""
Do some gymnastics to initialize the Overrides script.
"""
unresolved_variables: Set[str] = set()
for (
plugin_options,
added_variables,
modified_variables,
) in self.plugins.get_added_and_modified_variables(
additional_options=[self.downloader_options, self.output_options]
):
for added_variable in added_variables:
if not overrides.ensure_added_plugin_variable_valid(added_variable=added_variable):
# pylint: disable=protected-access
raise plugin_options._validation_exception(
f"Cannot use the variable name {added_variable} because it exists as a"
" built-in ytdl-sub variable name."
)
# pylint: enable=protected-access
# Set unresolved as variables that are added but do not exist as
# entry/override variables since they are created at run-time
unresolved_variables |= added_variables | modified_variables
# Initialize overrides with unresolved variables + modified variables to throw an error.
# For modified variables, this is to prevent a resolve(update=True) to setting any
# dependencies until it has been explicitly added
return overrides.initialize_script(unresolved_variables=unresolved_variables)
def __init__(self, config: ConfigValidator, name: str, value: Any): def __init__(self, config: ConfigValidator, name: str, value: Any):
super().__init__(name=name, value=value) super().__init__(name=name, value=value)
@ -192,13 +223,10 @@ class Preset(_PresetShell):
) )
self.plugins: PresetPlugins = self._validate_and_get_plugins() self.plugins: PresetPlugins = self._validate_and_get_plugins()
self.overrides = self._validate_key(key="overrides", validator=Overrides, default={}) self.overrides = self._initialize_overrides_script(
overrides=self._validate_key(key="overrides", validator=Overrides, default={})
VariableValidation( )
downloader_options=self.downloader_options, self.overrides.ensure_variable_names_not_a_plugin(plugin_names=PRESET_KEYS)
output_options=self.output_options,
plugins=self.plugins,
).initialize_preset_overrides(overrides=self.overrides).ensure_proper_usage()
@property @property
def name(self) -> str: def name(self) -> str:
@ -227,11 +255,18 @@ class Preset(_PresetShell):
""" """
return cls(config=config, name=preset_name, value=preset_dict) return cls(config=config, name=preset_name, value=preset_dict)
@property def yaml(self, subscription_only: bool) -> str:
def yaml(self) -> str:
""" """
Parameters
----------
subscription_only:
Only include the subscription contents, not the surrounding boiler-plate.
Returns Returns
------- -------
Preset in YAML format Preset in YAML format
""" """
if subscription_only:
return dump_yaml(self._value)
return dump_yaml({"presets": {self._name: self._value}}) return dump_yaml({"presets": {self._name: self._value}})

View file

@ -1,15 +1,22 @@
from typing import Any from typing import Any
from typing import Dict from typing import Dict
from typing import Optional from typing import Optional
from typing import Set
from ytdl_sub.config.defaults import DEFAULT_DOWNLOAD_ARCHIVE_NAME from ytdl_sub.config.defaults import DEFAULT_DOWNLOAD_ARCHIVE_NAME
from ytdl_sub.config.overrides import Overrides from ytdl_sub.config.overrides import Overrides
from ytdl_sub.config.plugin.plugin_operation import PluginOperation
from ytdl_sub.config.validators.options import OptionsDictValidator
from ytdl_sub.entries.script.variable_definitions import VARIABLES as v
from ytdl_sub.utils.exceptions import SubscriptionPermissionError
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.validators.file_path_validators import OverridesStringFormatterFilePathValidator from ytdl_sub.validators.file_path_validators import OverridesStringFormatterFilePathValidator
from ytdl_sub.validators.file_path_validators import StringFormatterFileNameValidator from ytdl_sub.validators.file_path_validators import StringFormatterFileNameValidator
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
from ytdl_sub.validators.string_datetime import StringDatetimeValidator from ytdl_sub.validators.string_datetime import StringDatetimeValidator
from ytdl_sub.validators.string_formatter_validators import OverridesIntegerFormatterValidator from ytdl_sub.validators.string_formatter_validators import OverridesIntegerFormatterValidator
from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator
from ytdl_sub.validators.string_formatter_validators import StandardizedDateValidator
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
from ytdl_sub.validators.string_formatter_validators import ( from ytdl_sub.validators.string_formatter_validators import (
UnstructuredOverridesDictFormatterValidator, UnstructuredOverridesDictFormatterValidator,
@ -53,19 +60,31 @@ class YTDLOptions(UnstructuredOverridesDictFormatterValidator):
def to_native_dict(self, overrides: Overrides) -> Dict: def to_native_dict(self, overrides: Overrides) -> Dict:
""" """
Materializes the entire ytdl-options dict from OverrideStringFormatters into Materializes the entire ytdl-options dict from OverrideStringFormatters into
native python native python.
""" """
return { out = {
key: overrides.apply_overrides_formatter_to_native(val) key: overrides.apply_formatter(val, expected_type=object)
for key, val in self.dict.items() for key, val in self.dict.items()
} }
if "cookiefile" in out:
if not FileHandler.is_file_existent(out["cookiefile"]):
raise ValidationException(
f"Specified cookiefile {out['cookiefile']} but it does not exist as a file."
)
if not FileHandler.is_file_readable(out["cookiefile"]):
raise SubscriptionPermissionError(
f"Cannot read cookiefile {out['cookiefile']} due to permissions issue."
)
return out
# Disable for proper docstring formatting # Disable for proper docstring formatting
# pylint: disable=line-too-long # pylint: disable=line-too-long
class OutputOptions(StrictDictValidator): class OutputOptions(OptionsDictValidator):
""" """
Defines where to output files and thumbnails after all post-processing has completed. Defines where to output files and thumbnails after all post-processing has completed.
@ -87,6 +106,8 @@ class OutputOptions(StrictDictValidator):
maintain_download_archive: True maintain_download_archive: True
keep_files_before: now keep_files_before: now
keep_files_after: 19000101 keep_files_after: 19000101
keep_max_files: 1000
keep_files_date_eval: "{upload_date_standardized}"
""" """
_required_keys = {"output_directory", "file_name"} _required_keys = {"output_directory", "file_name"}
@ -99,6 +120,9 @@ class OutputOptions(StrictDictValidator):
"keep_files_before", "keep_files_before",
"keep_files_after", "keep_files_after",
"keep_max_files", "keep_max_files",
"download_archive_standardized_date",
"keep_files_date_eval",
"preserve_mtime",
} }
@classmethod @classmethod
@ -156,6 +180,15 @@ class OutputOptions(StrictDictValidator):
self._keep_max_files = self._validate_key_if_present( self._keep_max_files = self._validate_key_if_present(
"keep_max_files", OverridesIntegerFormatterValidator "keep_max_files", OverridesIntegerFormatterValidator
) )
self._keep_files_date_eval = self._validate_key(
"keep_files_date_eval",
StandardizedDateValidator,
default=f"{{{v.upload_date_standardized.variable_name}}}",
)
self._preserve_mtime = self._validate_key_if_present(
key="preserve_mtime", validator=BoolValidator, default=False
)
if ( if (
self._keep_files_before or self._keep_files_after or self._keep_max_files self._keep_files_before or self._keep_files_after or self._keep_max_files
@ -272,6 +305,18 @@ class OutputOptions(StrictDictValidator):
""" """
return self._keep_files_after return self._keep_files_after
@property
def keep_files_date_eval(self) -> StandardizedDateValidator:
"""
:expected type: str
:description:
Uses this standardized date in the form of YYYY-MM-DD to record in the
download archive for a given entry. Subsequently, uses this value to
perform evaluation for keep_files_before/after and keep_max_files. Defaults
to the entry's upload_date_standardized variable.
"""
return self._keep_files_date_eval
@property @property
def keep_max_files(self) -> Optional[OverridesIntegerFormatterValidator]: def keep_max_files(self) -> Optional[OverridesIntegerFormatterValidator]:
""" """
@ -283,3 +328,21 @@ class OutputOptions(StrictDictValidator):
applied. Can be used in conjunction with ``keep_files_before`` and ``keep_files_after``. applied. Can be used in conjunction with ``keep_files_before`` and ``keep_files_after``.
""" """
return self._keep_max_files return self._keep_max_files
@property
def preserve_mtime(self) -> bool:
"""
:expected type: Optional[Boolean]
:description:
Preserve the video's original upload time as the file modification time.
When True, sets the file's mtime to match the video's upload_date from
yt-dlp metadata. Defaults to False.
"""
return self._preserve_mtime.value
def added_variables(self, unresolved_variables: Set[str]) -> Dict[PluginOperation, Set[str]]:
return {
# PluginOperation.MODIFY_ENTRY_METADATA: {
# VARIABLES.ytdl_sub_entry_date_eval.variable_name
# }
}

View file

@ -1,10 +1,6 @@
import copy
from typing import Dict from typing import Dict
from typing import Iterable
from typing import List from typing import List
from typing import Optional
from typing import Set from typing import Set
from typing import Tuple
from ytdl_sub.config.overrides import Overrides from ytdl_sub.config.overrides import Overrides
from ytdl_sub.config.plugin.plugin_mapping import PluginMapping from ytdl_sub.config.plugin.plugin_mapping import PluginMapping
@ -13,198 +9,188 @@ from ytdl_sub.config.plugin.preset_plugins import PresetPlugins
from ytdl_sub.config.preset_options import OutputOptions from ytdl_sub.config.preset_options import OutputOptions
from ytdl_sub.config.validators.options import OptionsValidator from ytdl_sub.config.validators.options import OptionsValidator
from ytdl_sub.downloaders.url.validators import MultiUrlValidator from ytdl_sub.downloaders.url.validators import MultiUrlValidator
from ytdl_sub.entries.variables.override_variables import REQUIRED_OVERRIDE_VARIABLE_NAMES from ytdl_sub.entries.script.variable_definitions import UNRESOLVED_VARIABLES
from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.script.script import Script from ytdl_sub.script.script import Script
from ytdl_sub.script.script import _is_function from ytdl_sub.script.utils.name_validation import is_function
from ytdl_sub.utils.scriptable import BASE_SCRIPT from ytdl_sub.utils.script import ScriptUtils
from ytdl_sub.validators.string_formatter_validators import to_variable_dependency_format_string
from ytdl_sub.validators.string_formatter_validators import validate_formatters from ytdl_sub.validators.string_formatter_validators import validate_formatters
# Entry variables to mock during validation
_DUMMY_ENTRY_VARIABLES: Dict[str, str] = {
name: to_variable_dependency_format_string(
# pylint: disable=protected-access
script=BASE_SCRIPT,
parsed_format_string=BASE_SCRIPT._variables[name],
# pylint: enable=protected-access
)
for name in BASE_SCRIPT.variable_names
}
class ResolutionLevel:
ORIGINAL = 0
FILL = 1
RESOLVE = 2
INTERNAL = 3
def _add_dummy_variables(variables: Iterable[str]) -> Dict[str, str]: @classmethod
dummy_variables: Dict[str, str] = {} def name_of(cls, resolution_level: int) -> str:
for var in variables: """
dummy_variables[var] = "" Name of the resolution level.
dummy_variables[f"{var}_sanitized"] = "" """
if resolution_level == cls.ORIGINAL:
return "original"
if resolution_level == cls.FILL:
return "fill"
if resolution_level == cls.RESOLVE:
return "resolve"
if resolution_level == cls.INTERNAL:
return "internal"
raise ValueError("Invalid resolution level")
return dummy_variables @classmethod
def all(cls) -> List[int]:
"""
def _add_dummy_overrides(overrides: Overrides) -> Dict[str, str]: All possible resolution levels.
# Have the dummy override variable contain all variable deps that it uses in the string """
dummy_overrides: Dict[str, str] = {} return [cls.ORIGINAL, cls.FILL, cls.RESOLVE, cls.INTERNAL]
for override_name in _override_variables(overrides):
if _is_function(override_name):
continue
# pylint: disable=protected-access
dummy_overrides[override_name] = to_variable_dependency_format_string(
script=overrides.script, parsed_format_string=overrides.script._variables[override_name]
)
# pylint: enable=protected-access
return dummy_overrides
def _get_added_and_modified_variables(
plugins: PresetPlugins, downloader_options: MultiUrlValidator
) -> Iterable[Tuple[OptionsValidator, Set[str], Set[str]]]:
"""
Iterates and returns the plugin options, added variables, modified variables
"""
options: List[OptionsValidator] = plugins.plugin_options
options.append(downloader_options)
for plugin_options in options:
added_variables: Set[str] = set()
modified_variables: Set[str] = set()
for plugin_added_variables in plugin_options.added_variables(
unresolved_variables=set(),
).values():
added_variables |= set(plugin_added_variables)
for plugin_modified_variables in plugin_options.modified_variables().values():
modified_variables = plugin_modified_variables
yield plugin_options, added_variables, modified_variables
def _override_variables(overrides: Overrides) -> Set[str]:
return set(list(overrides.initial_variables().keys()))
class VariableValidation: class VariableValidation:
def _get_resolve_partial_filter(self) -> Set[str]:
# Exclude sanitized variables from partial validation. This lessens the work
# and prevents double-evaluation, which can lead to bad behavior like double-prints.
return {
name
for name in self.script.variable_names
if name not in self.unresolved_variables and not name.endswith("_sanitized")
}
def _apply_resolution_level(self) -> None:
if self._resolution_level == ResolutionLevel.FILL:
self.unresolved_variables |= VARIABLES.variable_names(include_sanitized=True)
# Only partial resolve definitions that are already resolved
self.unresolved_variables |= {
name
for name in self.overrides.keys
if not is_function(name) and not self.script.definition_of(name).maybe_resolvable
}
elif self._resolution_level == ResolutionLevel.RESOLVE:
# Partial resolve everything, but not including internal variables
self.unresolved_variables |= VARIABLES.variable_names(include_sanitized=True)
elif self._resolution_level == ResolutionLevel.INTERNAL:
# Partial resolve everything including internal variables
pass
else:
raise ValueError("Invalid resolution level for validation")
self.script = self.script.resolve_partial(
unresolvable=self.unresolved_variables,
output_filter=self._get_resolve_partial_filter(),
)
def __init__( def __init__(
self, self,
overrides: Overrides,
downloader_options: MultiUrlValidator, downloader_options: MultiUrlValidator,
output_options: OutputOptions, output_options: OutputOptions,
plugins: PresetPlugins, plugins: PresetPlugins,
resolution_level: int = ResolutionLevel.RESOLVE,
): ):
self.overrides = overrides
self.downloader_options = downloader_options self.downloader_options = downloader_options
self.output_options = output_options self.output_options = output_options
self.plugins = plugins self.plugins = plugins
self.script: Optional[Script] = None self.script: Script = self.overrides.script
self.resolved_variables: Set[str] = set() self.unresolved_variables = (
self.unresolved_variables: Set[str] = set() self.plugins.get_all_variables(
additional_options=[self.output_options, self.downloader_options]
def initialize_preset_overrides(self, overrides: Overrides) -> "VariableValidation": )
""" | UNRESOLVED_VARIABLES
Do some gymnastics to initialize the Overrides script.
"""
override_variables = set(list(overrides.initial_variables().keys()))
# Set resolved variables as all entry + override variables
# at this point to generate every possible added/modified variable
self.resolved_variables = set(_DUMMY_ENTRY_VARIABLES.keys()) | override_variables
plugin_variables: Set[str] = set()
for (
plugin_options,
added_variables,
modified_variables,
) in _get_added_and_modified_variables(
plugins=self.plugins,
downloader_options=self.downloader_options,
):
for added_variable in added_variables:
if not overrides.ensure_added_plugin_variable_valid(added_variable=added_variable):
# pylint: disable=protected-access
raise plugin_options._validation_exception(
f"Cannot use the variable name {added_variable} because it exists as a"
" built-in ytdl-sub variable name."
)
# pylint: enable=protected-access
# Set unresolved as variables that are added but do not exist as
# entry/override variables since they are created at run-time
self.unresolved_variables |= added_variables | modified_variables
plugin_variables |= added_variables | modified_variables
# Then update resolved variables to reflect that
self.resolved_variables -= self.unresolved_variables
# Initialize overrides with unresolved variables + modified variables to throw an error.
# For modified variables, this is to prevent a resolve(update=True) to setting any
# dependencies until it has been explicitly added
overrides = overrides.initialize_script(unresolved_variables=self.unresolved_variables)
# copy the script and mock entry variables
self.script = copy.deepcopy(overrides.script)
self.script.add(
variables=_add_dummy_overrides(overrides=overrides)
| _add_dummy_variables(variables=plugin_variables)
| _DUMMY_ENTRY_VARIABLES
) )
self.unresolved_runtime_variables = self.plugins.get_all_variables(
additional_options=[self.output_options, self.downloader_options]
)
self._resolution_level = resolution_level
return self self._apply_resolution_level()
def _update_script(self) -> None: def _add_runtime_variables(self, plugin_op: PluginOperation, options: OptionsValidator) -> None:
_ = self.script.resolve(unresolvable=self.unresolved_variables, update=True)
def _add_subscription_override_variables(self) -> None:
"""
Add dummy subscription variables for script validation
"""
self.resolved_variables |= REQUIRED_OVERRIDE_VARIABLE_NAMES
def _add_variables(self, plugin_op: PluginOperation, options: OptionsValidator) -> None:
""" """
Add dummy variables for script validation Add dummy variables for script validation
""" """
added_variables = options.added_variables( added_variables = options.added_variables(
unresolved_variables=self.unresolved_variables, unresolved_variables=self.unresolved_runtime_variables,
).get(plugin_op, set()) ).get(plugin_op, set())
modified_variables = options.modified_variables().get(plugin_op, set()) modified_variables = options.modified_variables().get(plugin_op, set())
resolved_variables = added_variables | modified_variables self.unresolved_runtime_variables -= added_variables | modified_variables
self.resolved_variables |= resolved_variables def ensure_proper_usage(self, partial_resolve_formatters: bool = False) -> Dict:
self.unresolved_variables -= resolved_variables
def ensure_proper_usage(self) -> None:
""" """
Validate variables resolve as plugins are executed, and return Validate variables resolve as plugins are executed, and return
a mock script which contains actualized added variables from the plugins a mock script which contains actualized added variables from the plugins
""" """
resolved_subscription: Dict = {}
self._add_variables(PluginOperation.DOWNLOADER, options=self.downloader_options) self._add_runtime_variables(PluginOperation.DOWNLOADER, options=self.downloader_options)
self._add_subscription_override_variables()
# Always add output options first
self._add_runtime_variables(
PluginOperation.MODIFY_ENTRY_METADATA, options=self.output_options
)
# Metadata variables to be added # Metadata variables to be added
for plugin_options in PluginMapping.order_options_by( for plugin_options in PluginMapping.order_options_by(
self.plugins.zipped(), PluginOperation.MODIFY_ENTRY_METADATA self.plugins.zipped(), PluginOperation.MODIFY_ENTRY_METADATA
): ):
self._add_variables(PluginOperation.MODIFY_ENTRY_METADATA, options=plugin_options) self._add_runtime_variables(
PluginOperation.MODIFY_ENTRY_METADATA, options=plugin_options
)
for plugin_options in PluginMapping.order_options_by( for plugin_options in PluginMapping.order_options_by(
self.plugins.zipped(), PluginOperation.MODIFY_ENTRY self.plugins.zipped(), PluginOperation.MODIFY_ENTRY
): ):
self._add_variables(PluginOperation.MODIFY_ENTRY, options=plugin_options) self._add_runtime_variables(PluginOperation.MODIFY_ENTRY, options=plugin_options)
# Validate that any formatter in the plugin options can resolve # Validate that any formatter in the plugin options can resolve
validate_formatters( resolved_subscription |= validate_formatters(
script=self.script, script=self.script,
unresolved_variables=self.unresolved_variables, unresolved_variables=self.unresolved_variables,
unresolved_runtime_variables=self.unresolved_runtime_variables,
validator=plugin_options, validator=plugin_options,
partial_resolve_formatters=partial_resolve_formatters,
) )
validate_formatters( resolved_subscription |= validate_formatters(
script=self.script, script=self.script,
unresolved_variables=self.unresolved_variables, unresolved_variables=self.unresolved_variables,
unresolved_runtime_variables=self.unresolved_runtime_variables,
validator=self.output_options, validator=self.output_options,
partial_resolve_formatters=partial_resolve_formatters,
) )
assert not self.unresolved_variables # TODO: make this a function
raw_download_output = validate_formatters(
script=self.script,
unresolved_variables=self.unresolved_variables,
unresolved_runtime_variables=self.unresolved_runtime_variables,
validator=self.downloader_options.urls,
partial_resolve_formatters=partial_resolve_formatters,
)
resolved_subscription["download"] = []
for url_output in raw_download_output["download"]:
if isinstance(url_output["url"], list):
url_output["url"] = [url for url in url_output["url"] if bool(url)]
if url_output["url"]:
resolved_subscription["download"].append(url_output)
# TODO: make function
resolved_subscription["overrides"] = {}
for name in self.overrides.keys:
value = self.script.definition_of(name)
if name in self.script.function_names:
# Keep custom functions as-is
resolved_subscription["overrides"][name] = self.overrides.dict_with_format_strings[
name
]
elif resolved := value.maybe_resolvable:
resolved_subscription["overrides"][name] = resolved.native
else:
resolved_subscription["overrides"][name] = ScriptUtils.to_native_script(value)
assert not self.unresolved_runtime_variables
return resolved_subscription

View file

@ -25,7 +25,6 @@ from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.entries.script.variable_definitions import VariableDefinitions from ytdl_sub.entries.script.variable_definitions import VariableDefinitions
from ytdl_sub.utils.file_handler import FileHandler from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.script import ScriptUtils
from ytdl_sub.utils.thumbnail import ThumbnailTypes from ytdl_sub.utils.thumbnail import ThumbnailTypes
from ytdl_sub.utils.thumbnail import download_and_convert_url_thumbnail from ytdl_sub.utils.thumbnail import download_and_convert_url_thumbnail
from ytdl_sub.utils.thumbnail import try_convert_download_thumbnail from ytdl_sub.utils.thumbnail import try_convert_download_thumbnail
@ -53,12 +52,12 @@ class UrlDownloaderBasePluginExtension(SourcePluginExtension[MultiUrlValidator])
if 0 <= input_url_idx < len(self.plugin_options.urls.list): if 0 <= input_url_idx < len(self.plugin_options.urls.list):
validator = self.plugin_options.urls.list[input_url_idx] validator = self.plugin_options.urls.list[input_url_idx]
if self.overrides.apply_formatter(validator.url) == entry_input_url: if entry_input_url in self.overrides.apply_formatter(validator.url, expected_type=list):
return validator return validator
# Match the first validator based on the URL, if one exists # Match the first validator based on the URL, if one exists
for validator in self.plugin_options.urls.list: for validator in self.plugin_options.urls.list:
if self.overrides.apply_formatter(validator.url) == entry_input_url: if entry_input_url in self.overrides.apply_formatter(validator.url, expected_type=list):
return validator return validator
# Return the first validator if none exist # Return the first validator if none exist
@ -258,6 +257,16 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
.to_dict() .to_dict()
) )
def webpage_url(self, entry: Entry) -> str:
"""
Returns
-------
The webpage_url to use for the actual download
"""
url_idx = entry.get(v.ytdl_sub_input_url_index, int)
webpage_url_formatter = self.plugin_options.urls.list[url_idx].webpage_url
return self.overrides.apply_formatter(webpage_url_formatter, entry=entry)
def metadata_ytdl_options(self, ytdl_option_overrides: Dict) -> Dict: def metadata_ytdl_options(self, ytdl_option_overrides: Dict) -> Dict:
""" """
Returns Returns
@ -359,7 +368,7 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
if (self.is_dry_run or not self.is_entry_thumbnails_enabled) if (self.is_dry_run or not self.is_entry_thumbnails_enabled)
else entry.is_thumbnail_downloaded_via_ytdlp else entry.is_thumbnail_downloaded_via_ytdlp
), ),
url=entry.webpage_url, url=self.webpage_url(entry=entry),
) )
return Entry( return Entry(
download_entry_dict, download_entry_dict,
@ -367,13 +376,13 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
) )
def _iterate_child_entries( def _iterate_child_entries(
self, entries: List[Entry], download_reversed: bool self, entries: List[Entry], validator: UrlValidator
) -> Iterator[Entry]: ) -> Iterator[Entry]:
# Iterate a list of entries, and delete the entries after yielding # Iterate a list of entries, and delete the entries after yielding
entries_to_iter: List[Optional[Entry]] = entries entries_to_iter: List[Optional[Entry]] = entries
indices = list(range(len(entries_to_iter))) indices = list(range(len(entries_to_iter)))
if download_reversed: if self.overrides.apply_formatter(validator.download_reverse, expected_type=bool):
indices = reversed(indices) indices = reversed(indices)
for idx in indices: for idx in indices:
@ -395,17 +404,13 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
entries_to_iter[idx] = None entries_to_iter[idx] = None
def _iterate_parent_entry( def _iterate_parent_entry(
self, parent: EntryParent, download_reversed: bool self, parent: EntryParent, validator: UrlValidator
) -> Iterator[Entry]: ) -> Iterator[Entry]:
yield from self._iterate_child_entries( yield from self._iterate_child_entries(entries=parent.entry_children(), validator=validator)
entries=parent.entry_children(), download_reversed=download_reversed
)
# Recursion the parent's parent entries # Recursion the parent's parent entries
for parent_child in reversed(parent.parent_children()): for parent_child in reversed(parent.parent_children()):
yield from self._iterate_parent_entry( yield from self._iterate_parent_entry(parent=parent_child, validator=validator)
parent=parent_child, download_reversed=download_reversed
)
def _download_url_metadata( def _download_url_metadata(
self, url: str, include_sibling_metadata: bool, ytdl_options_overrides: Dict self, url: str, include_sibling_metadata: bool, ytdl_options_overrides: Dict
@ -439,7 +444,7 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
self, self,
parents: List[EntryParent], parents: List[EntryParent],
orphans: List[Entry], orphans: List[Entry],
download_reversed: bool, validator: UrlValidator,
) -> Iterator[Entry]: ) -> Iterator[Entry]:
""" """
Downloads the leaf entries from EntryParent trees Downloads the leaf entries from EntryParent trees
@ -447,23 +452,17 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
# Delete info json files afterwards so other collection URLs do not use them # Delete info json files afterwards so other collection URLs do not use them
with self._separate_download_archives(clear_info_json_files=True): with self._separate_download_archives(clear_info_json_files=True):
for parent in parents: for parent in parents:
yield from self._iterate_parent_entry( yield from self._iterate_parent_entry(parent=parent, validator=validator)
parent=parent, download_reversed=download_reversed
)
yield from self._iterate_child_entries( yield from self._iterate_child_entries(entries=orphans, validator=validator)
entries=orphans, download_reversed=download_reversed
)
def _download_metadata(self, url: str, validator: UrlValidator) -> Iterable[Entry]: def _download_metadata(self, url: str, validator: UrlValidator) -> Iterable[Entry]:
metadata_ytdl_options = self.metadata_ytdl_options( metadata_ytdl_options = self.metadata_ytdl_options(
ytdl_option_overrides=validator.ytdl_options.to_native_dict(self.overrides) ytdl_option_overrides=validator.ytdl_options.to_native_dict(self.overrides)
) )
download_reversed = ScriptUtils.bool_formatter_output(
self.overrides.apply_formatter(validator.download_reverse) include_sibling_metadata = self.overrides.apply_formatter(
) validator.include_sibling_metadata, expected_type=bool
include_sibling_metadata = ScriptUtils.bool_formatter_output(
self.overrides.apply_formatter(validator.include_sibling_metadata)
) )
parents, orphan_entries = self._download_url_metadata( parents, orphan_entries = self._download_url_metadata(
@ -472,16 +471,15 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
ytdl_options_overrides=metadata_ytdl_options, ytdl_options_overrides=metadata_ytdl_options,
) )
# TODO: Encapsulate this logic into its own class
self._url_state = URLDownloadState( self._url_state = URLDownloadState(
entries_total=sum(parent.num_children() for parent in parents) + len(orphan_entries) entries_total=sum(parent.num_children() for parent in parents) + len(orphan_entries),
) )
download_logger.info("Beginning downloads for %s", url) download_logger.info("Beginning downloads for %s", url)
yield from self._iterate_entries( yield from self._iterate_entries(
parents=parents, parents=parents,
orphans=orphan_entries, orphans=orphan_entries,
download_reversed=download_reversed, validator=validator,
) )
def download_metadata(self) -> Iterable[Entry]: def download_metadata(self) -> Iterable[Entry]:
@ -489,19 +487,25 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
# download the bottom-most urls first since they are top-priority # download the bottom-most urls first since they are top-priority
for idx, url_validator in reversed(list(enumerate(self.collection.urls.list))): for idx, url_validator in reversed(list(enumerate(self.collection.urls.list))):
# URLs can be empty. If they are, then skip # URLs can be empty. If they are, then skip
if not (url := self.overrides.apply_formatter(url_validator.url)): if not (urls := self.overrides.apply_formatter(url_validator.url, expected_type=list)):
continue continue
for entry in self._download_metadata(url=url, validator=url_validator): for url in reversed(urls):
entry.initialize_script(self.overrides).add( assert isinstance(url, str)
{
v.ytdl_sub_input_url: url,
v.ytdl_sub_input_url_index: idx,
v.ytdl_sub_input_url_count: len(self.collection.urls.list),
}
)
yield entry if not url:
continue
for entry in self._download_metadata(url=url, validator=url_validator):
entry.initialize_script(self.overrides).add(
{
v.ytdl_sub_input_url: url,
v.ytdl_sub_input_url_index: idx,
v.ytdl_sub_input_url_count: len(self.collection.urls.list),
}
)
yield entry
def download(self, entry: Entry) -> Optional[Entry]: def download(self, entry: Entry) -> Optional[Entry]:
""" """
@ -534,8 +538,8 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
download_logger.info("Entry rejected by download match-filter, skipping ..") download_logger.info("Entry rejected by download match-filter, skipping ..")
return None return None
upload_date_idx = self._enhanced_download_archive.mapping.get_num_entries_with_upload_date( upload_date_idx = self._enhanced_download_archive.mapping.get_num_entries_with_date(
upload_date_standardized=entry.get(v.upload_date_standardized, str) standardized_date=entry.get(v.ytdl_sub_keep_files_date_eval, str)
) )
download_idx = self._enhanced_download_archive.num_entries download_idx = self._enhanced_download_archive.num_entries

View file

@ -1,5 +1,6 @@
from typing import Any from typing import Any
from typing import Dict from typing import Dict
from typing import List
from typing import Optional from typing import Optional
from typing import Set from typing import Set
@ -21,7 +22,7 @@ class UrlThumbnailValidator(StrictDictValidator):
def __init__(self, name, value): def __init__(self, name, value):
super().__init__(name, value) super().__init__(name, value)
self._name = self._validate_key(key="name", validator=StringFormatterValidator) self._thumb_name = self._validate_key(key="name", validator=StringFormatterValidator)
self._uid = self._validate_key(key="uid", validator=OverridesStringFormatterValidator) self._uid = self._validate_key(key="uid", validator=OverridesStringFormatterValidator)
@property @property
@ -29,7 +30,7 @@ class UrlThumbnailValidator(StrictDictValidator):
""" """
File name for the thumbnail File name for the thumbnail
""" """
return self._name return self._thumb_name
@property @property
def uid(self) -> OverridesStringFormatterValidator: def uid(self) -> OverridesStringFormatterValidator:
@ -43,6 +44,19 @@ class UrlThumbnailListValidator(ListValidator[UrlThumbnailValidator]):
_inner_list_type = UrlThumbnailValidator _inner_list_type = UrlThumbnailValidator
class OverridesOneOrManyUrlValidator(OverridesStringFormatterValidator):
def post_process(self, resolved: Any) -> List[str]:
if isinstance(resolved, str):
return [resolved]
if isinstance(resolved, list):
for value in resolved:
if not isinstance(value, str):
raise self._validation_exception("Must be a string or an array of strings.")
return resolved
raise self._validation_exception("Must be a string or an array of strings.")
class UrlValidator(StrictDictValidator): class UrlValidator(StrictDictValidator):
_required_keys = {"url"} _required_keys = {"url"}
_optional_keys = { _optional_keys = {
@ -52,6 +66,7 @@ class UrlValidator(StrictDictValidator):
"download_reverse", "download_reverse",
"ytdl_options", "ytdl_options",
"include_sibling_metadata", "include_sibling_metadata",
"webpage_url",
} }
@classmethod @classmethod
@ -67,7 +82,7 @@ class UrlValidator(StrictDictValidator):
super().__init__(name, value) super().__init__(name, value)
# TODO: url validate using yt-dlp IE # TODO: url validate using yt-dlp IE
self._url = self._validate_key(key="url", validator=OverridesStringFormatterValidator) self._url = self._validate_key(key="url", validator=OverridesOneOrManyUrlValidator)
self._variables = self._validate_key_if_present( self._variables = self._validate_key_if_present(
key="variables", validator=DictFormatterValidator, default={} key="variables", validator=DictFormatterValidator, default={}
) )
@ -89,6 +104,9 @@ class UrlValidator(StrictDictValidator):
validator=OverridesBooleanFormatterValidator, validator=OverridesBooleanFormatterValidator,
default="False", default="False",
) )
self._webpage_url = self._validate_key(
key="webpage_url", validator=StringFormatterValidator, default="{webpage_url}"
)
@property @property
def url(self) -> OverridesStringFormatterValidator: def url(self) -> OverridesStringFormatterValidator:
@ -180,6 +198,19 @@ class UrlValidator(StrictDictValidator):
""" """
return self._include_sibling_metadata return self._include_sibling_metadata
@property
def webpage_url(self) -> StringFormatterValidator:
"""
Optional. After ytdl-sub performs the metadata download, it will inspect each
entry's .info.json file and perform the actual download from yt-dlp using
`webpage_url <config_reference/scripting/entry_variables:webpage_url>`. This
can be overwritten by supplying parameter with a modification to ``webpage_url`` in the
form of an override variable.
Defaults to ``{webpage_url}``.
"""
return self._webpage_url
class UrlStringOrDictValidator(UrlValidator): class UrlStringOrDictValidator(UrlValidator):
""" """

View file

@ -224,7 +224,7 @@ class YTDLP:
except RejectedVideoReached: except RejectedVideoReached:
cls.logger.debug( cls.logger.debug(
"RejectedVideoReached, stopping additional downloads " "RejectedVideoReached, stopping additional downloads "
"(Can be disable by setting `date_range.breaking` to False)." "(Can be disable by setting `date_range.breaks` to False)."
) )
except ExistingVideoReached: except ExistingVideoReached:
cls.logger.debug( cls.logger.debug(

View file

@ -102,6 +102,9 @@ class Entry(BaseEntry, Scriptable):
v.sponsorblock_chapters.metadata_key, [] v.sponsorblock_chapters.metadata_key, []
), ),
v.comments: download_entry._kwargs_get(v.comments.metadata_key, []), v.comments: download_entry._kwargs_get(v.comments.metadata_key, []),
# Updates with more accurate value, which may differ from the metadata value
v.height: download_entry._kwargs_get(v.height.metadata_key, 0),
v.width: download_entry._kwargs_get(v.width.metadata_key, 0),
} }
) )
return self return self

View file

@ -823,6 +823,17 @@ class YtdlSubVariableDefinitions(ABC):
variable_name="upload_date_index_reversed_padded", pad=2 variable_name="upload_date_index_reversed_padded", pad=2
) )
@cached_property
def ytdl_sub_keep_files_date_eval(self: "VariableDefinitions") -> StringVariable:
"""
:description:
The standardized date variable supplied in ``output_options.keep_files_date_eval``.
"""
return StringVariable(
variable_name="ytdl_sub_entry_date_eval",
definition=f"{{%string({self.upload_date_standardized.variable_name})}}",
)
class EntryVariableDefinitions(ABC): class EntryVariableDefinitions(ABC):
@cached_property @cached_property
@ -1082,6 +1093,24 @@ class EntryVariableDefinitions(ABC):
definition="{ {} }", definition="{ {} }",
) )
@cached_property
def height(self: "VariableDefinitions") -> IntegerMetadataVariable:
"""
:description:
Height in pixels of the video. If this value is unavailable (i.e. audio download), it
will default to 0.
"""
return IntegerMetadataVariable.from_entry(metadata_key="height", default=0)
@cached_property
def width(self: "VariableDefinitions") -> IntegerMetadataVariable:
"""
:description:
Width in pixels of the video. If this value is unavailable (i.e. audio download), it
will default to 0.
"""
return IntegerMetadataVariable.from_entry(metadata_key="width", default=0)
class VariableDefinitions( class VariableDefinitions(
EntryVariableDefinitions, EntryVariableDefinitions,
@ -1106,6 +1135,16 @@ class VariableDefinitions(
] ]
} }
@cache
def variable_names(self, include_sanitized: bool):
"""
Returns all variable names, and can include sanitized.
"""
var_names: Set[str] = self.scripts().keys()
if include_sanitized:
var_names |= {f"{name}_sanitized" for name in var_names}
return var_names
@cache @cache
def injected_variables(self) -> Set[MetadataVariable]: def injected_variables(self) -> Set[MetadataVariable]:
""" """
@ -1121,6 +1160,9 @@ class VariableDefinitions(
self.ytdl_sub_input_url, self.ytdl_sub_input_url,
self.ytdl_sub_input_url_index, self.ytdl_sub_input_url_index,
self.ytdl_sub_input_url_count, self.ytdl_sub_input_url_count,
self.ytdl_sub_keep_files_date_eval,
self.width,
self.height,
} }
@cache @cache
@ -1130,6 +1172,7 @@ class VariableDefinitions(
""" """
return { return {
self.uid, self.uid,
self.extractor,
self.extractor_key, self.extractor_key,
self.epoch, self.epoch,
self.webpage_url, self.webpage_url,

View file

@ -22,7 +22,6 @@ VariableT = TypeVar("VariableT", bound="Variable")
def _get( def _get(
cast: str,
metadata_variable_name: str, metadata_variable_name: str,
metadata_key: str, metadata_key: str,
variable_name: Optional[str], variable_name: Optional[str],
@ -47,7 +46,7 @@ def _get(
return as_type( return as_type(
variable_name=variable_name or metadata_key, variable_name=variable_name or metadata_key,
metadata_key=metadata_key, metadata_key=metadata_key,
definition=f"{{ %legacy_bracket_safety(%{cast}({out})) }}", definition=f"{{ {out} }}",
) )
@ -182,7 +181,6 @@ class MapMetadataVariable(MetadataVariable, MapVariable):
Creates a map variable from entry metadata Creates a map variable from entry metadata
""" """
return _get( return _get(
"map",
metadata_variable_name=ENTRY_METADATA_VARIABLE_NAME, metadata_variable_name=ENTRY_METADATA_VARIABLE_NAME,
metadata_key=metadata_key, metadata_key=metadata_key,
variable_name=variable_name, variable_name=variable_name,
@ -204,7 +202,6 @@ class ArrayMetadataVariable(MetadataVariable, ArrayVariable):
Creates an array variable from entry metadata Creates an array variable from entry metadata
""" """
return _get( return _get(
"array",
metadata_variable_name=ENTRY_METADATA_VARIABLE_NAME, metadata_variable_name=ENTRY_METADATA_VARIABLE_NAME,
metadata_key=metadata_key, metadata_key=metadata_key,
variable_name=variable_name, variable_name=variable_name,
@ -226,7 +223,6 @@ class StringMetadataVariable(MetadataVariable, StringVariable):
Creates a string variable from entry metadata Creates a string variable from entry metadata
""" """
return _get( return _get(
"string",
metadata_variable_name=ENTRY_METADATA_VARIABLE_NAME, metadata_variable_name=ENTRY_METADATA_VARIABLE_NAME,
metadata_key=metadata_key, metadata_key=metadata_key,
variable_name=variable_name, variable_name=variable_name,
@ -245,7 +241,6 @@ class StringMetadataVariable(MetadataVariable, StringVariable):
Creates a string variable from playlist metadata Creates a string variable from playlist metadata
""" """
return _get( return _get(
"string",
metadata_variable_name=PLAYLIST_METADATA_VARIABLE_NAME, metadata_variable_name=PLAYLIST_METADATA_VARIABLE_NAME,
metadata_key=metadata_key, metadata_key=metadata_key,
variable_name=variable_name, variable_name=variable_name,
@ -264,7 +259,6 @@ class StringMetadataVariable(MetadataVariable, StringVariable):
Creates a string variable from source metadata Creates a string variable from source metadata
""" """
return _get( return _get(
"string",
metadata_variable_name=SOURCE_METADATA_VARIABLE_NAME, metadata_variable_name=SOURCE_METADATA_VARIABLE_NAME,
metadata_key=metadata_key, metadata_key=metadata_key,
variable_name=variable_name, variable_name=variable_name,
@ -301,7 +295,6 @@ class IntegerMetadataVariable(MetadataVariable, IntegerVariable):
Creates an int variable from entry metadata Creates an int variable from entry metadata
""" """
return _get( return _get(
"int",
metadata_variable_name=ENTRY_METADATA_VARIABLE_NAME, metadata_variable_name=ENTRY_METADATA_VARIABLE_NAME,
metadata_key=metadata_key, metadata_key=metadata_key,
variable_name=variable_name, variable_name=variable_name,
@ -320,7 +313,6 @@ class IntegerMetadataVariable(MetadataVariable, IntegerVariable):
Creates an int variable from playlist metadata Creates an int variable from playlist metadata
""" """
return _get( return _get(
"int",
metadata_variable_name=PLAYLIST_METADATA_VARIABLE_NAME, metadata_variable_name=PLAYLIST_METADATA_VARIABLE_NAME,
metadata_key=metadata_key, metadata_key=metadata_key,
variable_name=variable_name, variable_name=variable_name,

View file

@ -11,9 +11,6 @@ from ytdl_sub.entries.script.variable_types import Variable
from ytdl_sub.script.functions import Functions from ytdl_sub.script.functions import Functions
from ytdl_sub.script.utils.name_validation import is_valid_name from ytdl_sub.script.utils.name_validation import is_valid_name
# TODO: use this
SUBSCRIPTION_ARRAY = "subscription_array"
class SubscriptionVariables: class SubscriptionVariables:
@staticmethod @staticmethod
@ -55,16 +52,21 @@ class SubscriptionVariables:
@staticmethod @staticmethod
def subscription_indent_i(index: int) -> StringVariable: def subscription_indent_i(index: int) -> StringVariable:
""" """
For subscriptions in the form of For subscriptions where the ancestor keys contain the ``= ...`` prefix, the
variables ``subscription_indent_1``, ``subscription_indent_2``, and so on get
set to each subsequent value. For example, given the following subscriptions
file snippet:
.. code-block:: yaml .. code-block:: yaml
Preset | = Indent Value 1: Preset 1 | = Indent Value 1 | Preset 2:
= Indent Value 2: Preset 3 | = Indent Value 2 | Preset 4:
"Subscription Name": "https://..." "Subscription Name": "https://..."
``subscription_indent_1`` and ``subscription_indent_2`` get set to The ``{subscription_indent_1}`` variable will be ``Indent Value 1`` and
``Indent Value 1`` and ``Indent Value 2``. ``{subscription_indent_2}`` will be ``Indent Value 2``. The most common use of
these variables is to :doc:`set the genre and rating for subscriptions from the
YAML keys <../prebuilt_presets/tv_show>`.
""" """
return StringVariable( return StringVariable(
variable_name=f"subscription_indent_{index + 1}", definition="{ %string('') }" variable_name=f"subscription_indent_{index + 1}", definition="{ %string('') }"
@ -158,7 +160,7 @@ class OverrideHelpers:
True if the override name itself is valid. False otherwise. True if the override name itself is valid. False otherwise.
""" """
if name.startswith("%"): if name.startswith("%"):
return is_valid_name(name=name[1:]) name = name[1:]
return is_valid_name(name=name) return is_valid_name(name=name)

View file

@ -1,13 +1,18 @@
from typing import List from typing import List
from typing import Optional from typing import Optional
from typing import Set
from typing import Tuple from typing import Tuple
from ytdl_sub.config.plugin.plugin import Plugin from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator
from ytdl_sub.utils.datetime import to_date_str from ytdl_sub.utils.datetime import to_date_str
from ytdl_sub.utils.script import ScriptUtils
from ytdl_sub.validators.string_datetime import StringDatetimeValidator from ytdl_sub.validators.string_datetime import StringDatetimeValidator
from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator
from ytdl_sub.validators.string_select_validator import OverridesStringSelectValidator
class DateRangeType(OverridesStringSelectValidator):
_select_values: Set[str] = {"upload_date", "release_date"}
class DateRangeOptions(ToggleableOptionsDictValidator): class DateRangeOptions(ToggleableOptionsDictValidator):
@ -20,9 +25,11 @@ class DateRangeOptions(ToggleableOptionsDictValidator):
A string in the format YYYYMMDD or A string in the format YYYYMMDD or
(now|today|yesterday|date)[+-][0-9](microsecond|second|minute|hour|day|week|month|year)(s) (now|today|yesterday|date)[+-][0-9](microsecond|second|minute|hour|day|week|month|year)(s)
Valid examples are ``now-2weeks`` or ``20200101``. Can use override variables in this. Valid examples are ``now-2weeks`` or ``20200101``. Can use override variables in
Note that yt-dlp will round times to the closest day, meaning that `day` is the lowest this. Note that yt-dlp will round times to the closest day, meaning that `day` is
granularity possible. the lowest granularity possible. Also note that, considering time zones, it's best
to include a margin of an extra day on either side to be sure it includes the
intended download files.
:Usage: :Usage:
@ -31,9 +38,11 @@ class DateRangeOptions(ToggleableOptionsDictValidator):
date_range: date_range:
before: "now" before: "now"
after: "today-2weeks" after: "today-2weeks"
breaks: True
type: "upload_date"
""" """
_optional_keys = {"enable", "before", "after", "breaks"} _optional_keys = {"enable", "before", "after", "breaks", "type"}
def __init__(self, name, value): def __init__(self, name, value):
super().__init__(name, value) super().__init__(name, value)
@ -42,13 +51,14 @@ class DateRangeOptions(ToggleableOptionsDictValidator):
self._breaks = self._validate_key_if_present( self._breaks = self._validate_key_if_present(
"breaks", OverridesBooleanFormatterValidator, default="True" "breaks", OverridesBooleanFormatterValidator, default="True"
) )
self._type = self._validate_key("type", DateRangeType, default="upload_date")
@property @property
def before(self) -> Optional[StringDatetimeValidator]: def before(self) -> Optional[StringDatetimeValidator]:
""" """
:expected type: Optional[OverridesFormatter] :expected type: Optional[OverridesFormatter]
:description: :description:
Only download videos before this datetime. Only download videos only before this datetime, not inclusive.
""" """
return self._before return self._before
@ -57,7 +67,7 @@ class DateRangeOptions(ToggleableOptionsDictValidator):
""" """
:expected type: Optional[OverridesFormatter] :expected type: Optional[OverridesFormatter]
:description: :description:
Only download videos after this datetime. Only download videos after or on this datetime, inclusive.
""" """
return self._after return self._after
@ -71,6 +81,16 @@ class DateRangeOptions(ToggleableOptionsDictValidator):
""" """
return self._breaks return self._breaks
@property
def type(self) -> DateRangeType:
"""
:expected type: Optional[OverridesFormatter]
:description:
Which type of date to use. Must be either ``upload_date`` or ``release_date``.
Defaults to ``upload_date``.
"""
return self._type
class DateRangePlugin(Plugin[DateRangeOptions]): class DateRangePlugin(Plugin[DateRangeOptions]):
plugin_options_type = DateRangeOptions plugin_options_type = DateRangeOptions
@ -84,20 +104,19 @@ class DateRangePlugin(Plugin[DateRangeOptions]):
match_filters: List[str] = [] match_filters: List[str] = []
breaking_match_filters: List[str] = [] breaking_match_filters: List[str] = []
date_type: str = self.overrides.apply_formatter(formatter=self.plugin_options.type)
if self.plugin_options.before: if self.plugin_options.before:
before_str = to_date_str( before_str = to_date_str(
date_validator=self.plugin_options.before, overrides=self.overrides date_validator=self.plugin_options.before, overrides=self.overrides
) )
match_filters.append(f"upload_date < {before_str}") match_filters.append(f"{date_type} < {before_str}")
if self.plugin_options.after: if self.plugin_options.after:
after_str = to_date_str( after_str = to_date_str(
date_validator=self.plugin_options.after, overrides=self.overrides date_validator=self.plugin_options.after, overrides=self.overrides
) )
after_filter = f"upload_date >= {after_str}" after_filter = f"{date_type} >= {after_str}"
if ScriptUtils.bool_formatter_output( if self.overrides.apply_formatter(self.plugin_options.breaks, expected_type=bool):
self.overrides.apply_formatter(self.plugin_options.breaks)
):
breaking_match_filters.append(after_filter) breaking_match_filters.append(after_filter)
else: else:
match_filters.append(after_filter) match_filters.append(after_filter)

View file

@ -11,12 +11,12 @@ from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS
from ytdl_sub.validators.validators import BoolValidator from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator
logger = Logger.get("embed-thumbnail") logger = Logger.get("embed-thumbnail")
class EmbedThumbnailOptions(BoolValidator, OptionsValidator): class EmbedThumbnailOptions(OverridesBooleanFormatterValidator, OptionsValidator):
""" """
Whether to embed thumbnails to the audio/video file or not. Whether to embed thumbnails to the audio/video file or not.
@ -33,7 +33,7 @@ class EmbedThumbnailPlugin(Plugin[EmbedThumbnailOptions]):
@property @property
def _embed_thumbnail(self) -> bool: def _embed_thumbnail(self) -> bool:
return self.plugin_options.value return self.overrides.apply_formatter(self.plugin_options, expected_type=bool)
@classmethod @classmethod
def _embed_video_thumbnail(cls, entry: Entry) -> None: def _embed_video_thumbnail(cls, entry: Entry) -> None:

View file

@ -7,14 +7,14 @@ from ytdl_sub.config.validators.options import OptionsValidator
from ytdl_sub.entries.entry import Entry from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.exceptions import StringFormattingException from ytdl_sub.utils.exceptions import StringFormattingException
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.script import ScriptUtils from ytdl_sub.validators.string_formatter_validators import BooleanFormatterValidator
from ytdl_sub.validators.string_formatter_validators import ListFormatterValidator from ytdl_sub.validators.validators import ListValidator
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
logger = Logger.get("filter-exclude") logger = Logger.get("filter-exclude")
class FilterExcludeOptions(ListFormatterValidator, OptionsValidator): class FilterExcludeOptions(ListValidator[BooleanFormatterValidator], OptionsValidator):
""" """
Applies a conditional OR on any number of filters comprised of either variables or scripts. Applies a conditional OR on any number of filters comprised of either variables or scripts.
If any filter evaluates to True, the entry will be excluded. If any filter evaluates to True, the entry will be excluded.
@ -30,6 +30,8 @@ class FilterExcludeOptions(ListFormatterValidator, OptionsValidator):
{ %contains( %lower(description), '#short' ) } { %contains( %lower(description), '#short' ) }
""" """
_inner_list_type = BooleanFormatterValidator
class FilterExcludePlugin(Plugin[FilterExcludeOptions]): class FilterExcludePlugin(Plugin[FilterExcludeOptions]):
plugin_options_type = FilterExcludeOptions plugin_options_type = FilterExcludeOptions
@ -53,10 +55,11 @@ class FilterExcludePlugin(Plugin[FilterExcludeOptions]):
return entry return entry
for formatter in self.plugin_options.list: for formatter in self.plugin_options.list:
out = ScriptUtils.bool_formatter_output( should_exclude = self.overrides.apply_formatter(
self.overrides.apply_formatter(formatter=formatter, entry=entry) formatter=formatter, entry=entry, expected_type=bool
) )
if bool(out):
if should_exclude:
logger.info( logger.info(
"Filtering '%s' from the filter %s evaluating to True", "Filtering '%s' from the filter %s evaluating to True",
entry.title, entry.title,

View file

@ -7,14 +7,14 @@ from ytdl_sub.config.validators.options import OptionsValidator
from ytdl_sub.entries.entry import Entry from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.exceptions import StringFormattingException from ytdl_sub.utils.exceptions import StringFormattingException
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.script import ScriptUtils from ytdl_sub.validators.string_formatter_validators import BooleanFormatterValidator
from ytdl_sub.validators.string_formatter_validators import ListFormatterValidator from ytdl_sub.validators.validators import ListValidator
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
logger = Logger.get("filter-include") logger = Logger.get("filter-include")
class FilterIncludeOptions(ListFormatterValidator, OptionsValidator): class FilterIncludeOptions(ListValidator[BooleanFormatterValidator], OptionsValidator):
""" """
Applies a conditional AND on any number of filters comprised of either variables or scripts. Applies a conditional AND on any number of filters comprised of either variables or scripts.
If all filters evaluate to True, the entry will be included. If all filters evaluate to True, the entry will be included.
@ -38,6 +38,8 @@ class FilterIncludeOptions(ListFormatterValidator, OptionsValidator):
} }
""" """
_inner_list_type = BooleanFormatterValidator
class FilterIncludePlugin(Plugin[FilterIncludeOptions]): class FilterIncludePlugin(Plugin[FilterIncludeOptions]):
plugin_options_type = FilterIncludeOptions plugin_options_type = FilterIncludeOptions
@ -61,10 +63,10 @@ class FilterIncludePlugin(Plugin[FilterIncludeOptions]):
return entry return entry
for formatter in self.plugin_options.list: for formatter in self.plugin_options.list:
out = ScriptUtils.bool_formatter_output( should_exclude = self.overrides.apply_formatter(
self.overrides.apply_formatter(formatter=formatter, entry=entry) formatter=formatter, entry=entry, expected_type=bool
) )
if not bool(out): if not should_exclude:
logger.info( logger.info(
"Filtering '%s' from the filter %s evaluating to False", "Filtering '%s' from the filter %s evaluating to False",
entry.title, entry.title,

View file

@ -5,7 +5,6 @@ from pathlib import Path
from typing import Any from typing import Any
from typing import Dict from typing import Dict
from typing import List from typing import List
from typing import Optional
from ytdl_sub.config.plugin.plugin import Plugin from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator
@ -19,8 +18,8 @@ from ytdl_sub.utils.xml import to_xml
from ytdl_sub.validators.file_path_validators import StringFormatterFileNameValidator from ytdl_sub.validators.file_path_validators import StringFormatterFileNameValidator
from ytdl_sub.validators.nfo_validators import NfoTagsValidator from ytdl_sub.validators.nfo_validators import NfoTagsValidator
from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator
from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
from ytdl_sub.validators.validators import BoolValidator
class SharedNfoTagsOptions(ToggleableOptionsDictValidator): class SharedNfoTagsOptions(ToggleableOptionsDictValidator):
@ -54,8 +53,8 @@ class SharedNfoTagsOptions(ToggleableOptionsDictValidator):
) )
self._tags = self._validate_key_if_present(key="tags", validator=NfoTagsValidator) self._tags = self._validate_key_if_present(key="tags", validator=NfoTagsValidator)
self._kodi_safe = self._validate_key_if_present( self._kodi_safe = self._validate_key_if_present(
key="kodi_safe", validator=BoolValidator, default=False key="kodi_safe", validator=OverridesBooleanFormatterValidator, default="False"
).value )
@property @property
def nfo_name(self) -> StringFormatterFileNameValidator: def nfo_name(self) -> StringFormatterFileNameValidator:
@ -81,9 +80,9 @@ class SharedNfoTagsOptions(ToggleableOptionsDictValidator):
return self._tags return self._tags
@property @property
def kodi_safe(self) -> Optional[bool]: def kodi_safe(self) -> OverridesBooleanFormatterValidator:
""" """
:expected type: Optional[Boolean] :expected type: OverridesBooleanFormatterValidator
:description: :description:
Defaults to False. Kodi does not support > 3-byte unicode characters, which include Defaults to False. Kodi does not support > 3-byte unicode characters, which include
emojis and some foreign language characters. Setting this to True will replace those emojis and some foreign language characters. Setting this to True will replace those
@ -141,7 +140,7 @@ class SharedNfoTagsPlugin(Plugin[SharedNfoTagsOptions], ABC):
if not nfo_tags: if not nfo_tags:
return return
if self.plugin_options.kodi_safe: if self.overrides.apply_formatter(self.plugin_options.kodi_safe, expected_type=bool):
nfo_root = to_max_3_byte_utf8_string(nfo_root) nfo_root = to_max_3_byte_utf8_string(nfo_root)
nfo_tags = { nfo_tags = {
to_max_3_byte_utf8_string(key): [ to_max_3_byte_utf8_string(key): [

View file

@ -0,0 +1,77 @@
from typing import List
from typing import Optional
from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.validators.options import OptionsValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.ffmpeg import FFMPEG
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator
logger = Logger.get("square-thumbnail")
class SquareThumbnailOptions(OverridesBooleanFormatterValidator, OptionsValidator):
"""
Whether to make thumbnails square. Supports both file and embedded-based thumbnails. Ideal
for representing audio albums.
:Usage:
.. code-block:: yaml
square_thumbnail: True
"""
class SquareThumbnailPlugin(Plugin[SquareThumbnailOptions]):
plugin_options_type = SquareThumbnailOptions
@property
def _square_thumbnail(self) -> bool:
return self.overrides.apply_formatter(self.plugin_options, expected_type=bool)
@classmethod
def _convert_to_square_thumbnail(cls, entry: Entry) -> None:
thumbnail_path = entry.get_download_thumbnail_path()
tmp_file_path = FFMPEG.tmp_file_path(thumbnail_path)
try:
ffmpeg_args: List[str] = [
"-i",
thumbnail_path,
"-c:v",
"mjpeg",
"-qmin",
"1",
"-qscale:v",
"1",
"-vf",
"crop=min(iw\\,ih):min(iw\\,ih)",
"-bitexact", # for reproducibility
tmp_file_path,
]
FFMPEG.run(ffmpeg_args)
FileHandler.move(tmp_file_path, thumbnail_path)
finally:
FileHandler.delete(tmp_file_path)
def post_process_entry(self, entry: Entry) -> Optional[FileMetadata]:
"""
Maybe make the thumbnail square
"""
if not self._square_thumbnail:
return None
if not self.is_dry_run:
if not entry.is_thumbnail_downloaded():
logger.warning(
"Cannot make a square thumbnail for '%s' because it is not available",
entry.title,
)
return None
self._convert_to_square_thumbnail(entry)
return FileMetadata("Square thumbnail")

View file

@ -0,0 +1,71 @@
from ytdl_sub.entries.entry import Entry
from ytdl_sub.plugins.nfo_tags import NfoTagsValidator
from ytdl_sub.plugins.nfo_tags import SharedNfoTagsOptions
from ytdl_sub.plugins.nfo_tags import SharedNfoTagsPlugin
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
class StaticNfoTagsOptions(SharedNfoTagsOptions):
"""
Adds an NFO file for every entry, but does not link it to an entry in the download
archive. This is intended to produce ``season.nfo`` files in each season
directory. Each entry within a season will overwrite this file with its season
name. If the entry gets deleted from ytdl-sub, this file will remain since it's not
linked.
Usage:
.. code-block:: yaml
presets:
my_example_preset:
static_nfo_tags:
# required
nfo_name: "season.nfo"
nfo_root: "season"
tags:
title: "My custom season name!"
# optional
kodi_safe: False
"""
@property
def nfo_root(self) -> StringFormatterValidator:
"""
:expected type: EntryFormatter
:description:
The root tag of the NFO's XML. In the usage above, it would look like
.. code-block:: xml
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<season>
</season>
"""
return self._nfo_root
@property
def tags(self) -> NfoTagsValidator:
"""
:expected type: NfoTags
:description:
Tags within the nfo_root tag. In the usage above, it would look like
.. code-block:: xml
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<season>
<title>My custom season name!</title>
</season>
"""
return self._tags
class StaticNfoTagsPlugin(SharedNfoTagsPlugin):
plugin_options_type = StaticNfoTagsOptions
def post_process_entry(self, entry: Entry) -> None:
"""
Creates the NFO from each entry, but does not link/save it to the entry.
"""
self._create_nfo(entry=entry, save_to_entry=False)

View file

@ -1,8 +1,10 @@
import os
from pathlib import Path from pathlib import Path
from typing import Dict from typing import Dict
from typing import List from typing import List
from typing import Optional from typing import Optional
from typing import Set from typing import Set
from typing import Tuple
from ytdl_sub.config.plugin.plugin import Plugin from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.plugin.plugin_operation import PluginOperation from ytdl_sub.config.plugin.plugin_operation import PluginOperation
@ -11,6 +13,7 @@ from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.entry import Entry from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.script.variable_definitions import VARIABLES from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.entries.script.variable_definitions import VariableDefinitions from ytdl_sub.entries.script.variable_definitions import VariableDefinitions
from ytdl_sub.script.utils.exceptions import UserThrownRuntimeError
from ytdl_sub.utils.file_handler import FileHandler from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
@ -57,6 +60,7 @@ class SubtitleOptions(ToggleableOptionsDictValidator):
"subtitles_type", "subtitles_type",
"embed_subtitles", "embed_subtitles",
"languages", "languages",
"languages_required",
"allow_auto_generated_subtitles", "allow_auto_generated_subtitles",
} }
@ -76,6 +80,9 @@ class SubtitleOptions(ToggleableOptionsDictValidator):
self._languages = self._validate_key_if_present( self._languages = self._validate_key_if_present(
key="languages", validator=StringListValidator, default=["en"] key="languages", validator=StringListValidator, default=["en"]
).list ).list
self._languages_required = self._validate_key_if_present(
key="languages_required", validator=StringListValidator, default=[]
).list
self._allow_auto_generated_subtitles = self._validate_key_if_present( self._allow_auto_generated_subtitles = self._validate_key_if_present(
key="allow_auto_generated_subtitles", validator=BoolValidator, default=False key="allow_auto_generated_subtitles", validator=BoolValidator, default=False
).value ).value
@ -121,6 +128,16 @@ class SubtitleOptions(ToggleableOptionsDictValidator):
""" """
return [lang.value for lang in self._languages] return [lang.value for lang in self._languages]
@property
def languages_required(self) -> Optional[List[str]]:
"""
:expected type: Optional[List[String]]
:description:
Language code(s) that are required to be present for downloads to continue. If missing,
ytdl-sub will throw an error. NOTE: currently this only checks file-based subtitles.
"""
return [lang.value for lang in self._languages_required]
@property @property
def allow_auto_generated_subtitles(self) -> Optional[bool]: def allow_auto_generated_subtitles(self) -> Optional[bool]:
""" """
@ -146,7 +163,12 @@ class SubtitlesPlugin(Plugin[SubtitleOptions]):
plugin_options_type = SubtitleOptions plugin_options_type = SubtitleOptions
def ytdl_options(self) -> Optional[Dict]: def ytdl_options(self) -> Optional[Dict]:
builder = YTDLOptionsBuilder().add({"writesubtitles": True}) builder = YTDLOptionsBuilder().add(
{
"writesubtitles": True,
"sleep_interval_subtitles": 5.367, # for safe measure
}
)
if self.plugin_options.embed_subtitles: if self.plugin_options.embed_subtitles:
builder.add( builder.add(
@ -206,7 +228,21 @@ class SubtitlesPlugin(Plugin[SubtitleOptions]):
if self.plugin_options.embed_subtitles: if self.plugin_options.embed_subtitles:
file_metadata = FileMetadata(f"Embedded subtitles with lang(s) {', '.join(langs)}") file_metadata = FileMetadata(f"Embedded subtitles with lang(s) {', '.join(langs)}")
if self.plugin_options.subtitles_name: if self.plugin_options.subtitles_name:
download_subtitle_lang_file_names: List[Tuple[str, str]] = []
for lang in langs: for lang in langs:
download_subtitle_file_name = entry.base_filename(
ext=f"{lang}.{self.plugin_options.subtitles_type}"
)
if os.path.isfile(Path(self.working_directory) / download_subtitle_file_name):
download_subtitle_lang_file_names.append((lang, download_subtitle_file_name))
elif lang in self.plugin_options.languages_required:
raise UserThrownRuntimeError(
f"Required the subtitle lang {lang}, but the file could not be found for "
f"the entry {entry.title}."
)
for lang, file_name in download_subtitle_lang_file_names:
output_subtitle_file_name = self.overrides.apply_formatter( output_subtitle_file_name = self.overrides.apply_formatter(
formatter=self.plugin_options.subtitles_name, formatter=self.plugin_options.subtitles_name,
entry=entry, entry=entry,
@ -214,9 +250,7 @@ class SubtitlesPlugin(Plugin[SubtitleOptions]):
) )
self.save_file( self.save_file(
file_name=entry.base_filename( file_name=file_name,
ext=f"{lang}.{self.plugin_options.subtitles_type}"
),
output_file_name=output_subtitle_file_name, output_file_name=output_subtitle_file_name,
entry=entry, entry=entry,
) )

View file

@ -1,6 +1,10 @@
import random import random
import time import time
from abc import ABC
from typing import Dict
from typing import Optional from typing import Optional
from typing import Type
from typing import TypeVar
from ytdl_sub.config.overrides import Overrides from ytdl_sub.config.overrides import Overrides
from ytdl_sub.config.plugin.plugin import Plugin from ytdl_sub.config.plugin.plugin import Plugin
@ -9,52 +13,135 @@ from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
from ytdl_sub.validators.validators import FloatValidator from ytdl_sub.validators.string_formatter_validators import FloatFormatterValidator
from ytdl_sub.validators.string_formatter_validators import OverridesFloatFormatterValidator
from ytdl_sub.validators.validators import ProbabilityValidator from ytdl_sub.validators.validators import ProbabilityValidator
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
logger = Logger.get("throttle-protection") logger = Logger.get("throttle-protection")
FloatValidatorT = TypeVar("FloatValidatorT", bound=FloatFormatterValidator)
class RandomizedRangeValidator(StrictDictValidator):
class _RandomizedRangeValidator(StrictDictValidator, ABC):
""" """
Validator to specify a float range between [min, max) Base class for range validation, to support both entry and static overrides.
""" """
_float_validator: Type[FloatValidatorT]
_required_keys = {"max"} _required_keys = {"max"}
_optional_keys = {"min"} _optional_keys = {"min"}
def __init__(self, name, value): def __init__(self, name, value):
super().__init__(name, value) super().__init__(name, value)
self._max = self._validate_key(key="max", validator=FloatValidator).value self._max = self._validate_key(key="max", validator=self._float_validator)
self._min = self._validate_key_if_present( self._min = self._validate_key_if_present(
key="min", validator=FloatValidator, default=0.0 key="min", validator=self._float_validator, default=0.0
).value )
if self._min < 0: def _randomized_float(self, overrides: Overrides, entry: Optional[Entry] = None) -> float:
raise self._validation_exception("min must be greater than zero") actualized_min = overrides.apply_formatter(self._min, entry=entry, expected_type=float)
actualized_max = overrides.apply_formatter(self._max, entry=entry, expected_type=float)
if self._max < self._min: if actualized_min < 0:
raise self._validation_exception( raise self._validation_exception(
f"max ({self._max}) must be greater than or equal to min ({self._min})" f"min must be greater than zero, received {actualized_min}"
)
if actualized_max < actualized_min:
raise self._validation_exception(
f"max ({actualized_max}) must be greater than or equal to min ({actualized_min})"
) )
def randomized_float(self) -> float: return random.uniform(actualized_min, actualized_max)
"""
Returns
-------
A random float within the range
"""
return random.uniform(self._min, self._max)
def randomized_int(self) -> int: def _randomized_int(self, overrides: Overrides, entry: Optional[Entry] = None) -> int:
""" """
Returns Returns
------- -------
A random float within the range, then cast to an integer (floored) A random float within the range, then cast to an integer (floored)
""" """
return int(self.randomized_float()) return int(self._randomized_float(overrides, entry=entry))
def _max_value(self, overrides: Overrides, entry: Optional[Entry] = None) -> float:
"""
Returns
-------
Max possible value
"""
actualized_max = overrides.apply_formatter(self._max, entry=entry, expected_type=float)
if actualized_max < 0:
raise self._validation_exception(
f"max must be greater than zero, received {actualized_max}"
)
return actualized_max
class RandomizedRangeValidator(_RandomizedRangeValidator):
"""
Validator to specify a float range between [min, max) with both
override and entry variable support.
"""
_float_validator = FloatFormatterValidator
def randomized_float(self, overrides: Overrides, entry: Entry) -> float:
"""
Returns
-------
A random float within the range
"""
return self._randomized_float(overrides=overrides, entry=entry)
def randomized_int(self, overrides: Overrides, entry: Entry) -> int:
"""
Returns
-------
A random float within the range, then cast to an integer (floored)
"""
return self._randomized_int(overrides=overrides, entry=entry)
def max_value(self, overrides: Overrides, entry: Entry) -> float:
"""
Returns
-------
Max possible value
"""
return self._max_value(overrides=overrides, entry=entry)
class RandomizedRangeOverridesValidator(_RandomizedRangeValidator):
"""
Validator to specify a float range between [min, max) with
static variable support.
"""
_float_validator = OverridesFloatFormatterValidator
def randomized_float(self, overrides: Overrides) -> float:
"""
Returns
-------
A random float within the range
"""
return self._randomized_float(overrides=overrides)
def randomized_int(self, overrides: Overrides) -> int:
"""
Returns
-------
A random float within the range, then cast to an integer (floored)
"""
return self._randomized_int(overrides=overrides)
def max_value(self, overrides: Overrides) -> float:
"""
Returns
-------
Max possible value
"""
return self._max_value(overrides=overrides)
class ThrottleProtectionOptions(ToggleableOptionsDictValidator): class ThrottleProtectionOptions(ToggleableOptionsDictValidator):
@ -63,6 +150,9 @@ class ThrottleProtectionOptions(ToggleableOptionsDictValidator):
range-based values, a random number will be chosen within the range to avoid sleeps looking range-based values, a random number will be chosen within the range to avoid sleeps looking
scripted. scripted.
Range min and max values support static override variables within their definitions.
``sleep_per_download_s`` supports both static and override variables.
:Usage: :Usage:
.. code-block:: yaml .. code-block:: yaml
@ -70,6 +160,9 @@ class ThrottleProtectionOptions(ToggleableOptionsDictValidator):
presets: presets:
my_example_preset: my_example_preset:
throttle_protection: throttle_protection:
sleep_per_request_s:
min: 5.5
max: 10.4
sleep_per_download_s: sleep_per_download_s:
min: 2.2 min: 2.2
max: 10.8 max: 10.8
@ -84,6 +177,7 @@ class ThrottleProtectionOptions(ToggleableOptionsDictValidator):
_optional_keys = { _optional_keys = {
"enable", "enable",
"sleep_per_request_s",
"sleep_per_download_s", "sleep_per_download_s",
"sleep_per_subscription_s", "sleep_per_subscription_s",
"max_downloads_per_subscription", "max_downloads_per_subscription",
@ -93,19 +187,34 @@ class ThrottleProtectionOptions(ToggleableOptionsDictValidator):
def __init__(self, name, value): def __init__(self, name, value):
super().__init__(name, value) super().__init__(name, value)
self._sleep_per_request_s = self._validate_key_if_present(
key="sleep_per_request_s", validator=RandomizedRangeOverridesValidator
)
self._sleep_per_download_s = self._validate_key_if_present( self._sleep_per_download_s = self._validate_key_if_present(
key="sleep_per_download_s", validator=RandomizedRangeValidator key="sleep_per_download_s", validator=RandomizedRangeValidator
) )
self._sleep_per_subscription_s = self._validate_key_if_present( self._sleep_per_subscription_s = self._validate_key_if_present(
key="sleep_per_subscription_s", validator=RandomizedRangeValidator key="sleep_per_subscription_s", validator=RandomizedRangeOverridesValidator
) )
self._max_downloads_per_subscription = self._validate_key_if_present( self._max_downloads_per_subscription = self._validate_key_if_present(
key="max_downloads_per_subscription", validator=RandomizedRangeValidator key="max_downloads_per_subscription", validator=RandomizedRangeOverridesValidator
) )
self._subscription_download_probability = self._validate_key_if_present( self._subscription_download_probability = self._validate_key_if_present(
key="subscription_download_probability", validator=ProbabilityValidator key="subscription_download_probability", validator=ProbabilityValidator
) )
@property
def sleep_per_request_s(self) -> Optional[RandomizedRangeValidator]:
"""
:expected type: Optional[Range]
:description:
Number in seconds to sleep between each request during metadata download. Note that
metadata download refers to the initial info.json download, not the actual audio/video
download for the entry. Also, yt-dlp only supports a single value at this time for this,
so will always use the max value.
"""
return self._sleep_per_request_s
@property @property
def sleep_per_download_s(self) -> Optional[RandomizedRangeValidator]: def sleep_per_download_s(self) -> Optional[RandomizedRangeValidator]:
""" """
@ -149,6 +258,13 @@ class ThrottleProtectionOptions(ToggleableOptionsDictValidator):
class ThrottleProtectionPlugin(Plugin[ThrottleProtectionOptions]): class ThrottleProtectionPlugin(Plugin[ThrottleProtectionOptions]):
plugin_options_type = ThrottleProtectionOptions plugin_options_type = ThrottleProtectionOptions
@classmethod
def perform_sleep(cls, sleep_time: float) -> None:
"""
Wrapper to be able to mock
"""
time.sleep(sleep_time)
def __init__( def __init__(
self, self,
options: ThrottleProtectionOptions, options: ThrottleProtectionOptions,
@ -159,12 +275,27 @@ class ThrottleProtectionPlugin(Plugin[ThrottleProtectionOptions]):
self._subscription_download_counter: int = 0 self._subscription_download_counter: int = 0
self._subscription_max_downloads: Optional[int] = None self._subscription_max_downloads: Optional[int] = None
# Compute this during post-processing using entry metadata.
# Apply the sleep post-completion.
self._entry_sleep_time: Optional[float] = None
# If subscriptions have a max download limit, set it here for the first subscription # If subscriptions have a max download limit, set it here for the first subscription
if self.plugin_options.max_downloads_per_subscription: if self.plugin_options.max_downloads_per_subscription:
self._subscription_max_downloads = ( self._subscription_max_downloads = (
self.plugin_options.max_downloads_per_subscription.randomized_int() self.plugin_options.max_downloads_per_subscription.randomized_int(
overrides=self.overrides
)
) )
def ytdl_options(self) -> Optional[Dict]:
if self.plugin_options.sleep_per_request_s is not None:
return {
"sleep_interval_requests": self.plugin_options.sleep_per_request_s.max_value(
overrides=self.overrides
)
}
return {}
def initialize_subscription(self) -> bool: def initialize_subscription(self) -> bool:
if self.plugin_options.subscription_download_probability: if self.plugin_options.subscription_download_probability:
proba = self.plugin_options.subscription_download_probability.value proba = self.plugin_options.subscription_download_probability.value
@ -198,7 +329,7 @@ class ThrottleProtectionPlugin(Plugin[ThrottleProtectionOptions]):
self._subscription_max_downloads is not None self._subscription_max_downloads is not None
and self._subscription_download_counter == 0 and self._subscription_download_counter == 0
): ):
logger.debug( logger.info(
"Setting subscription max downloads to %d", self._subscription_max_downloads "Setting subscription max downloads to %d", self._subscription_max_downloads
) )
@ -206,12 +337,19 @@ class ThrottleProtectionPlugin(Plugin[ThrottleProtectionOptions]):
self._subscription_download_counter += 1 self._subscription_download_counter += 1
if self.plugin_options.sleep_per_download_s: if self.plugin_options.sleep_per_download_s:
sleep_time = self.plugin_options.sleep_per_download_s.randomized_float() self._entry_sleep_time = self.plugin_options.sleep_per_download_s.randomized_float(
logger.debug("Sleeping between downloads for %0.2f seconds", sleep_time) overrides=self.overrides, entry=entry
time.sleep(sleep_time) )
return None return None
def post_completion_entry(self, file_metadata: FileMetadata) -> None:
if self._entry_sleep_time:
# pylint: disable=logging-fstring-interpolation)
# needed to test logs in unit test
logger.info(f"Sleeping between downloads for {self._entry_sleep_time:.2f} seconds")
self.perform_sleep(self._entry_sleep_time)
def post_process_subscription(self): def post_process_subscription(self):
# Reset counter to 0 for the next subscription # Reset counter to 0 for the next subscription
self._subscription_download_counter = 0 self._subscription_download_counter = 0
@ -219,10 +357,14 @@ class ThrottleProtectionPlugin(Plugin[ThrottleProtectionOptions]):
# If present, reset max downloads for the next subscription # If present, reset max downloads for the next subscription
if self.plugin_options.max_downloads_per_subscription: if self.plugin_options.max_downloads_per_subscription:
self._subscription_max_downloads = ( self._subscription_max_downloads = (
self.plugin_options.max_downloads_per_subscription.randomized_int self.plugin_options.max_downloads_per_subscription.randomized_int(
overrides=self.overrides
)
) )
if self.plugin_options.sleep_per_subscription_s: if self.plugin_options.sleep_per_subscription_s:
sleep_time = self.plugin_options.sleep_per_subscription_s.randomized_float() sleep_time = self.plugin_options.sleep_per_subscription_s.randomized_float(
logger.debug("Sleeping between subscriptions for %0.2f seconds", sleep_time) overrides=self.overrides
time.sleep(sleep_time) )
logger.info("Sleeping between subscriptions for %0.2f seconds", sleep_time)
self.perform_sleep(sleep_time)

View file

@ -34,7 +34,7 @@ class VideoTagsPlugin(Plugin[VideoTagsOptions]):
Tags the entry's audio file using values defined in the metadata options Tags the entry's audio file using values defined in the metadata options
""" """
tags_to_write: Dict[str, str] = {} tags_to_write: Dict[str, str] = {}
for tag_name, tag_formatter in self.plugin_options.dict.items(): for tag_name, tag_formatter in sorted(self.plugin_options.dict.items()):
tag_value = self.overrides.apply_formatter(formatter=tag_formatter, entry=entry) tag_value = self.overrides.apply_formatter(formatter=tag_formatter, entry=entry)
tags_to_write[tag_name] = tag_value tags_to_write[tag_name] = tag_value

View file

@ -11,8 +11,7 @@ presets:
# Set the default date_range to 2 months # Set the default date_range to 2 months
overrides: overrides:
date_range: "2months" # keep for legacy-reasons only_recent_date_range: "2months"
only_recent_date_range: "{date_range}"
############################################################################# #############################################################################
# Only Recent # Only Recent

Some files were not shown because too many files have changed in this diff Show more