Merge branch 'master' into j/airplane/default-verbose

This commit is contained in:
Jesse Bannon 2025-06-06 09:51:29 -07:00
commit 23a0b3d434
247 changed files with 7934 additions and 1120 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,9 +14,9 @@ else
endif
lint:
@-isort .
@-black .
@-pylint src/
python3 -m isort .
python3 -m black .
python3 -m pylint src
check_lint:
isort . --check-only --diff \
&& black . --check \

View file

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

View file

@ -3,8 +3,10 @@ FROM ghcr.io/linuxserver/baseimage-alpine:edge
###############################################################################
# YTDL-SUB INSTALL
# Needed for phantomjs
# For phantomjs
ENV OPENSSL_CONF="/etc/ssl"
# For downloading thumbnails
ENV SSL_CERT_DIR="/etc/ssl/certs/"
COPY root/ /
RUN mkdir -p /config && \
@ -55,6 +57,12 @@ RUN mkdir -p /config && \
# CONTAINER CONFIGS
ENV EDITOR="nano" \
HOME="/config"
HOME="/config" \
DOCKER_MODS=linuxserver/mods:universal-stdout-logs|linuxserver/mods:universal-cron \
DEFAULT_WORKSPACE=/config \
CRON_SCRIPT="/config/cron" \
CRON_WRAPPER_SCRIPT="/config/.cron_wrapper" \
LOGS_TO_STDOUT=/config/.cron.log \
LSIO_FIRST_PARTY=false
VOLUME /config

View file

@ -1,7 +1,9 @@
FROM lscr.io/linuxserver/code-server:4.18.0-ls181
FROM lscr.io/linuxserver/code-server:4.98.2
# Needed for phantomjs
ENV OPENSSL_CONF=/etc/ssl
# For phantomjs
ENV OPENSSL_CONF="/etc/ssl"
# For downloading thumbnails
ENV SSL_CERT_DIR="/etc/ssl/certs/"
###############################################################################
# YTDL-SUB INSTALL
@ -20,7 +22,6 @@ RUN mkdir -p /config && \
g++ \
nano \
make \
python3.10-dev \
python3-pip \
fontconfig \
xz-utils \
@ -59,7 +60,7 @@ RUN mkdir -p /config && \
phantomjs --version ; \
fi && \
# Install ytdl-sub, ensure it is installed properly
pip install --no-cache-dir ytdl_sub-*.whl && \
pip install --no-cache-dir --break-system-packages ytdl_sub-*.whl && \
ytdl-sub -h && \
# Delete unneeded packages after install
rm ytdl_sub-*.whl && \
@ -68,7 +69,6 @@ RUN mkdir -p /config && \
make \
xz-utils \
bzip2 \
python3.10-dev \
python3-venv && \
apt-get autoremove -y && \
apt-get purge -y --auto-remove && \
@ -77,11 +77,13 @@ RUN mkdir -p /config && \
###############################################################################
# CONTAINER CONFIGS
ENV YTDL_SUB_TYPE="gui" \
EDITOR="nano" \
ENV EDITOR="nano" \
HOME="/config" \
DOCKER_MODS=linuxserver/mods:universal-cron \
DEFAULT_WORKSPACE=/config/ytdl-sub-configs
DOCKER_MODS=linuxserver/mods:universal-stdout-logs|linuxserver/mods:universal-cron \
DEFAULT_WORKSPACE=/config/ytdl-sub-configs \
CRON_SCRIPT="/config/ytdl-sub-configs/cron" \
CRON_WRAPPER_SCRIPT="/config/.cron_wrapper" \
LOGS_TO_STDOUT=/config/.cron.log \
LSIO_FIRST_PARTY=false
VOLUME /config

View file

@ -1,10 +1,12 @@
FROM ghcr.io/linuxserver/baseimage-ubuntu:jammy
FROM ghcr.io/linuxserver/baseimage-ubuntu:noble
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
ARG DEBIAN_FRONTEND=noninteractive
# Needed for phantomjs
ENV OPENSSL_CONF=/etc/ssl
# For phantomjs
ENV OPENSSL_CONF="/etc/ssl"
# For downloading thumbnails
ENV SSL_CERT_DIR="/etc/ssl/certs/"
###############################################################################
# YTDL-SUB INSTALL
@ -23,7 +25,6 @@ RUN mkdir -p /config && \
g++ \
nano \
make \
python3.10-dev \
python3-pip \
fontconfig \
xz-utils \
@ -62,7 +63,7 @@ RUN mkdir -p /config && \
phantomjs --version ; \
fi && \
# Install ytdl-sub, ensure it is installed properly
pip install --no-cache-dir ytdl_sub-*.whl && \
pip install --no-cache-dir --break-system-packages ytdl_sub-*.whl && \
ytdl-sub -h && \
# Delete unneeded packages after install
rm ytdl_sub-*.whl && \
@ -71,7 +72,6 @@ RUN mkdir -p /config && \
make \
xz-utils \
bzip2 \
python3.10-dev \
python3-venv && \
apt-get autoremove -y && \
apt-get purge -y --auto-remove && \
@ -81,6 +81,12 @@ RUN mkdir -p /config && \
# CONTAINER CONFIGS
ENV EDITOR="nano" \
HOME="/config"
HOME="/config" \
DOCKER_MODS=linuxserver/mods:universal-stdout-logs|linuxserver/mods:universal-cron \
DEFAULT_WORKSPACE=/config \
CRON_SCRIPT="/config/cron" \
CRON_WRAPPER_SCRIPT="/config/.cron_wrapper" \
LOGS_TO_STDOUT=/config/.cron.log \
LSIO_FIRST_PARTY=false
VOLUME /config

View file

@ -0,0 +1,68 @@
#!/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
# permissions
chown -R ${PUID:-abc}:${PGID:-abc} \
/config
# always create empty cron log file on start
echo "" > "$LOGS_TO_STDOUT"
# 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\" | tee -a \"$LOGS_TO_STDOUT\"" >> "$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

@ -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

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

View file

@ -6,8 +6,34 @@ services:
- PUID=1000
- PGID=1000
- TZ=America/Los_Angeles
- CRON_SCHEDULE="*/1 * * * *"
- CRON_RUN_ON_START=true
volumes:
- ./volumes/ytdl-sub-gui:/config
ports:
- 8443:8443
restart: unless-stopped
ytdl-sub:
image: ytdl-sub:local
container_name: ytdl-sub
environment:
- PUID=1000
- PGID=1000
- TZ=America/Los_Angeles
- CRON_SCHEDULE="*/1 * * * *"
- CRON_RUN_ON_START=true
volumes:
- ./volumes/ytdl-sub:/config
restart: unless-stopped
ytdl-sub-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
volumes:
- ./volumes/ytdl-sub:/config
restart: unless-stopped

View file

@ -6,7 +6,7 @@ This section contains direct references to the code of ``ytdl-sub`` and informat
.. toctree::
config_yaml
subscriptions_yaml
subscription_yaml
plugins
scripting/index
prebuilt_presets/index

View file

@ -151,6 +151,8 @@ granularity possible.
date_range:
before: "now"
after: "today-2weeks"
breaks: True
type: "upload_date"
``after``
@ -183,6 +185,14 @@ granularity possible.
is enabled or not via Boolean.
``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
@ -456,7 +466,7 @@ with a ``.nfo`` extension. You can add any values into the NFO.
``kodi_safe``
:expected type: Optional[Boolean]
: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
@ -554,7 +564,7 @@ Usage:
``kodi_safe``
:expected type: Optional[Boolean]
: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
@ -640,6 +650,8 @@ Defines where to output files and thumbnails after all post-processing has compl
maintain_download_archive: True
keep_files_before: now
keep_files_after: 19000101
keep_max_files: 1000
keep_files_date_eval: "{upload_date_standardized}"
``download_archive_name``
@ -690,6 +702,16 @@ Defines where to output files and thumbnails after all post-processing has compl
``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``
:expected type: Optional[OverridesFormatter]
@ -806,6 +828,82 @@ used with no modifications.
set to the entry's title and ``chapter_index``, ``chapter_count`` are both set to 1.
----------------------------------------------------------------------------------------------------
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``s 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 '□'.
``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
@ -891,6 +989,9 @@ scripted.
presets:
my_example_preset:
throttle_protection:
sleep_per_request_s:
min: 5.5
max: 10.4
sleep_per_download_s:
min: 2.2
max: 10.8
@ -926,6 +1027,16 @@ scripted.
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``
:expected type: Optional[Range]

View file

@ -659,3 +659,9 @@ ytdl_sub_input_url_index
:type: ``Integer``
:description:
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

@ -523,6 +523,38 @@ sub
----------------------------------------------------------------------------------------------------
Print Functions
---------------
print
~~~~~
:spec: ``print(message: AnyArgument, passthrough: ReturnableArgument, level: Optional[Integer]) -> ReturnableArgument``
:description:
Print the ``message`` and return ``passthrough``.
Optionally can pass level, where < 0 is debug, 0 is info, 1 is warning, > 1 is error.
Defaults to info.
print_if_false
~~~~~~~~~~~~~~
:spec: ``print_if_false(message: AnyArgument, passthrough: ReturnableArgument, level: Optional[Integer]) -> ReturnableArgument``
:description:
Print 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.
Defaults to info.
print_if_true
~~~~~~~~~~~~~
:spec: ``print_if_true(message: AnyArgument, passthrough: ReturnableArgument, level: Optional[Integer]) -> ReturnableArgument``
:description:
Print 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.
Defaults to info.
----------------------------------------------------------------------------------------------------
Regex Functions
---------------
@ -634,7 +666,7 @@ capitalize
concat
~~~~~~
:spec: ``concat(values: String, ...) -> String``
:spec: ``concat(values: AnyArgument, ...) -> String``
:description:
Concatenate multiple Strings into a single String.
@ -646,6 +678,13 @@ contains
:description:
Returns True if ``contains`` is in ``string``. False otherwise.
contains_all
~~~~~~~~~~~~
:spec: ``contains_all(string: String, contains_array: Array) -> Boolean``
:description:
Returns true if all elements in ``contains_array`` are in ``string``. False otherwise.
contains_any
~~~~~~~~~~~~
:spec: ``contains_any(string: String, contains_array: Array) -> Boolean``
@ -653,6 +692,24 @@ contains_any
:description:
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
~~~~~
:spec: ``lower(string: String) -> String``
@ -703,6 +760,23 @@ string
:description:
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
~~~~~~~~~
:spec: ``titlecase(string: String) -> String``

View file

@ -0,0 +1,121 @@
==================
Subscription File
==================
A subscription file is designed to both define and organize many things
to download in condensed YAML.
.. hint::
Read the :ref:`getting started guide <guides/getting_started/index:Getting Started>`
first before reviewing this section.
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>`
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
supply a cookies file path.
.. code-block:: yaml
__preset__:
overrides:
tv_show_directory: "/tv_shows"
ytdl_options:
cookiefile: "/config/cookie.txt"
Layout
------
A subscription file is comprised of YAML keys and values. Keys can be either
- a preset
- an override value
- a subscription name
Take the following example:
.. code-block:: yaml
Jellyfin TV Show by Date:
= News:
"Breaking News": "https://www.youtube.com/@SomeBreakingNews"
"BBC News": "https://www.youtube.com/@BBCNews"
All three types of keys are used for the following:
- ``Jellyfin TV Show by Date`` - a prebuilt preset
- ``= News`` - an override value for genre
- ``Breaking News``, ``BBC News`` - The subscription names
The bottom-most keys, or leaf keys, should always be the subscription name.
It is good practice to put subscription names in quotes to differentiate
between preset names and subscription names.
Values should always be the subscription itself. The simplest form is
just the URL. Further sections will show more exotic examples that go beyond
a single URL.
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``
preset and the ``= News`` override value.
.. note::
There are no limits or boundaries on how one structures
their presets. This flexibility is intended for subscription authors
to organize their downloads as they see fit.
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
example:
.. code-block:: yaml
Jellyfin TV Show by Date | = News:
"Breaking News": "https://www.youtube.com/@SomeBreakingNews"
"BBC News": "https://www.youtube.com/@BBCNews"
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
using tilda syntax, or ``~``.
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
set the date range to be different than the default ``2months`` value to
``2weeks``.
We can change it as follows:
.. code-block:: yaml
Jellyfin TV Show by Date
= News | Only Recent:
"Breaking News": "https://www.youtube.com/@SomeBreakingNews"
"~BBC News":
url: "https://www.youtube.com/@BBCNews"
only_recent_date_range: "2weeks"
.. important::
When using override mode, we need to set the ``url``
variable since we are no longer using the simplified
*subscription_value*. For more info on how this works,
read about :ref:`subscription variables <config_reference/scripting/static_variables:Subscription Variables>`.
Map Mode
--------
Map mode is for highly advanced presets that benefit
from a more complex subscription definition. TODO: Show music video
example here.

View file

@ -1,114 +0,0 @@
==================
Subscriptions File
==================
The ``subscriptions.yaml`` file is where we use :ref:`config_reference/config_yaml:presets`
to define a ``subscription``: something we want to recurrently download, such as a specific
channel or playlist.
The only difference between a ``subscription`` and ``preset`` is that the subscription
must have all required variables defined to perform a download.
Below is an example that downloads a YouTube playlist:
.. code-block:: yaml
:caption: config.yaml
presets:
playlist_preset_ex:
download: "{url}"
output_options:
output_directory: "{output_directory}/{playlist_name}"
file_name: "{playlist_name}.{title}.{ext}"
overrides:
output_directory: "/path/to/ytdl-sub-videos"
.. code-block:: yaml
:caption: subscription.yaml
my_subscription_name:
preset: "playlist_preset_ex"
overrides:
playlist_name: "diy-playlist"
url: "https://youtube.com/playlist?list=UCsvn_Po0SmunchJYtttWpOxMg"
Our preset ``playlist_preset_ex`` defines three
custom variables: ``{output_directory}``, ``{playlist_name}``, and ``{url}``. The subscription sets
the ``parent preset`` to ``playlist_preset_ex``, and must define the variables ``{playlist_name}``
and ``{url}`` since the preset did not.
Beautifying Subscriptions
-------------------------
Subscriptions support using presets as keys, and using keys to set override variables as values.
For example:
.. code-block:: yaml
:caption: subscription.yaml
TV Show Full Archive:
= News:
"Breaking News": "https://www.youtube.com/@SomeBreakingNews"
TV Show Only Recent:
= Tech | TV-Y:
"Two Minute Papers": "https://www.youtube.com/@TwoMinutePapers"
Will create two subscriptions named "Breaking News" and "Two Minute Papers", equivalent to:
.. code-block:: yaml
"Breaking News":
preset:
- "TV Show Full Archive"
overrides:
subscription_indent_1: "News"
subscription_name: "Breaking News"
subscription_value: "https://www.youtube.com/@SomeBreakingNews"
"Two Minute Papers":
preset:
- "TV Show Only Recent"
overrides:
subscription_indent_1: "Tech"
subscription_indent_2: "TV-Y"
subscription_name: "Two Minute Papers"
subscription_value: "https://www.youtube.com/@TwoMinutePapers"
You can provide as many parent presets in the form of ``keys``, and subscription indents as ``= keys``.
This can drastically simplify subscription definitions by setting things like so in your
parent preset:
.. code-block:: yaml
presets:
"TV Show Preset":
overrides:
subscription_indent_1: "default-genre"
subscription_indent_2: "default-content-rating"
tv_show_name: "{subscription_name}"
url: "{subscription_value}"
genre: "{subscription_indent_1}"
content_rating: "{subscription_indent_2}"
File Preset
-----------
You can apply a preset to all subscriptions in the ``subscription.yaml`` file
by using the file-wide ``__preset__``:
.. code-block:: yaml
:caption: subscription.yaml
__preset__:
preset: "playlist_preset_ex"
my_subscription_name:
overrides:
url: "https://youtube.com/playlist?list=UCsvn_Po0SmunchJYtttWpOxMg"
playlist_name: "diy-playlist"
This ``subscription.yaml`` is equivalent to the one above it because all
subscriptions automatically set ``__preset__`` as a ``parent preset``.

View file

@ -46,7 +46,7 @@ Oct 2023
subscription preset and value
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The use of ``__value__`` will go away in Dec 2023 in favor of the method found in
:ref:`config_reference/subscriptions_yaml:beautifying subscriptions`. ``__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
---------

View file

@ -62,6 +62,29 @@ See the prebuilt preset :doc:`chunk_initial_download </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
There is a bug where...
-----------------------

View file

@ -1,56 +0,0 @@
Advanced Configuration
======================
If the :doc:`prebuilt presets </prebuilt_presets/index>` aren't suitable for your needs, you may want to set up an advanced configuration.
Layout of a Config file
-----------------------
The layout of the ``config.yaml`` file is relatively straightforward:
.. code-block:: yaml
presets:
preset_name:
plugin1:
plugin1_option1: value1
This creates a preset named ``preset_name``, which contains the made-up
:doc:`plugin </config_reference/plugins>` ``plugin1``. Under each plugin are its settings.
A preset can contain multiple plugins.
Preset Inheritance
------------------
You can modularize your presets via preset inheritance. For example,
.. code-block:: yaml
presets:
TV Show:
preset:
- "Jellyfin TV Show by Date"
overrides:
tv_show_directory: "/ytdl_sub_tv_shows"
TV Show Only Recent:
preset:
- "TV Show"
- "Only Recent"
overrides:
only_recent_date_range: "3weeks"
This creates two presets:
* ``TV Show``
* Inherits the :doc:`prebuilt </prebuilt_presets/index>` ``Jellyfin TV Show by Date`` preset
* Sets the output tv show directory
* ``TV Show Only Recent``
* Inherits the ``TV Show`` preset made above it and the ``Only Recent`` prebuilt preset
* Sets only_recent preset to only keep the last 3 weeks worth of videos
Inheritance makes it easy to extend existing presets to include logic for your specific needs.

View file

@ -1,125 +1,43 @@
Automating Downloads
====================
One of the key capabilities of ``ytdl-sub`` is how well it runs without user input, but 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 Linux <guides/getting_started/automating_downloads:linux>`
: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 and Unraid
-----------------
.. tab-set::
.. tab-item:: GUI Image
The script that will execute automatically is located at ``/config/ytdl-sub-configs/run_cron``.
Cron is preconfigured in every ytdl-sub docker container. Enable by adding the following
ENV variables to your docker setup.
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.
.. 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.
.. _linux-setup:
Linux
-----
Must configure crontab manually, like so:
.. code-block:: shell

View file

@ -1,15 +0,0 @@
=====================
Using Example Configs
=====================
Copy and paste the examples into local yaml files, modify the
``working_directory`` and ``output_directory`` with your desired paths,
and perform a dry-run using
.. code-block:: bash
ytdl-sub \
--dry-run \
--config path/to/config.yaml \
sub path/to/subscriptions.yaml
This will simulate what a download will look like.

View file

@ -1,33 +1,203 @@
Basic Configuration
===================
Your first configuration will look pretty simple:
A configuration file serves two purposes:
1. Set advanced functionality that is not specifiable in a subscription file, such as working directory location. These
are set underneath ``configuration``.
2. Create custom presets, which can drastically simplify your subscription file. These are defined underneath ``presets``.
Presets are intended to be applicable and reusable across multiple subscriptions.
Below is a common configuration:
.. code-block:: yaml
:linenos:
configuration:
working_directory: '.ytdl-sub-downloads'
working_directory: '/mnt/ssd/.ytdl-sub-downloads'
presets:
TV Show:
preset:
- "Jellyfin TV Show by Date"
- "Only Recent"
- "Max 1080p"
embed_thumbnail: True
throttle_protection:
sleep_per_download_s:
min: 2.2
max: 10.8
sleep_per_subscription_s:
min: 9.0
max: 14.1
max_downloads_per_subscription:
min: 10
max: 36
overrides:
tv_show_directory: "/ytdl_sub_tv_shows"
TV Show Only Recent:
preset:
- "TV Show"
- "Only Recent"
The first two lines in this ``config.yaml`` file are the ``configuration``, and define the ``working_directory``, which is described near the bottom of :ref:`this section <guides/getting_started/index:quick overview of \`\`ytdl-sub\`\`>`
Configuration Section
---------------------
The :ref:`configuration <config_reference/config_yaml:Configuration File>` section sets options for ytdl-sub execution.
.. code-block:: yaml
:lineno-start: 1
configuration:
working_directory: '/mnt/ssd/.ytdl-sub-downloads'
Preset Section
--------------
Underneath ``presets``, we define two custom presets with the names ``TV Show`` and ``TV Show Only Recent``.
.. code-block:: yaml
presets:
TV Show:
...
TV Show Only Recent:
...
The indentation example above shows how to define multiple presets.
Custom Preset Definition
------------------------
Before we break down the above ``TV Show`` preset, lets first outline a preset layout:
.. code-block:: yaml
Preset Name:
preset:
...
plugin(s):
...
overrides:
...
Presets can contain three important things:
1. ``preset`` section, which can inherit :ref:`prebuilt presets <config_reference/prebuilt_presets/index:Prebuilt Preset Reference>`
or other presets defined in your config.
2. :ref:`Plugin definitions <config_reference/plugins:Plugins>`
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.
Inheriting Presets
~~~~~~~~~~~~~~~~~~
.. code-block:: yaml
:lineno-start: 5
TV Show:
preset:
- "Jellyfin TV Show by Date"
- "Max 1080p"
The following snippet shows that the ``TV Show`` preset will inherit all properties
of 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.
It is highly advisable to use :ref:`prebuilt presets <config_reference/prebuilt_presets/index:Prebuilt Preset Reference>` as
a starting point 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
~~~~~~~~~~~~~~~~
.. code-block:: yaml
:lineno-start: 10
embed_thumbnail: True
throttle_protection:
sleep_per_download_s:
min: 2.2
max: 10.8
sleep_per_subscription_s:
min: 9.0
max: 14.1
max_downloads_per_subscription:
min: 10
max: 36
Our ``TV Show`` sets two plugins, :ref:`throttle_protection <config_reference/plugins:throttle_protection>` and
:ref:`embed_thumbnail <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
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
Line 4 begins the definition of your custom ``presets``, with line 5 being the name of your first custom ``preset``.
Setting Override Variables
~~~~~~~~~~~~~~~~~~~~~~~~~~
Lines 7 and 8 tell ``ytdl-sub`` which :doc:`/prebuilt_presets/index` to expand on; these ``presets`` already indicate that the downloaded files should be:
.. code-block:: yaml
:lineno-start: 23
- in a format usable by, and with metadata accessible to, Jellyfin
- sorted by upload date, and
- only uploaded in the last 2 months (and will also delete any files in the media library which were uploaded over 2 months ago)
overrides:
tv_show_directory: "/ytdl_sub_tv_shows"
Line 11 is an override variable, ``tv_show_directory``, that tells ``ytdl-sub`` where to save your downloaded files once they've been processed, also known as the ``output_directory``. In this case, the downloaded files will be saved to the ``youtube`` folder in the root ``tv_shows`` directory.
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.
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
2. Use them as placeholders to perform other logic, then have subscriptions or child presets
define their specific value
For simplicity, we'll focus on (1) for now. The above snippet sets the ``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>`
to see all available variables that are overridable.
Using Custom Presets in Subscriptions
--------------------------------------
Subscription files can use custom presets just like any other prebuilt preset.
Below shows a complete subscription file using the above two custom presets.
.. code-block:: yaml
TV Show:
= Documentaries:
"NOVA PBS": "https://www.youtube.com/@novapbs"
= Kids | = TV-Y:
"Jake Trains": "https://www.youtube.com/@JakeTrains"
TV Show Only Recent:
= News:
"BBC News": "https://www.youtube.com/@BBCNews"
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.
Reference Custom Config in the CLI
----------------------------------
Be sure to tell ytdl-sub to use your config by using the argument
``--config /path/to/config.yaml``.
If you run ytdl-sub in the same directory, and the config file is named ``config.yaml``, it will
use it by default.

View file

@ -1,32 +1,50 @@
Initial Download
================
Once you have a ``subscriptions.yaml`` file created and filled out, you can perform your first
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, then run the below command:
file.
.. tab-set::
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.
.. tab-item:: Dry run
.. code-block:: shell
A dry run lets you check that your configuration doesn't throw any errors and what the expected output files of actually doing the download are, without actually downloading the full media.
ytdl-sub --dry-run sub subscriptions.yaml
.. code-block:: shell
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.
ytdl-sub --dry-run sub
.. code-block:: shell
.. tab-item:: Normal run
ytdl-sub --dry-run sub subscriptions.yaml -o '--ytdl_options.max_downloads 3'
A normal run will download all files as determined by your ``presets`` and, once processing is finished, move the downloaded and processed files to your ``output_directory``.
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
.. code-block:: shell
:caption: Only run subscriptions that have PBS in their names
ytdl-sub sub
ytdl-sub --dry-run sub subscriptions.yaml -o '--ytdl_options.max_downloads 3' --match PBS
.. tab-item:: One-time download
Downloading
-----------
Once the subscriptions file is validated, a download can be performed by omitting the dry run argument.
Sometimes you may only want to download media once, in which case adding them to your ``subscriptions.yaml`` file is unneccessary. As an example, the below code will download the same videos as our subscription file:
.. code-block:: shell
.. code-block:: shell
ytdl-sub dl --preset "Jellyfin TV Show by Date" --overrides.subscription_name "NOVA PBS" --overrides.subscription_value "https://www.youtube.com/@novapbs" --overrides.tv_show_genre "Documentaries"
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,7 +1,7 @@
Initial Subscription
====================
Your first subscription should look something like this:
Your first subscription file should look something like this:
.. code-block:: yaml
:linenos:
@ -31,7 +31,7 @@ Your first subscription should look something like this:
= Lofi:
"Game Chops": "https://www.youtube.com/playlist?list=PLBsm_SagFMmdWnCnrNtLjA9kzfrRkto4i"
Lets break this down:
Let's break this down:
.. code-block:: yaml
:lineno-start: 1
@ -42,9 +42,18 @@ Lets break this down:
music_directory: "/music"
The first :ref:`__preset__ <config_reference/subscriptions_yaml:File Preset>` section is where we
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
@ -73,6 +82,8 @@ subscriptions to look like TV shows in the Jellyfin media player (can be changed
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
@ -82,17 +93,13 @@ subscriptions underneath it will *inherit* this preset.
= Documentaries:
Line 12 sets the key to ``= Documentaries``. When keys are prefixed with ``=``, it means we are
setting the
:ref:`subscription indent variable <config_reference/subscriptions_yaml:Beautifying Subscriptions>`.
For TV Show presets, the first subscription indent variable maps to the TV show's genre.
Setting subscription indent variables as a key implies all subscriptions underneath it will
have this variable set.
setting the genre. This value will get written to the respective metadata tags for both TV show
and music presets.
To better understand what variables are used in prebuilt presets, refer to the
:ref:`prebuilt preset reference <config_reference/prebuilt_presets/index:Prebuilt Preset Reference>`.
Here you will see the underlying variables used in prebuilt presets that can be overwritten.
We already overwrote a few of the variables in the ``__preset__`` section above to define our
output directory.
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>`.
-------------------------------------
@ -104,8 +111,10 @@ output directory.
"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``. Referring to the
:ref:`TV show preset reference <config_reference/prebuilt_presets/tv_show:TV Show>`,
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.
-------------------------------------

View file

@ -1,25 +1,15 @@
Getting Started
===============
Now that you've completed your install of ``ytdl-sub``, it's time to get started. This is a 3-step process:
- Create your configuration file (if the :doc:`/prebuilt_presets/index` don't fit your needs)
- Create your subscription file
- Automate starting YTDL-Sub
Prerequisite Knowledge
----------------------
.. _navigate directories: https://en.wikipedia.org/wiki/Cd_(command)
.. _YAML syntax: https://yaml.org/spec/1.2.2/#chapter-2-language-overview
In order to use ``ytdl-sub`` in any of the forms listed in these docs, you will need some basic knowledge.
Be sure that you:
☑ Can `navigate directories`_ in a command line interface (or CLI)
☑ Can navigate directories in a command line interface (or CLI)
☑ Have a basic understanding of `YAML syntax`_
☑ Have a basic understanding of YAML syntax
If you plan on using the headless image of ``ytdl-sub``, you:
☑ Can use ``nano`` or ``vim`` to edit OR
@ -29,76 +19,39 @@ If you plan on using the headless image of ``ytdl-sub``, you:
Additional useful (but not required) knowledge:
☑ Understanding how :yt-dlp:`\ ` works
Overview
--------
``ytdl-sub`` uses two types of YAML files:
Terminology
-----------
subscriptions.yaml
~~~~~~~~~~~~~~~~~~
Defines ``subscriptions``, which specify the media we want to recurrently download, like YouTube
channels and playlists, SoundCloud artists, or any
:yt-dlp:`yt-dlp supported site <blob/master/supportedsites.md>`. ``subscriptions`` use ``presets``
to define how ``ytdl-sub`` should handle downloading, processing, and saving them.
Must-know terminology:
``ytdl-sub`` comes packaged with many
:ref:`prebuilt presets <prebuilt_presets/index:Prebuilt Presets>`
that will play nicely with well-known media players.
- ``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.
config.yaml
~~~~~~~~~~~
To customize ``ytdl-sub`` to beyond the prebuilt presets, you will need a ``config.yaml`` file. This
file is where custom ``presets`` can be defined to orchestrate ``ytdl-sub`` to your very specific needs.
Intermediate terminology:
Running ytdl-sub
~~~~~~~~~~~~~~~~
To invoke ``ytdl-sub`` to download subscriptions, use the following command:
- ``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.
.. tab-set-code::
Advanced terminology:
.. code-block:: shell
ytdl-sub sub subscriptions.yaml
.. code-block:: powershell
ytdl-sub.exe sub subscriptions.yaml
``ytdl-sub`` initially downloads all files to a defined ``working_directory``. This is a temporary
storage spot for metadata and media files so that errors during processing- if they occur- don't
affect your existing media library. Once all file processing is complete, your media files are
moved to the ``output_directory``.
- ``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.
Ready to Start?
---------------
Now that you have installed ``ytdl-sub``, checked your skills, and gotten a bit of background on how ``ytdl-sub`` functions, read through the articles below to get started:
:doc:`Step 1: Initial Subscriptions <first_sub>`
:doc:`Step 2: Your First Download <first_download>`
:doc:`Step 3: Automating Downloads <automating_downloads>`
Want to go a step further?
If you want to use atypical paths or specific configuration options, check out :doc:`Basic Configuration <first_config>`
For tips on creating your own presets when the prebuilt presets aren't cutting it, check out :doc:`Advanced Configuration <advanced_configuration>`
Other docs that may be of use:
:doc:`/prebuilt_presets/index`
:doc:`examples`
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::
:hidden:
:caption: Getting Started Guide
:maxdepth: 1
:maxdepth: 2
first_sub
first_download
automating_downloads
first_config
advanced_configuration
examples

View file

@ -79,7 +79,6 @@ Docker Compose is an easy "set it and forget it" install method. Follow the inst
- 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

View file

@ -4,21 +4,7 @@ Helper Presets
.. hint::
Apply presets to your subscriptions using pipes:
.. code-block:: yaml
:caption: Pipes can separate presets and values to apply them to all subscriptions below them.
Plex TV Show by Date | best_video_quality:
= Documentaries | chunk_initial_download:
"NOVA PBS": "https://www.youtube.com/@novapbs"
"National Geographic": "https://www.youtube.com/@NatGeo"
= Documentaries:
"Cosmos - What If": "https://www.youtube.com/playlist?list=PLZdXRHYAVxTJno6oFF9nLGuwXNGYHmE8U"
Common presets are not usable by themselves- setting one of these as the sole preset of your subscription and attempting to download will not work. But you can add these presets to quickly modify an existing preset to better suit your needs.
See how to apply helper presets :doc:`here </prebuilt_presets/index>`
Only Recent
-----------
@ -42,32 +28,19 @@ upload date is outside of the range, or you hit max files, older videos will be
To prevent deletion of files, use the preset ``Only Recent Archive`` instead.
Best A/V Quality
----------------
Add the following preset to download the best available video and audio quality, and remux it into an MP4 container:
``best_video_quality``
Max 1080p Video
---------------
Add the following preset to download the best available audio and video quality, with the video not greater than 1080p, and remux it into an MP4 container:
``max_1080p``
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.
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:
* ``title_include_keywords``
* ``title_exclude_keywords``
* ``description_include_keywords``
* ``description_exclude_keywords``
* ``title_include_keywords``, ``title_include_eval``
* ``title_exclude_keywords``, ``title_exclude_eval``
* ``description_include_keywords``, ``title_exclude_eval``
* ``description_exclude_keywords``, ``title_exclude_eval``
.. tip::
@ -90,6 +63,13 @@ Supports the following override variables:
title_include_keywords:
- "To Catch a Smuggler"
= Sports:
"~Maple Leafs Highlights":
url: "https://www.youtube.com/@NHL"
title_include_eval: "ALL"
title_include_keywords:
- "maple leafs"
- "highlights"
Chunk Downloads
---------------

View file

@ -3,13 +3,34 @@ Prebuilt Presets
================
``ytdl-sub`` offers a number of built-in presets using best practices for formatting
media in various players. For advanced users, you can review the prebuilt preset
media in various players.
.. hint::
Apply multiple presets to your subscriptions using pipes. Pipes can define multiple presets and values
on the same line to apply to all subscriptions nested below them.
.. code-block:: yaml
:caption: Applies Max Video Quality preset to all TV shows, and Chunk Downloads preset to some
Plex TV Show by Date | Max Video Quality:
= Documentaries | Chunk Downloads:
"NOVA PBS": "https://www.youtube.com/@novapbs"
"National Geographic": "https://www.youtube.com/@NatGeo"
= Documentaries:
"Cosmos - What If": "https://www.youtube.com/playlist?list=PLZdXRHYAVxTJno6oFF9nLGuwXNGYHmE8U"
For advanced users, you can review the prebuilt preset
definitions :doc:`here </config_reference/prebuilt_presets/index>`.
.. toctree::
:titlesonly:
helpers
tv_shows
music
music_videos
music_videos
media_quality
helpers

View file

@ -0,0 +1,29 @@
======================
Media Quality Presets
======================
.. hint::
See how to apply media quality presets :doc:`here </prebuilt_presets/index>`
Video
-----
The following presets set video quality specifications to yt-dlp.
- ``Max Video Quality``
- ``Max 2160p``
- ``Max 1440p``
- ``Max 1080p``
- ``Max 720p``
- ``Max 480p``
Audio
-----
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 MP3 Quality``
- ``Max Opus Quality``
- ``MP3 320k``
- ``MP3 128k``

View file

@ -9,16 +9,22 @@ Player-Specific Presets
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
--------
* Places any season-specific poster art in the main show folder
* Generates NFO tags
Kodi
--------
* Everything that the Jellyfin version does
* Enables ``kodi_safe`` NFOs, replacing 4-byte unicode characters that break kodi with ````
Emby
----
* 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
--------
@ -28,23 +34,19 @@ Plex
----------------------------------------------
Generic Presets
===============
There are two main methods for downloading and formatting videos as a TV show.
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.
Example
~~~~~~~
-------
Must define ``tv_show_directory``. Available presets:
* ``"Kodi TV Show by Date"``
* ``"Jellyfin TV Show by Date"``
* ``"Plex TV Show by Date"``
* ``Kodi TV Show by Date``
* ``Jellyfin TV Show by Date``
* ``Emby TV Show by Date``
* ``Plex TV Show by Date``
.. code-block:: yaml
@ -72,30 +74,68 @@ Must define ``tv_show_directory``. Available presets:
- "https://www.youtube.com/@rickbeato240"
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``
* ``jellyfin_tv_show_by_date``
* ``plex_tv_show_by_date``
__preset__:
overrides:
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``
* ``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.
.. code-block:: yaml
"~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 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
@ -109,12 +149,13 @@ Two main use cases of a collection are:
represents a separate channel/playlist.
Example
~~~~~~~
-------
Must define ``tv_show_directory``. Available presets:
* ``"Kodi TV Show Collection"``
* ``"Jellyfin TV Show Collection"``
* ``"Plex TV Show Collection"``
* ``Kodi TV Show Collection``
* ``Jellyfin TV Show Collection``
* ``Emby TV Show Collection``
* ``Plex TV Show Collection``
.. code-block:: yaml
@ -132,21 +173,44 @@ Must define ``tv_show_directory``. Available presets:
s02_url: "https://www.youtube.com/playlist?list=PLE62gWlWZk5NWVAVuf0Lm9jdv_-_KXs0W"
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``
* ``jellyfin_tv_show_collection``
* ``plex_tv_show_collection``
__preset__:
overrides:
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``
* ``season_by_collection__episode_by_year_month_day_reversed``
* ``season_by_collection__episode_by_playlist_index``
* Only use playlist_index episode formatting for playlists that will be fully downloaded once and never again. Otherwise, indices can change.
* ``season_by_collection__episode_by_playlist_index_reversed``
.. code-block:: yaml
"~Beyond the Guitar":
tv_show_collection_episode_ordering: "release-year-month-day"
s01_name: "Videos"
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

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

View file

@ -15,7 +15,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
]
dependencies = [
"yt-dlp[default]==2024.11.4",
"yt-dlp[default]==2025.5.22",
"colorama~=0.4",
"mergedeep~=1.3",
"mediafile~=0.12",
@ -44,12 +44,12 @@ where = ["src"]
test = [
"coverage[toml]>=6.3,<8.0",
"pytest>=7.2,<9.0",
"pytest-rerunfailures~=14.0",
"pytest-rerunfailures>=14,<16",
]
lint = [
"black==24.10.0",
"isort==5.13.2",
"pylint==3.3.1",
"isort==6.0.1",
"pylint==3.3.7",
]
docs = [
"sphinx>=7,<9",
@ -60,7 +60,7 @@ docs = [
]
build = [
"build~=1.2",
"twine~=5.0",
"twine>=5,<7",
"pyinstaller~=6.5",
]
[project.scripts]

View file

@ -271,6 +271,6 @@ def main() -> List[Subscription]:
transaction_log_file_path=args.transaction_log,
)
output_summary(subscriptions)
output_summary(subscriptions, suppress_colors=args.suppress_colors)
return subscriptions

View file

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

View file

@ -44,6 +44,7 @@ class MainArguments:
short="-m",
long="--match",
)
SUPPRESS_COLORS = CLIArgument(short="-nc", long="--suppress-colors")
@classmethod
def all(cls) -> List[CLIArgument]:
@ -59,6 +60,7 @@ class MainArguments:
cls.TRANSACTION_LOG,
cls.SUPPRESS_TRANSACTION_LOG,
cls.MATCH,
cls.SUPPRESS_COLORS,
]
@classmethod
@ -129,6 +131,13 @@ def _add_shared_arguments(arg_parser: argparse.ArgumentParser, suppress_defaults
help="do not output transaction logs to console or file",
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(
MainArguments.MATCH.short,
MainArguments.MATCH.long,

View file

@ -221,3 +221,12 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
return self._apply_to_resolvable(
formatter=formatter, entry=None, function_overrides=None
).native
def evaluate_boolean(
self, formatter: StringFormatterValidator, entry: Optional[Entry] = None
) -> bool:
"""
Apply a formatter, and evaluate it to a boolean
"""
output = self.apply_formatter(formatter=formatter, entry=entry)
return ScriptUtils.bool_formatter_output(output)

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.entries.entry import Entry
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 EnhancedDownloadArchive
@ -49,9 +48,7 @@ class Plugin(BasePlugin[OptionsValidatorT], Generic[OptionsValidatorT], ABC):
Returns True if enabled, False if disabled.
"""
if isinstance(self.plugin_options, ToggleableOptionsDictValidator):
return ScriptUtils.bool_formatter_output(
self.overrides.apply_formatter(self.plugin_options.enable)
)
return self.overrides.evaluate_boolean(self.plugin_options.enable)
return True
def ytdl_options_match_filters(self) -> Tuple[List[str], List[str]]:

View file

@ -24,6 +24,7 @@ from ytdl_sub.plugins.music_tags import MusicTagsPlugin
from ytdl_sub.plugins.nfo_tags import NfoTagsPlugin
from ytdl_sub.plugins.output_directory_nfo_tags import OutputDirectoryNfoTagsPlugin
from ytdl_sub.plugins.split_by_chapters import SplitByChaptersPlugin
from ytdl_sub.plugins.static_nfo_tags import StaticNfoTagsPlugin
from ytdl_sub.plugins.subtitles import SubtitlesPlugin
from ytdl_sub.plugins.throttle_protection import ThrottleProtectionPlugin
from ytdl_sub.plugins.video_tags import VideoTagsPlugin
@ -46,6 +47,7 @@ class PluginMapping:
"video_tags": VideoTagsPlugin,
"nfo_tags": NfoTagsPlugin,
"output_directory_nfo_tags": OutputDirectoryNfoTagsPlugin,
"static_nfo_tags": StaticNfoTagsPlugin,
"subtitles": SubtitlesPlugin,
"chapters": ChaptersPlugin,
"split_by_chapters": SplitByChaptersPlugin,
@ -83,6 +85,7 @@ class PluginMapping:
MusicTagsPlugin,
VideoTagsPlugin,
NfoTagsPlugin,
StaticNfoTagsPlugin,
EmbedThumbnailPlugin,
]

View file

@ -1,15 +1,19 @@
from typing import Any
from typing import Dict
from typing import Optional
from typing import Set
from ytdl_sub.config.defaults import DEFAULT_DOWNLOAD_ARCHIVE_NAME
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.validators.file_path_validators import OverridesStringFormatterFilePathValidator
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_formatter_validators import OverridesIntegerFormatterValidator
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 (
UnstructuredOverridesDictFormatterValidator,
@ -65,7 +69,7 @@ class YTDLOptions(UnstructuredOverridesDictFormatterValidator):
# pylint: disable=line-too-long
class OutputOptions(StrictDictValidator):
class OutputOptions(OptionsDictValidator):
"""
Defines where to output files and thumbnails after all post-processing has completed.
@ -87,6 +91,8 @@ class OutputOptions(StrictDictValidator):
maintain_download_archive: True
keep_files_before: now
keep_files_after: 19000101
keep_max_files: 1000
keep_files_date_eval: "{upload_date_standardized}"
"""
_required_keys = {"output_directory", "file_name"}
@ -99,6 +105,8 @@ class OutputOptions(StrictDictValidator):
"keep_files_before",
"keep_files_after",
"keep_max_files",
"download_archive_standardized_date",
"keep_files_date_eval",
}
@classmethod
@ -156,6 +164,11 @@ class OutputOptions(StrictDictValidator):
self._keep_max_files = self._validate_key_if_present(
"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}}}",
)
if (
self._keep_files_before or self._keep_files_after or self._keep_max_files
@ -272,6 +285,18 @@ class OutputOptions(StrictDictValidator):
"""
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
def keep_max_files(self) -> Optional[OverridesIntegerFormatterValidator]:
"""
@ -283,3 +308,10 @@ class OutputOptions(StrictDictValidator):
applied. Can be used in conjunction with ``keep_files_before`` and ``keep_files_after``.
"""
return self._keep_max_files
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

@ -57,13 +57,14 @@ def _add_dummy_overrides(overrides: Overrides) -> Dict[str, str]:
def _get_added_and_modified_variables(
plugins: PresetPlugins, downloader_options: MultiUrlValidator
plugins: PresetPlugins, downloader_options: MultiUrlValidator, output_options: OutputOptions
) -> 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)
options.append(output_options)
for plugin_options in options:
added_variables: Set[str] = set()
@ -117,6 +118,7 @@ class VariableValidation:
) in _get_added_and_modified_variables(
plugins=self.plugins,
downloader_options=self.downloader_options,
output_options=self.output_options,
):
for added_variable in added_variables:
@ -183,6 +185,9 @@ class VariableValidation:
self._add_variables(PluginOperation.DOWNLOADER, options=self.downloader_options)
self._add_subscription_override_variables()
# Always add output options first
self._add_variables(PluginOperation.MODIFY_ENTRY_METADATA, options=self.output_options)
# Metadata variables to be added
for plugin_options in PluginMapping.order_options_by(
self.plugins.zipped(), PluginOperation.MODIFY_ENTRY_METADATA

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.utils.file_handler import FileHandler
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 download_and_convert_url_thumbnail
from ytdl_sub.utils.thumbnail import try_convert_download_thumbnail
@ -459,11 +458,9 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
metadata_ytdl_options = self.metadata_ytdl_options(
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 = ScriptUtils.bool_formatter_output(
self.overrides.apply_formatter(validator.include_sibling_metadata)
download_reversed = self.overrides.evaluate_boolean(validator.download_reverse)
include_sibling_metadata = self.overrides.evaluate_boolean(
validator.include_sibling_metadata
)
parents, orphan_entries = self._download_url_metadata(
@ -534,8 +531,8 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
download_logger.info("Entry rejected by download match-filter, skipping ..")
return None
upload_date_idx = self._enhanced_download_archive.mapping.get_num_entries_with_upload_date(
upload_date_standardized=entry.get(v.upload_date_standardized, str)
upload_date_idx = self._enhanced_download_archive.mapping.get_num_entries_with_date(
standardized_date=entry.get(v.ytdl_sub_keep_files_date_eval, str)
)
download_idx = self._enhanced_download_archive.num_entries

View file

@ -823,6 +823,17 @@ class YtdlSubVariableDefinitions(ABC):
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):
@cached_property
@ -1121,6 +1132,7 @@ class VariableDefinitions(
self.ytdl_sub_input_url,
self.ytdl_sub_input_url_index,
self.ytdl_sub_input_url_count,
self.ytdl_sub_keep_files_date_eval,
}
@cache

View file

@ -1,13 +1,18 @@
from typing import List
from typing import Optional
from typing import Set
from typing import Tuple
from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator
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_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):
@ -31,9 +36,11 @@ class DateRangeOptions(ToggleableOptionsDictValidator):
date_range:
before: "now"
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):
super().__init__(name, value)
@ -42,6 +49,7 @@ class DateRangeOptions(ToggleableOptionsDictValidator):
self._breaks = self._validate_key_if_present(
"breaks", OverridesBooleanFormatterValidator, default="True"
)
self._type = self._validate_key("type", DateRangeType, default="upload_date")
@property
def before(self) -> Optional[StringDatetimeValidator]:
@ -71,6 +79,16 @@ class DateRangeOptions(ToggleableOptionsDictValidator):
"""
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]):
plugin_options_type = DateRangeOptions
@ -84,20 +102,19 @@ class DateRangePlugin(Plugin[DateRangeOptions]):
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:
before_str = to_date_str(
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:
after_str = to_date_str(
date_validator=self.plugin_options.after, overrides=self.overrides
)
after_filter = f"upload_date >= {after_str}"
if ScriptUtils.bool_formatter_output(
self.overrides.apply_formatter(self.plugin_options.breaks)
):
after_filter = f"{date_type} >= {after_str}"
if self.overrides.evaluate_boolean(self.plugin_options.breaks):
breaking_match_filters.append(after_filter)
else:
match_filters.append(after_filter)

View file

@ -7,7 +7,6 @@ from ytdl_sub.config.validators.options import OptionsValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.exceptions import StringFormattingException
from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.script import ScriptUtils
from ytdl_sub.validators.string_formatter_validators import ListFormatterValidator
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
@ -53,10 +52,9 @@ class FilterExcludePlugin(Plugin[FilterExcludeOptions]):
return entry
for formatter in self.plugin_options.list:
out = ScriptUtils.bool_formatter_output(
self.overrides.apply_formatter(formatter=formatter, entry=entry)
)
if bool(out):
should_exclude = self.overrides.evaluate_boolean(formatter=formatter, entry=entry)
if should_exclude:
logger.info(
"Filtering '%s' from the filter %s evaluating to True",
entry.title,

View file

@ -61,10 +61,10 @@ class FilterIncludePlugin(Plugin[FilterIncludeOptions]):
return entry
for formatter in self.plugin_options.list:
out = ScriptUtils.bool_formatter_output(
should_exclude = ScriptUtils.bool_formatter_output(
self.overrides.apply_formatter(formatter=formatter, entry=entry)
)
if not bool(out):
if not should_exclude:
logger.info(
"Filtering '%s' from the filter %s evaluating to False",
entry.title,

View file

@ -5,7 +5,6 @@ from pathlib import Path
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from ytdl_sub.config.plugin.plugin import Plugin
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.nfo_validators import NfoTagsValidator
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.validators import BoolValidator
class SharedNfoTagsOptions(ToggleableOptionsDictValidator):
@ -54,8 +53,8 @@ class SharedNfoTagsOptions(ToggleableOptionsDictValidator):
)
self._tags = self._validate_key_if_present(key="tags", validator=NfoTagsValidator)
self._kodi_safe = self._validate_key_if_present(
key="kodi_safe", validator=BoolValidator, default=False
).value
key="kodi_safe", validator=OverridesBooleanFormatterValidator, default="False"
)
@property
def nfo_name(self) -> StringFormatterFileNameValidator:
@ -81,9 +80,9 @@ class SharedNfoTagsOptions(ToggleableOptionsDictValidator):
return self._tags
@property
def kodi_safe(self) -> Optional[bool]:
def kodi_safe(self) -> OverridesBooleanFormatterValidator:
"""
:expected type: Optional[Boolean]
: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
@ -141,7 +140,7 @@ class SharedNfoTagsPlugin(Plugin[SharedNfoTagsOptions], ABC):
if not nfo_tags:
return
if self.plugin_options.kodi_safe:
if self.overrides.evaluate_boolean(self.plugin_options.kodi_safe):
nfo_root = to_max_3_byte_utf8_string(nfo_root)
nfo_tags = {
to_max_3_byte_utf8_string(key): [

View file

@ -0,0 +1,70 @@
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``s 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

@ -146,7 +146,12 @@ class SubtitlesPlugin(Plugin[SubtitleOptions]):
plugin_options_type = SubtitleOptions
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:
builder.add(

View file

@ -1,5 +1,6 @@
import random
import time
from typing import Dict
from typing import Optional
from ytdl_sub.config.overrides import Overrides
@ -40,6 +41,22 @@ class RandomizedRangeValidator(StrictDictValidator):
f"max ({self._max}) must be greater than or equal to min ({self._min})"
)
def min_value(self) -> float:
"""
Returns
-------
Minimum value
"""
return self._min
def max_value(self) -> float:
"""
Returns
-------
Maximum value
"""
return self._max
def randomized_float(self) -> float:
"""
Returns
@ -70,6 +87,9 @@ class ThrottleProtectionOptions(ToggleableOptionsDictValidator):
presets:
my_example_preset:
throttle_protection:
sleep_per_request_s:
min: 5.5
max: 10.4
sleep_per_download_s:
min: 2.2
max: 10.8
@ -84,6 +104,7 @@ class ThrottleProtectionOptions(ToggleableOptionsDictValidator):
_optional_keys = {
"enable",
"sleep_per_request_s",
"sleep_per_download_s",
"sleep_per_subscription_s",
"max_downloads_per_subscription",
@ -93,6 +114,9 @@ class ThrottleProtectionOptions(ToggleableOptionsDictValidator):
def __init__(self, name, value):
super().__init__(name, value)
self._sleep_per_request_s = self._validate_key_if_present(
key="sleep_per_request_s", validator=RandomizedRangeValidator
)
self._sleep_per_download_s = self._validate_key_if_present(
key="sleep_per_download_s", validator=RandomizedRangeValidator
)
@ -106,6 +130,18 @@ class ThrottleProtectionOptions(ToggleableOptionsDictValidator):
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
def sleep_per_download_s(self) -> Optional[RandomizedRangeValidator]:
"""
@ -165,6 +201,11 @@ class ThrottleProtectionPlugin(Plugin[ThrottleProtectionOptions]):
self.plugin_options.max_downloads_per_subscription.randomized_int()
)
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()}
return {}
def initialize_subscription(self) -> bool:
if self.plugin_options.subscription_download_probability:
proba = self.plugin_options.subscription_download_probability.value

View file

@ -12,6 +12,11 @@ presets:
description_include_keywords: "{ [] }"
description_exclude_keywords: "{ [] }"
title_include_eval: "ANY"
title_exclude_eval: "ANY"
description_include_eval: "ANY"
description_exclude_eval: "ANY"
"%ensure_string": >-
{
%assert_then(
@ -32,21 +37,36 @@ presets:
)
}
# $0 - var to evaluate
# $1 - keyword list
# $2 - eval type
"%contains_keywords_inner": >-
{
%elif(
%eq(%ensure_string($2), 'any'),
%contains_any( $0, $1 ),
%eq(%ensure_string($2), 'all'),
%contains_all( $0, $1 ),
%throw('Keyword eval must be either ANY or ALL')
)
}
# $0 - var to evaluate
# $1 - keyword list
# $2 - variable name for error messages
# $3 - default return if keyword list is empty
# $3 - keyword eval
# $4 - default return if keyword list is empty
"%contains_keywords": >-
{
%if(
%bool( $1 ),
%contains_any( %lower($0), %ensure_lower_array($1, $2) ),
$3
%contains_keywords_inner( %lower($0), %ensure_lower_array($1, $2), $3 ),
$4
)
}
filter_exclude:
- "{ %not( %contains_keywords(title, title_include_keywords, 'title_include_keywords', true) ) }"
- "{ %not( %contains_keywords(description, description_include_keywords, 'description_include_keywords', true) ) }"
- "{ %contains_keywords(title, title_exclude_keywords, 'title_exclude_keywords', false) }"
- "{ %contains_keywords(description, description_exclude_keywords, 'description_exclude_keywords',false) }"
- "{ %not( %contains_keywords(title, title_include_keywords, 'title_include_keywords', title_include_eval, true) ) }"
- "{ %not( %contains_keywords(description, description_include_keywords, 'description_include_keywords', description_include_eval, true) ) }"
- "{ %contains_keywords(title, title_exclude_keywords, 'title_exclude_keywords', title_exclude_eval, false) }"
- "{ %contains_keywords(description, description_exclude_keywords, 'description_exclude_keywords', description_exclude_eval, false) }"

View file

@ -13,6 +13,10 @@ presets:
preset:
- best_video_quality
"Max Video Quality":
preset:
- best_video_quality
#############################################################################
# Max 2160p
@ -55,4 +59,32 @@ presets:
"Max 480p":
format: "(bv*[height<=480]+bestaudio/best[height<=480])"
ytdl_options:
merge_output_format: "mp4"
merge_output_format: "mp4"
#############################################################################
# Audio Quality Presets
"Max Audio Quality":
audio_extract:
codec: "best"
quality: 0
"Max MP3 Quality":
audio_extract:
codec: "mp3"
quality: 0
"Max Opus Quality":
audio_extract:
codec: "opus"
quality: 0
"MP3 320k":
audio_extract:
codec: "mp3"
quality: 320
"MP3 128k":
audio_extract:
codec: "mp3"
quality: 128

View file

@ -8,6 +8,7 @@ class MusicVideoPresets(PrebuiltPresets):
preset_names = {
"Kodi Music Videos",
"Jellyfin Music Videos",
"Emby Music Videos",
"Plex Music Videos",
}

View file

@ -7,6 +7,9 @@ presets:
- "_music_video_nfo"
- "_music_video_tags"
"Emby Music Videos":
preset: "Jellyfin Music Videos"
"Kodi Music Videos":
preset:
- "_kodi_base"

View file

@ -8,64 +8,15 @@ class TvShowByDatePresets(PrebuiltPresets):
preset_names = {
"Kodi TV Show by Date",
"Jellyfin TV Show by Date",
"Emby TV Show by Date",
"Plex TV Show by Date",
}
class TvShowByDateOldPresets(PrebuiltPresets):
"""
TV Show by Date presets create a TV show from a single URL using upload dates as season/episode
numbers.
"""
preset_names = {
"kodi_tv_show_by_date",
"jellyfin_tv_show_by_date",
"plex_tv_show_by_date",
}
class TvShowByDateEpisodeFormattingPresets(PrebuiltPresets):
preset_names = {
"season_by_year__episode_by_month_day",
"season_by_year__episode_by_month_day_reversed",
"season_by_year_month__episode_by_day",
"season_by_year__episode_by_download_index",
}
class TvShowCollectionPresets(PrebuiltPresets):
preset_names = {
"Kodi TV Show Collection",
"Jellyfin TV Show Collection",
"Emby TV Show Collection",
"Plex TV Show Collection",
}
class TvShowCollectionOldPresets(PrebuiltPresets):
preset_names = {
"kodi_tv_show_collection",
"jellyfin_tv_show_collection",
"plex_tv_show_collection",
}
class TvShowCollectionEpisodeFormattingPresets(PrebuiltPresets):
preset_names = {
"season_by_collection__episode_by_year_month_day",
"season_by_collection__episode_by_year_month_day_reversed",
"season_by_collection__episode_by_playlist_index",
"season_by_collection__episode_by_playlist_index_reversed",
}
class TvShowCollectionSeasonPresets(PrebuiltPresets):
"""Now Deprecated"""
preset_names = {
"collection_season_1",
"collection_season_2",
"collection_season_3",
"collection_season_4",
"collection_season_5",
}

View file

@ -9,6 +9,7 @@ presets:
thumbnail_name: "{thumbnail_file_name}"
info_json_name: "{episode_file_path}.{info_json_ext}"
maintain_download_archive: True
keep_files_date_eval: "{episode_date_standardized}"
ytdl_options:
break_on_existing: True
@ -16,6 +17,9 @@ presets:
chapters:
embed_chapters: True
date_range:
type: "{tv_show_date_range_type}"
overrides:
# MUST DEFINE:
# tv_show_directory
@ -36,14 +40,15 @@ presets:
tv_show_genre: "{subscription_indent_1}"
tv_show_content_rating: "{subscription_indent_2}"
season_directory_name: "Season {season_number_padded}"
episode_title: "{upload_date_standardized} - {title}"
episode_title: "{episode_date_standardized} - {title}"
episode_plot: "{webpage_url}\n\n{description}"
episode_year: "{upload_year}"
episode_content_rating: "{tv_show_content_rating}"
episode_date_standardized: "{upload_date_standardized}"
episode_file_name: "s{season_number_padded}.e{episode_number_padded} - {file_title}"
episode_file_path: "{season_directory_name_sanitized}/{episode_file_name_sanitized}"
thumbnail_file_name: "{episode_file_path}-thumb.jpg"
episode_year: "{%slice(episode_date_standardized, 0, 4)}"
# Used to determine which date type to use if `Only Recent` preset
# is applied
tv_show_date_range_type: "upload_date"

View file

@ -31,26 +31,141 @@ presets:
- "plex_tv_show_by_date"
- "season_by_year__episode_by_month_day"
"Emby TV Show by Date":
preset: "Jellyfin TV Show by Date"
####################################################################################################
# TV show from one or more sources. Uses {url}'s avatar and banner as poster and fanart
_tv_show_by_date:
preset: "_multi_url_bilateral"
preset:
- "_multi_url_bilateral"
- "_tv_show_by_date_asserts"
overrides:
avatar_uncropped_thumbnail_file_name: "{tv_show_poster_file_name}"
banner_uncropped_thumbnail_file_name: "{tv_show_fanart_file_name}"
_tv_show_by_date_asserts:
overrides:
s01_url: ""
s01_name: ""
assert_not_collection: >-
{
%assert(
%and(
%not( %bool(s01_url) ),
%not( %bool(s01_name) )
),
"Provided `s01_url` or `s01_name` variable to TV Show by Date preset when it expects `url`. Perhaps you meant to use the `TV Show Collection` preset?"
)
}
####################################################################################################
_tv_show_by_date_season_episode_ordering:
overrides:
tv_show_by_date_season_ordering: >-
{ %throw("Must specify tv_show_by_date_season_ordering") }
"%season_ordering_": >-
{ %eq( %lower(tv_show_by_date_season_ordering), $0 ) }
season_number: >-
{
%elif(
%season_ordering_( "upload-year" ), upload_year,
%season_ordering_( "upload-year-month" ), %concat(upload_year, upload_month_padded),
%season_ordering_( "release-year" ), release_year,
%season_ordering_( "release-year-month" ), %concat(release_year, release_month_padded),
%throw(
'tv_show_by_date_season_ordering must be one of the following: "upload-year", "upload-year-month", "release-year", "release-year-month"'
)
)
}
# both year and year-month are fixed width, until the year 10000 :)
season_number_padded: "{season_number}"
##################################### episode overrides
tv_show_by_date_episode_ordering: >-
{ %throw("Must specify tv_show_by_date_episode_ordering") }
"%episode_ordering_": >-
{ %eq( %lower(tv_show_by_date_episode_ordering), $0 ) }
episode_number_and_padded_: >-
{
%elif(
%episode_ordering_( "upload-day" ), [ %concat(upload_day, upload_date_index_padded), 4 ],
%episode_ordering_( "upload-month-day" ), [ %concat(upload_month, upload_day_padded, upload_date_index_padded), 6],
%episode_ordering_( "upload-month-day-reversed" ), [ %concat(upload_day_of_year_reversed, upload_date_index_reversed_padded), 5],
%episode_ordering_( "release-day" ), [ %concat(release_day, upload_date_index_padded), 4 ],
%episode_ordering_( "release-month-day" ), [ %concat(release_month, release_day_padded, upload_date_index_padded), 6],
%episode_ordering_( "release-month-day-reversed" ), [ %concat(release_day_of_year_reversed, upload_date_index_reversed_padded), 5],
%episode_ordering_( "download-index" ), [ download_index, 6 ],
%throw(
'tv_show_by_date_episode_ordering must be one of the following: "upload-day", "upload-month-day", "upload-month-day-reversed", "release-day", "release-month-day", "release-month-day-reversed", "download-index"'
)
)
}
episode_number: "{ %array_at(episode_number_and_padded_, 0) }"
episode_number_padded: "{ %pad_zero( %int(episode_number), %int(%array_at(episode_number_and_padded_, 1))) }"
episode_date_standardized: >-
{
%if(
%contains(tv_show_by_date_season_ordering, "release"),
release_date_standardized,
upload_date_standardized
)
}
tv_show_date_range_type: >-
{
%if(
%contains(tv_show_by_date_season_ordering, "release"),
"release_date",
"upload_date"
)
}
##################################### season/episode pairing validation
"%ordering_pair_eq": >-
{
%eq([tv_show_by_date_season_ordering, tv_show_by_date_episode_ordering], [$0, $1])
}
tv_show_by_date_ordering_pair_validation_: >-
{
%assert_then(
%or(
%ordering_pair_eq("upload-year", "upload-month-day"),
%ordering_pair_eq("upload-year", "upload-month-day-reversed"),
%ordering_pair_eq("upload-year", "download-index"),
%ordering_pair_eq("upload-year-month", "upload-day"),
%ordering_pair_eq("release-year", "release-month-day"),
%ordering_pair_eq("release-year", "release-month-day-reversed"),
%ordering_pair_eq("release-year", "download-index"),
%ordering_pair_eq("release-year-month", "release-day")
),
episode_number_and_padded_,
"Detected incompatibility between tv_show_by_date_season_ordering and tv_show_by_date_episode_ordering. Ensure you are not using both upload and release date, and that the year/month/day are included in the combined season and episode."
)
}
####################################################################################################
# LEGACY PRESETS
_season_by_year:
preset: "_tv_show_by_date_season_episode_ordering"
overrides:
season_number: "{upload_year}"
season_number_padded: "{season_number}"
tv_show_by_date_season_ordering: "upload-year"
_season_by_year_month:
preset: "_tv_show_by_date_season_episode_ordering"
overrides:
season_number: "{upload_year}{upload_month_padded}"
season_number_padded: "{season_number}"
tv_show_by_date_season_ordering: "upload-year-month"
####################################################################################################
@ -58,26 +173,22 @@ presets:
preset:
- "_season_by_year_month"
overrides:
episode_number: "{upload_day}{upload_date_index_padded}"
episode_number_padded: "{upload_day_padded}{upload_date_index_padded}"
tv_show_by_date_episode_ordering: "upload-day"
season_by_year__episode_by_month_day:
preset:
- "_season_by_year"
overrides:
episode_number: "{upload_month}{upload_day_padded}{upload_date_index_padded}"
episode_number_padded: "{upload_month_padded}{upload_day_padded}{upload_date_index_padded}"
tv_show_by_date_episode_ordering: "upload-month-day"
season_by_year__episode_by_month_day_reversed:
preset:
- "_season_by_year"
overrides:
episode_number: "{upload_day_of_year_reversed}{upload_date_index_reversed_padded}"
episode_number_padded: "{upload_day_of_year_reversed_padded}{upload_date_index_reversed_padded}"
tv_show_by_date_episode_ordering: "upload-month-day-reversed"
season_by_year__episode_by_download_index:
preset:
- "_season_by_year"
overrides:
episode_number: "{download_index}"
episode_number_padded: "{download_index_padded6}"
tv_show_by_date_episode_ordering: "download-index"

View file

@ -18,7 +18,20 @@ presets:
- "plex_tv_show_collection"
- "season_by_collection__episode_by_year_month_day"
"Emby TV Show Collection":
preset: "Jellyfin TV Show Collection"
# Emby cannot read seasons from tv_show.nfo
# Create explicit season NFOs in each season dir
static_nfo_tags:
nfo_name: "{season_directory_name_sanitized}/season.nfo"
nfo_root: "season"
tags:
title: "{collection_season_name}"
##################
# Legacy names
kodi_tv_show_collection:
preset:
@ -37,31 +50,78 @@ presets:
###############################
season_by_collection__episode_by_year_month_day:
_tv_show_collection_episode_ordering:
overrides:
episode_number: "{upload_year_truncated}{upload_month_padded}{upload_day_padded}{upload_date_index_padded}"
episode_number_padded: "{episode_number}"
tv_show_collection_episode_ordering: >-
{ %throw("Must specify tv_show_collection_episode_ordering") }
"%episode_ordering_": >-
{ %eq( %lower(tv_show_collection_episode_ordering), $0 ) }
episode_number_and_padded_: >-
{
%elif(
%episode_ordering_( "upload-year-month-day" ), [ %concat(upload_year_truncated, upload_month_padded, upload_day_padded, upload_date_index_padded), 8 ],
%episode_ordering_( "upload-year-month-day-reversed" ), [ %concat(upload_year_truncated_reversed, upload_month_reversed_padded, upload_day_reversed_padded, upload_date_index_reversed_padded), 8],
%episode_ordering_( "release-year-month-day" ), [ %concat(release_year_truncated, release_month_padded, release_day_padded, upload_date_index_padded), 8 ],
%episode_ordering_( "release-year-month-day-reversed" ), [ %concat(release_year_truncated_reversed, release_month_reversed_padded, release_day_reversed_padded, upload_date_index_reversed_padded), 8],
%episode_ordering_( "playlist-index" ), [ %concat(playlist_index), 6],
%episode_ordering_( "playlist-index-reversed" ), [ %concat(playlist_index_reversed), 6],
%throw(
'tv_show_collection_episode_ordering must be one of the following: "upload-year-month-day", "upload-year-month-day-reversed", "release-year-month-day", "release-year-month-day-reversed", "playlist-index", "playlist-index-reversed"'
)
)
}
episode_number: "{ %array_at(episode_number_and_padded_, 0) }"
episode_number_padded: "{ %pad_zero( %int(episode_number), %int(%array_at(episode_number_and_padded_, 1))) }"
episode_date_standardized: >-
{
%if(
%contains(tv_show_collection_episode_ordering, "release"),
release_date_standardized,
upload_date_standardized
)
}
tv_show_date_range_type: >-
{
%if(
%contains(tv_show_collection_episode_ordering, "release"),
"release_date",
"upload_date"
)
}
### LEGACY PRESETS
season_by_collection__episode_by_year_month_day:
preset: "_tv_show_collection_episode_ordering"
overrides:
tv_show_collection_episode_ordering: "upload-year-month-day"
season_by_collection__episode_by_year_month_day_reversed:
preset: "_tv_show_collection_episode_ordering"
overrides:
episode_number: "{upload_year_truncated_reversed}{upload_month_reversed_padded}{upload_day_reversed_padded}{upload_date_index_reversed_padded}"
episode_number_padded: "{episode_number}"
tv_show_collection_episode_ordering: "upload-year-month-day-reversed"
season_by_collection__episode_by_playlist_index:
preset: "_tv_show_collection_episode_ordering"
overrides:
episode_number: "{playlist_index}"
episode_number_padded: "{playlist_index_padded6}"
tv_show_collection_episode_ordering: "playlist-index"
season_by_collection__episode_by_playlist_index_reversed:
preset: "_tv_show_collection_episode_ordering"
overrides:
episode_number: "{playlist_index_reversed}"
episode_number_padded: "{playlist_index_reversed_padded6}"
tv_show_collection_episode_ordering: "playlist-index-reversed"
##############
_tv_show_collection:
preset:
- "_tv_show_collection_bilateral"
- "_tv_show_collection_asserts"
download:
- url: "{collection_season_1_url}"
@ -1015,6 +1075,16 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
_tv_show_collection_asserts:
overrides:
url: ""
assert_not_by_date: >-
{
%assert(
%not( %bool(url) ),
"Provided `url` to TV Show Collection preset when it expects `s01_url`. Perhaps you meant to use the `TV Show by Date` preset?"
)
}
####################################################################################################
# DEPRECATED SEASON PRESETS

View file

@ -9,6 +9,7 @@ from ytdl_sub.script.functions.error_functions import ErrorFunctions
from ytdl_sub.script.functions.json_functions import JsonFunctions
from ytdl_sub.script.functions.map_functions import MapFunctions
from ytdl_sub.script.functions.numeric_functions import NumericFunctions
from ytdl_sub.script.functions.print_functions import PrintFunctions
from ytdl_sub.script.functions.regex_functions import RegexFunctions
from ytdl_sub.script.functions.string_functions import StringFunctions
from ytdl_sub.script.types.resolvable import Resolvable
@ -26,6 +27,7 @@ class Functions(
RegexFunctions,
DateFunctions,
JsonFunctions,
PrintFunctions,
):
_custom_functions: Dict[str, Callable[..., Resolvable]] = {}

View file

@ -0,0 +1,67 @@
from typing import Optional
from ytdl_sub.script.types.resolvable import AnyArgument
from ytdl_sub.script.types.resolvable import Integer
from ytdl_sub.script.types.resolvable import ReturnableArgument
from ytdl_sub.utils.logger import Logger
logger = Logger.get(name="preset")
def _log(message: AnyArgument, level: Optional[Integer]) -> None:
if level is None:
logger.info(str(message))
return
level_value: int = level.native
if level_value < 0:
logger.debug(str(message))
elif level_value == 0:
logger.info(str(message))
elif level_value == 1:
logger.warning(str(message))
else: # > 1
logger.error(str(message))
class PrintFunctions:
@staticmethod
def print(
message: AnyArgument, passthrough: ReturnableArgument, level: Optional[Integer] = None
) -> ReturnableArgument:
"""
:description:
Print the ``message`` and return ``passthrough``.
Optionally can pass level, where < 0 is debug, 0 is info, 1 is warning, > 1 is error.
Defaults to info.
"""
_log(message=message, level=level)
return passthrough
@staticmethod
def print_if_true(
message: AnyArgument, passthrough: ReturnableArgument, level: Optional[Integer] = None
) -> ReturnableArgument:
"""
:description:
Print 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.
Defaults to info.
"""
if passthrough.value:
_log(message=message, level=level)
return passthrough
@staticmethod
def print_if_false(
message: AnyArgument, passthrough: ReturnableArgument, level: Optional[Integer] = None
) -> ReturnableArgument:
"""
:description:
Print 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.
Defaults to info.
"""
if not passthrough.value:
_log(message=message, level=level)
return passthrough

View file

@ -40,6 +40,20 @@ class StringFunctions:
)
)
@staticmethod
def contains_all(string: String, contains_array: Array) -> Boolean:
"""
:description:
Returns true if all elements in ``contains_array`` are in ``string``. False otherwise.
"""
return Boolean(
all(
str(val) in string.value
for val in contains_array.value
if isinstance(val, (String, Integer, Boolean, Float))
)
)
@staticmethod
def slice(string: String, start: Integer, end: Optional[Integer] = None) -> String:
"""
@ -113,12 +127,12 @@ class StringFunctions:
return Array([String(split_val) for split_val in string.value.split(sep=sep.value)])
@staticmethod
def concat(*values: String) -> String:
def concat(*values: AnyArgument) -> String:
"""
:description:
Concatenate multiple Strings into a single String.
"""
return String("".join(val.value for val in values))
return String("".join(str(val.value) for val in values))
@staticmethod
def pad(string: String, length: Integer, char: String) -> String:
@ -162,3 +176,41 @@ class StringFunctions:
# World
"""
return String(string.value.encode("utf-8").decode("unicode_escape"))
@staticmethod
def 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"
"""
return String(separator.value.join(str(val) for val in array.value))
@staticmethod
def 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!"
"""
return String(string.value.strip())

View file

@ -17,6 +17,7 @@ from ytdl_sub.downloaders.source_plugin import SourcePlugin
from ytdl_sub.downloaders.url.downloader import MultiUrlDownloader
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.subscriptions.base_subscription import BaseSubscription
from ytdl_sub.subscriptions.subscription_ytdl_options import SubscriptionYTDLOptions
from ytdl_sub.utils.datetime import to_date_range
@ -215,9 +216,23 @@ class SubscriptionDownload(BaseSubscription, ABC):
FileHandler.delete(entry.get_download_thumbnail_path())
FileHandler.delete(entry.get_download_info_json_path())
@classmethod
def _preprocess_entry(cls, plugins: List[Plugin], entry: Entry) -> Optional[Entry]:
def _preprocess_entry(self, plugins: List[Plugin], entry: Entry) -> Optional[Entry]:
maybe_entry: Optional[Entry] = entry
# Inject OutputOption variables here
entry.add(
{
VARIABLES.ytdl_sub_keep_files_date_eval: (
self.output_options.keep_files_date_eval.format_string
)
}
)
# Run it to make sure it's actually a standardized date
_ = self.overrides.apply_formatter(
formatter=self.output_options.keep_files_date_eval, entry=entry
)
for plugin in PluginMapping.order_plugins_by(
plugins, PluginOperation.MODIFY_ENTRY_METADATA
):
@ -334,6 +349,34 @@ class SubscriptionDownload(BaseSubscription, ABC):
return self.download_archive.get_file_handler_transaction_log()
def get_ytdl_options(
self, plugins: Optional[List[Plugin]], dry_run: bool
) -> SubscriptionYTDLOptions:
"""
Parameters
----------
plugins
Optional. If not provided, will reinitialize them
dry_run
Whether its dry run or not
Returns
-------
SubscriptionYTDLOptions
Both metadata and download ytdl-options
"""
if plugins is None:
plugins = self._initialize_plugins()
return SubscriptionYTDLOptions(
preset=self._preset_options,
plugins=plugins,
enhanced_download_archive=self.download_archive,
overrides=self.overrides,
working_directory=self.working_directory,
dry_run=dry_run,
)
def download(self, dry_run: bool = False) -> FileHandlerTransactionLog:
"""
Performs the subscription download
@ -353,14 +396,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
logging.info("Skipping %s", self.name)
return FileHandlerTransactionLog()
subscription_ytdl_options = SubscriptionYTDLOptions(
preset=self._preset_options,
plugins=plugins,
enhanced_download_archive=self.download_archive,
overrides=self.overrides,
working_directory=self.working_directory,
dry_run=dry_run,
)
subscription_ytdl_options = self.get_ytdl_options(plugins=plugins, dry_run=dry_run)
downloader = MultiUrlDownloader(
options=self.downloader_options,
@ -405,15 +441,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
self.download_archive.reinitialize(dry_run=dry_run)
plugins = self._initialize_plugins()
subscription_ytdl_options = SubscriptionYTDLOptions(
preset=self._preset_options,
plugins=plugins,
enhanced_download_archive=self.download_archive,
overrides=self.overrides,
working_directory=self.working_directory,
dry_run=dry_run,
)
subscription_ytdl_options = self.get_ytdl_options(plugins=plugins, dry_run=dry_run)
# Re-add the original downloader class' plugins
plugins.extend(

View file

@ -19,6 +19,7 @@ from ytdl_sub.plugins.match_filters import MatchFiltersPlugin
from ytdl_sub.plugins.match_filters import combine_filters
from ytdl_sub.plugins.match_filters import default_filters
from ytdl_sub.plugins.subtitles import SubtitlesPlugin
from ytdl_sub.plugins.throttle_protection import ThrottleProtectionPlugin
from ytdl_sub.utils.ffmpeg import FFMPEG
from ytdl_sub.utils.logger import Logger
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
@ -175,6 +176,7 @@ class SubscriptionYTDLOptions:
self._global_options,
self._output_options,
self._plugin_match_filters,
self._plugin_ytdl_options(ThrottleProtectionPlugin),
self._plugin_ytdl_options(FormatPlugin),
self._plugin_ytdl_options(AudioExtractPlugin), # will override format
self._user_ytdl_options, # user ytdl options...

View file

@ -220,10 +220,17 @@ class Chapters:
# Timestamp captured, store it
if match := Timestamp.TIMESTAMP_REGEX.search(line):
timestamp_str = match.group(1)
timestamps.append(Timestamp.from_str(timestamp_str))
# Remove timestamp and surrounding whitespace from it
title_str = re.sub(f"\\s*{re.escape(timestamp_str)}\\s*", " ", line).strip()
timestamp = Timestamp.from_str(timestamp_str)
if timestamps and (timestamps[-1].timestamp_sec == timestamp.timestamp_sec):
# duplicate timestamp... combine into one chapter
titles[-1] += " // " + title_str
continue
timestamps.append(timestamp)
titles.append(title_str)
# If more than 3 timestamps were parsed, return it

View file

@ -1,4 +1,5 @@
import os
import posixpath
from pathlib import Path
from typing import Tuple
@ -59,3 +60,8 @@ class FilePathTruncater:
return str(Path(file_directory) / cls._truncate_file_name(file_name))
return str(file_path)
@classmethod
def to_native_filepath(cls, file_path: str) -> str:
"""Ensures file paths use the correct separator"""
return os.path.expanduser(file_path.replace(posixpath.sep, os.sep))

View file

@ -2,7 +2,7 @@ import os
from pathlib import Path
from typing import Any
from ytdl_sub.script.script import Script
from ytdl_sub.utils.file_path import FilePathTruncater
from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
from ytdl_sub.validators.validators import StringValidator
@ -45,15 +45,8 @@ class StringFormatterFileNameValidator(StringFormatterValidator):
_expected_value_type_name = "filepath"
def post_process(self, resolved: str) -> str:
return (
Script(
{
"tmp_var_1": resolved,
"tmp_var_2": "{%to_native_filepath(%truncate_filepath_if_too_long(tmp_var_1))}",
}
)
.resolve()
.get_str("tmp_var_2")
return FilePathTruncater.to_native_filepath(
FilePathTruncater.maybe_truncate_file_path(resolved)
)
@ -61,13 +54,6 @@ class OverridesStringFormatterFilePathValidator(OverridesStringFormatterValidato
_expected_value_type_name = "static filepath"
def post_process(self, resolved: str) -> str:
return (
Script(
{
"tmp_var_1": resolved,
"tmp_var_2": "{%to_native_filepath(%truncate_filepath_if_too_long(tmp_var_1))}",
}
)
.resolve()
.get_str("tmp_var_2")
return FilePathTruncater.to_native_filepath(
FilePathTruncater.maybe_truncate_file_path(resolved)
)

View file

@ -1,3 +1,4 @@
from datetime import datetime
from typing import Dict
from typing import Set
from typing import Union
@ -73,6 +74,20 @@ class StringFormatterValidator(StringValidator):
return resolved
class StandardizedDateValidator(StringFormatterValidator):
_expected_value_type_name = "standardized_date"
def post_process(self, resolved: str) -> str:
try:
datetime.strptime(resolved, "%Y-%m-%d")
except ValueError as exc:
raise self._validation_exception(
f"Expected a standardized date in the form of YYYY-MM-DD, but received '{resolved}'"
) from exc
return resolved
# pylint: disable=line-too-long
class OverridesStringFormatterValidator(StringFormatterValidator):
"""

View file

@ -1,5 +1,6 @@
from typing import Set
from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator
from ytdl_sub.validators.validators import StringValidator
@ -17,3 +18,17 @@ class StringSelectValidator(StringValidator):
raise self._validation_exception(
f"Must be one of the following values: {', '.join(self._select_values)}"
)
class OverridesStringSelectValidator(OverridesStringFormatterValidator):
_expected_value_type_name = "overrides select"
_select_values: Set[str] = set()
def post_process(self, resolved: str) -> str:
if resolved not in self._select_values:
raise self._validation_exception(
f"Must be one of the following values: {', '.join(sorted(self._select_values))}"
)
return resolved

View file

@ -75,7 +75,7 @@ class DownloadMapping:
DownloadMapping for the entry
"""
return DownloadMapping(
upload_date=entry.get(v.upload_date_standardized, str),
upload_date=entry.get(v.ytdl_sub_keep_files_date_eval, str),
extractor=entry.download_archive_extractor,
file_names=set(),
)
@ -248,20 +248,18 @@ class DownloadMappings:
del self._entry_mappings[entry_id]
return self
def get_num_entries_with_upload_date(self, upload_date_standardized: str) -> int:
def get_num_entries_with_date(self, standardized_date: str) -> int:
"""
Parameters
----------
upload_date_standardized
standardized_date
A standardized upload date
Returns
-------
Number of entries in the mapping with this upload date
"""
return len(
[_ for _ in self._entry_mappings.values() if _.upload_date == upload_date_standardized]
)
return len([_ for _ in self._entry_mappings.values() if _.upload_date == standardized_date])
def get_num_entries(self) -> int:
"""

View file

@ -12,6 +12,7 @@ from typing import Callable
from typing import Dict
from typing import List
from typing import Optional
from typing import Tuple
from unittest.mock import patch
import pytest
@ -272,3 +273,27 @@ def mock_run_from_cli(args: str) -> List[Subscription]:
args_list = ["ytdl-sub"] + shlex.split(args)
with patch.object(sys, "argv", args_list):
return main()
def get_match_filters(
subscription: Subscription, dry_run: bool, download_filters: bool
) -> Tuple[List[str], List[str]]:
"""
Util function to get match filters from a subscription.
Returns
-------
match_filters, breaking_match_filters
"""
options = subscription.get_ytdl_options(plugins=None, dry_run=dry_run)
options_dict = (
options.download_builder().to_dict()
if download_filters
else options.metadata_builder().to_dict()
)
if "match_filter" not in options_dict:
return [], []
match_filter_str = repr(options_dict["match_filter"])
out = eval(match_filter_str.split("(", maxsplit=1)[-1].split(")")[0])
return out

View file

@ -1,3 +1,5 @@
from typing import List
import pytest
from conftest import assert_logs
from expected_download import assert_expected_downloads
@ -17,6 +19,7 @@ def subscription_dict(output_directory):
"subscription_indent_1": "Acoustic",
"music_directory": output_directory,
},
"throttle_protection": {"sleep_per_request_s": {"min": 0.1, "max": 0.5}},
}
@ -34,6 +37,21 @@ class TestSoundcloudDiscography:
output_directory,
dry_run,
):
ignored_mp3s: List[str] = [
"j_b/[2021] Baby Santana's Dorian Groove/01 - Baby Santana's Dorian Groove.mp3",
"j_b/[2021] Purple Clouds/01 - Purple Clouds.mp3",
"j_b/[2022] Acoustic Treats/01 - 20160426 184214.mp3",
"j_b/[2022] Acoustic Treats/02 - 20160502 123150.mp3",
"j_b/[2022] Acoustic Treats/03 - 20160504 143832.mp3",
"j_b/[2022] Acoustic Treats/04 - 20160601 221234.mp3",
"j_b/[2022] Acoustic Treats/05 - 20160601 222440.mp3",
"j_b/[2022] Acoustic Treats/06 - 20170604 190236.mp3",
"j_b/[2022] Acoustic Treats/07 - 20170612 193646.mp3",
"j_b/[2022] Acoustic Treats/08 - 20170628 215206.mp3",
"j_b/[2022] Acoustic Treats/09 - Finding Home.mp3",
"j_b/[2022] Acoustic Treats/10 - Shallow Water WIP.mp3",
"j_b/[2022] Acoustic Treats/11 - Untold History.mp3",
]
discography_subscription = Subscription.from_dict(
preset_dict=subscription_dict,
preset_name="j_b",
@ -49,6 +67,7 @@ class TestSoundcloudDiscography:
output_directory=output_directory,
dry_run=dry_run,
expected_download_summary_file_name="soundcloud/test_soundcloud_discography.json",
ignore_md5_hashes_for=ignored_mp3s,
)
# Ensure another invocation will hit ExistingVideoReached
@ -65,4 +84,5 @@ class TestSoundcloudDiscography:
output_directory=output_directory,
dry_run=dry_run,
expected_download_summary_file_name="soundcloud/test_soundcloud_discography.json",
ignore_md5_hashes_for=ignored_mp3s,
)

View file

@ -1,4 +1,3 @@
import json
from typing import Dict
import pytest
@ -44,13 +43,13 @@ class TestReproduce:
def test_debug_log_repro(
self,
default_config,
repro_preset_dict,
debug_log_rerpo,
output_directory,
):
sub = Subscription.from_dict(
config=default_config,
preset_name="repro",
preset_dict=repro_preset_dict,
preset_dict=debug_log_rerpo,
)
transaction_log = sub.download(dry_run=False)

View file

@ -1,10 +1,10 @@
import contextlib
import os
import shutil
from pathlib import Path
from typing import Callable
from typing import Dict
from typing import List
from typing import Optional
from unittest.mock import patch
import pytest
@ -53,6 +53,7 @@ def mock_entry_dict_factory(mock_downloaded_file_path) -> Callable:
is_youtube_channel: bool = False,
mock_download_to_working_dir: bool = True,
is_extracted_audio: bool = False,
release_date: Optional[str] = None,
) -> Dict:
entry_dict = {
v.uid.metadata_key: uid,
@ -71,6 +72,10 @@ def mock_entry_dict_factory(mock_downloaded_file_path) -> Callable:
v.description.metadata_key: "The Description",
}
# TODO: Make this required eventually
if release_date is not None:
entry_dict[v.release_date.metadata_key] = release_date
if is_youtube_channel:
entry_dict[v.playlist_metadata.metadata_key]["thumbnails"] = [
{
@ -153,6 +158,7 @@ def mock_download_collection_entries(
mock_entry_dict_factory(
uid="21-1",
upload_date="20210808",
release_date="20010808",
playlist_title="Download First",
playlist_index=1,
playlist_count=4,
@ -163,6 +169,7 @@ def mock_download_collection_entries(
mock_entry_dict_factory(
uid="20-1",
upload_date="20200808",
release_date="20000808",
playlist_title="Download First",
playlist_index=2,
playlist_count=4,
@ -173,6 +180,7 @@ def mock_download_collection_entries(
mock_entry_dict_factory(
uid="20-2",
upload_date="20200808",
release_date="20000808",
playlist_title="Download First",
playlist_index=3,
playlist_count=4,
@ -183,6 +191,7 @@ def mock_download_collection_entries(
mock_entry_dict_factory(
uid="20-3",
upload_date="20200807",
release_date="20000807",
playlist_title="Download First",
playlist_index=4,
playlist_count=4,
@ -196,6 +205,7 @@ def mock_download_collection_entries(
mock_entry_dict_factory(
uid="20-3",
upload_date="20200807",
release_date="20000807",
playlist_title="Download Second",
playlist_index=1,
playlist_count=5,
@ -206,6 +216,7 @@ def mock_download_collection_entries(
mock_entry_dict_factory(
uid="20-4",
upload_date="20200806",
release_date="20000806",
playlist_title="Download Second",
playlist_index=2,
playlist_count=5,
@ -216,6 +227,7 @@ def mock_download_collection_entries(
mock_entry_dict_factory(
uid="20-5",
upload_date="20200706",
release_date="20000706",
playlist_title="Download Second",
playlist_index=3,
playlist_count=5,
@ -226,6 +238,7 @@ def mock_download_collection_entries(
mock_entry_dict_factory(
uid="20-6",
upload_date="20200706",
release_date="20000706",
playlist_title="Download Second",
playlist_index=4,
playlist_count=5,
@ -236,6 +249,7 @@ def mock_download_collection_entries(
mock_entry_dict_factory(
uid="20-7",
upload_date="20200606",
release_date="20000606",
playlist_title="Download Second",
playlist_index=5,
playlist_count=5,

View file

@ -4,7 +4,6 @@ import pytest
from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches
from ytdl_sub.entries.entry import ytdl_sub_chapters_from_comments
from ytdl_sub.subscriptions.subscription import Subscription

View file

@ -1,3 +1,4 @@
import re
from pathlib import Path
from typing import Dict
from unittest.mock import patch
@ -11,6 +12,7 @@ from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.downloaders.ytdlp import YTDLP
from ytdl_sub.entries.entry import Entry
from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.thumbnail import try_convert_download_thumbnail
@ -223,3 +225,35 @@ class TestOutputOptions:
dry_run=False,
expected_download_summary_file_name="plugins/output_options/test_missing_thumb.json",
)
def test_invalid_keep_files_date_eval(
self,
config: ConfigFile,
subscription_name: str,
output_options_subscription_dict: Dict,
output_directory: str,
mock_download_collection_entries,
):
output_options_subscription_dict["output_options"]["keep_files_date_eval"] = "nope"
subscription = Subscription.from_dict(
config=config,
preset_name=subscription_name,
preset_dict=output_options_subscription_dict,
)
expected_error_msg = (
"Validation error in subscription_test.output_options.keep_files_date_eval: "
"Expected a standardized date in the form of YYYY-MM-DD, but received 'nope'"
)
with (
mock_download_collection_entries(
is_youtube_channel=False,
num_urls=1,
is_extracted_audio=False,
is_dry_run=True,
),
pytest.raises(ValidationException, match=re.escape(expected_error_msg)),
):
subscription.download(dry_run=True)

View file

@ -1,11 +1,8 @@
import re
import pytest
from expected_transaction_log import assert_transaction_log_matches
from ytdl_sub.script.utils.exceptions import UserThrownRuntimeError
from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.utils.exceptions import ValidationException
@pytest.fixture
@ -77,6 +74,39 @@ class TestFilterKeywords:
transaction_log_summary_file_name=f"integration/prebuilt_presets/title_filter_keywords_{filter_mode}.txt",
)
@pytest.mark.parametrize("filter_mode", ["include", "exclude"])
def test_title_all(
self,
config,
filter_subscription_dict,
output_directory,
subscription_name,
mock_download_collection_entries,
filter_mode: str,
):
filter_subscription_dict["overrides"][f"title_{filter_mode}_eval"] = "all"
filter_subscription_dict["overrides"][f"title_{filter_mode}_keywords"] = [
"MOCK",
"ENTRY",
"20-3",
]
subscription = Subscription.from_dict(
config=config,
preset_name=subscription_name,
preset_dict=filter_subscription_dict,
)
with mock_download_collection_entries(
is_youtube_channel=False, num_urls=1, is_dry_run=True
):
transaction_log = subscription.download(dry_run=True)
assert_transaction_log_matches(
output_directory=output_directory,
transaction_log=transaction_log,
transaction_log_summary_file_name=f"integration/prebuilt_presets/title_filter_keywords_{filter_mode}.txt",
)
@pytest.mark.parametrize("filter_mode", ["include", "exclude"])
def test_description(
self,
@ -108,6 +138,38 @@ class TestFilterKeywords:
transaction_log_summary_file_name=f"integration/prebuilt_presets/description_filter_keywords_{filter_mode}.txt",
)
@pytest.mark.parametrize("filter_mode", ["include", "exclude"])
def test_description_all(
self,
config,
filter_subscription_dict,
output_directory,
subscription_name,
mock_download_collection_entries,
filter_mode: str,
):
filter_subscription_dict["overrides"][f"description_{filter_mode}_eval"] = "ALL"
filter_subscription_dict["overrides"][f"description_{filter_mode}_keywords"] = [
"descr",
"iption",
]
subscription = Subscription.from_dict(
config=config,
preset_name=subscription_name,
preset_dict=filter_subscription_dict,
)
with mock_download_collection_entries(
is_youtube_channel=False, num_urls=1, is_dry_run=True
):
transaction_log = subscription.download(dry_run=True)
assert_transaction_log_matches(
output_directory=output_directory,
transaction_log=transaction_log,
transaction_log_summary_file_name=f"integration/prebuilt_presets/description_filter_keywords_{filter_mode}.txt",
)
@pytest.mark.parametrize(
"keyword_variable",
[
@ -169,3 +231,35 @@ class TestFilterKeywords:
pytest.raises(UserThrownRuntimeError, match="filter keywords must be strings"),
):
_ = subscription.download(dry_run=True)
@pytest.mark.parametrize(
"keyword_variable",
[
"title_include",
"title_exclude",
"description_include",
"description_exclude",
],
)
def test_error_not_correct_eval(
self,
config,
filter_subscription_dict,
output_directory,
subscription_name,
mock_download_collection_entries,
keyword_variable,
):
filter_subscription_dict["overrides"][f"{keyword_variable}_keywords"] = ["hmm"]
filter_subscription_dict["overrides"][f"{keyword_variable}_eval"] = "LOL"
subscription = Subscription.from_dict(
config=config,
preset_name=subscription_name,
preset_dict=filter_subscription_dict,
)
with (
mock_download_collection_entries(is_youtube_channel=False, num_urls=1, is_dry_run=True),
pytest.raises(UserThrownRuntimeError, match="Keyword eval must be either ANY or ALL"),
):
_ = subscription.download(dry_run=True)

View file

@ -1,19 +1,30 @@
import re
from typing import Set
import pytest
from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches
from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.prebuilt_presets.tv_show import TvShowByDateEpisodeFormattingPresets
from ytdl_sub.prebuilt_presets.tv_show import TvShowByDatePresets
from ytdl_sub.script.utils.exceptions import UserThrownRuntimeError
from ytdl_sub.subscriptions.subscription import Subscription
DEPRECATED_TV_SHOW_PRESET_EQUIVALENTS = {
"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": "plex_tv_show_by_date",
}
DEFAULT_SEASON_ORDERING = "upload-year"
DEFAULT_EPISODE_ORDERING = "upload-month-day"
DEFAULT_EPISODE_ORDERING_PRESET = "season_by_year__episode_by_month_day"
VALID_ORDERING_COMBOS = [
# upload
("upload-year", "upload-month-day"),
("upload-year", "upload-month-day-reversed"),
("upload-year", "download-index"),
("upload-year-month", "upload-day"),
# release
("release-year", "release-month-day"),
("release-year", "release-month-day-reversed"),
("release-year", "download-index"),
("release-year-month", "release-day"),
]
class TestPrebuiltTVShowPresets:
@ -24,24 +35,28 @@ class TestPrebuiltTVShowPresets:
subscription_name: str,
output_directory: str,
tv_show_preset: str,
episode_ordering_preset: str,
season_ordering: str,
episode_ordering: str,
is_youtube_channel: bool = True,
is_many_urls: bool = False,
):
expected_summary_name = "unit/{}/{}/is_yt_{}{}".format(
DEPRECATED_TV_SHOW_PRESET_EQUIVALENTS[tv_show_preset],
episode_ordering_preset,
expected_summary_name = "integration/by-date/{}/{}/{}/is_yt_{}{}".format(
tv_show_preset.split(" ")[0],
season_ordering,
episode_ordering,
int(is_youtube_channel),
"_many_urls" if is_many_urls else "",
)
preset_dict = {
"preset": [tv_show_preset, episode_ordering_preset],
"preset": tv_show_preset,
"overrides": {
"url": "https://your.name.here",
"tv_show_name": "Best Prebuilt TV Show by Date",
"tv_show_directory": output_directory,
"tv_show_by_date_season_ordering": season_ordering,
"tv_show_by_date_episode_ordering": episode_ordering,
},
}
if is_many_urls:
@ -67,16 +82,19 @@ class TestPrebuiltTVShowPresets:
)
###################################### Perform reformat
reformatted_tv_show_structure_preset = "season_by_year__episode_by_download_index"
reformatted_expected_summary_name = "unit/{}/{}/is_yt_{}{}".format(
DEPRECATED_TV_SHOW_PRESET_EQUIVALENTS[tv_show_preset],
reformatted_tv_show_structure_preset,
reformatted_season_ordering = "upload-year"
reformatted_episode_ordering = "download-index"
reformatted_expected_summary_name = "integration/by-date/{}/{}/{}/is_yt_{}{}".format(
tv_show_preset.split(" ")[0],
reformatted_season_ordering,
reformatted_episode_ordering,
int(is_youtube_channel),
"_many_urls" if is_many_urls else "",
)
reformatted_preset_dict = {
"preset": [tv_show_preset, reformatted_tv_show_structure_preset],
"preset": tv_show_preset,
"output_options": {
"migrated_download_archive_name": ".ytdl-sub-{tv_show_name_sanitized}-download-archive.json"
},
@ -84,6 +102,8 @@ class TestPrebuiltTVShowPresets:
"url": "https://your.name.here",
"tv_show_name": "Best Prebuilt TV Show by Date",
"tv_show_directory": output_directory,
"tv_show_by_date_season_ordering": reformatted_season_ordering,
"tv_show_by_date_episode_ordering": reformatted_episode_ordering,
},
}
if is_many_urls:
@ -98,7 +118,7 @@ class TestPrebuiltTVShowPresets:
output_directory=output_directory,
transaction_log=reformatted_transaction_log,
transaction_log_summary_file_name=(
f"{expected_summary_name}_reformatted_to_{reformatted_tv_show_structure_preset}.txt"
f"{expected_summary_name}_reformatted_to_{reformatted_season_ordering}.txt"
),
)
assert_expected_downloads(
@ -123,11 +143,13 @@ class TestPrebuiltTVShowPresets:
subscription_name=subscription_name,
output_directory=output_directory,
tv_show_preset=tv_show_preset,
episode_ordering_preset=DEFAULT_EPISODE_ORDERING_PRESET,
season_ordering=DEFAULT_SEASON_ORDERING,
episode_ordering=DEFAULT_EPISODE_ORDERING,
)
@pytest.mark.parametrize(
"episode_ordering_preset", TvShowByDateEpisodeFormattingPresets.preset_names
"season_ordering, episode_ordering",
VALID_ORDERING_COMBOS,
)
def test_episode_ordering_presets(
self,
@ -135,7 +157,8 @@ class TestPrebuiltTVShowPresets:
subscription_name,
output_directory,
mock_download_collection_entries,
episode_ordering_preset: str,
season_ordering: str,
episode_ordering: str,
):
with mock_download_collection_entries(
@ -146,5 +169,91 @@ class TestPrebuiltTVShowPresets:
subscription_name=subscription_name,
output_directory=output_directory,
tv_show_preset="Kodi TV Show by Date",
episode_ordering_preset=episode_ordering_preset,
season_ordering=season_ordering,
episode_ordering=episode_ordering,
)
def test_invalid_season_ordering(
self, config, subscription_name, output_directory, mock_download_collection_entries
):
expected_message = (
"tv_show_by_date_season_ordering must be one of the following: "
'"upload-year", '
'"upload-year-month", '
'"release-year", '
'"release-year-month"'
)
with (
mock_download_collection_entries(is_youtube_channel=True, num_urls=1),
pytest.raises(UserThrownRuntimeError, match=re.escape(expected_message)),
):
self.run(
config=config,
subscription_name=subscription_name,
output_directory=output_directory,
tv_show_preset="Kodi TV Show by Date",
season_ordering="nope",
episode_ordering=DEFAULT_EPISODE_ORDERING,
)
def test_invalid_episode_ordering(
self, config, subscription_name, output_directory, mock_download_collection_entries
):
expected_message = (
"tv_show_by_date_episode_ordering must be one of the following: "
'"upload-day", '
'"upload-month-day", '
'"upload-month-day-reversed", '
'"release-day", '
'"release-month-day", '
'"release-month-day-reversed", '
'"download-index"'
)
with (
mock_download_collection_entries(is_youtube_channel=True, num_urls=1),
pytest.raises(UserThrownRuntimeError, match=re.escape(expected_message)),
):
self.run(
config=config,
subscription_name=subscription_name,
output_directory=output_directory,
tv_show_preset="Kodi TV Show by Date",
season_ordering=DEFAULT_SEASON_ORDERING,
episode_ordering="not-a-valid",
)
def test_invalid_season_episode_ordering_combo(
self, config, subscription_name, output_directory, mock_download_collection_entries
):
expected_message = (
"Detected incompatibility between tv_show_by_date_season_ordering "
"and tv_show_by_date_episode_ordering. Ensure you are not using both "
"upload and release date, and that the year/month/day are included in "
"the combined season and episode."
)
possible_seasons: Set[str] = set()
possible_episodes: Set[str] = set()
for season_ordering, episode_ordering in VALID_ORDERING_COMBOS:
possible_seasons.add(season_ordering)
possible_episodes.add(episode_ordering)
for season_ordering in possible_seasons:
for episode_ordering in possible_episodes:
if (season_ordering, episode_ordering) in VALID_ORDERING_COMBOS:
continue
with (
mock_download_collection_entries(is_youtube_channel=True, num_urls=1),
pytest.raises(UserThrownRuntimeError, match=re.escape(expected_message)),
):
self.run(
config=config,
subscription_name=subscription_name,
output_directory=output_directory,
tv_show_preset="Kodi TV Show by Date",
season_ordering=season_ordering,
episode_ordering=episode_ordering,
)

View file

@ -1,3 +1,4 @@
import re
from typing import Dict
from typing import List
@ -6,17 +7,11 @@ from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches
from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.prebuilt_presets.tv_show import TvShowCollectionEpisodeFormattingPresets
from ytdl_sub.prebuilt_presets.tv_show import TvShowCollectionPresets
from ytdl_sub.script.utils.exceptions import UserThrownRuntimeError
from ytdl_sub.subscriptions.subscription import Subscription
DEPRECATED_TV_SHOW_PRESET_EQUIVALENTS = {
"Kodi TV Show Collection": "kodi_tv_show_collection",
"Jellyfin TV Show Collection": "jellyfin_tv_show_collection",
"Plex TV Show Collection": "plex_tv_show_collection",
}
DEFAULT_EPISODE_ORDERING_PRESET = "season_by_collection__episode_by_year_month_day"
DEFAULT_EPISODE_ORDERING = "upload-year-month-day"
class TestPrebuiltTvShowCollectionPresets:
@ -27,22 +22,19 @@ class TestPrebuiltTvShowCollectionPresets:
subscription_name: str,
output_directory: str,
media_player_preset: str,
tv_show_structure_preset: str,
episode_ordering: str,
season_indices: List[int],
is_youtube_channel: bool = True,
):
expected_summary_name = "unit/{}/{}/s_{}/is_yt_{}".format(
DEPRECATED_TV_SHOW_PRESET_EQUIVALENTS[media_player_preset],
tv_show_structure_preset,
expected_summary_name = "integration/collection/{}/{}/s_{}/is_yt_{}".format(
media_player_preset.split(" ")[0],
episode_ordering,
len(season_indices),
int(is_youtube_channel),
)
parent_presets: List[str] = [media_player_preset, tv_show_structure_preset]
overrides: Dict[str, str] = {}
for season_index in season_indices:
parent_presets.append(f"collection_season_{season_index}")
overrides = dict(
overrides,
**{
@ -55,12 +47,13 @@ class TestPrebuiltTvShowCollectionPresets:
config=config,
preset_name=subscription_name,
preset_dict={
"preset": parent_presets,
"preset": media_player_preset,
"overrides": dict(
overrides,
**{
"tv_show_name": "Best Prebuilt TV Show Collection",
"tv_show_directory": output_directory,
"tv_show_collection_episode_ordering": episode_ordering,
},
),
},
@ -79,12 +72,10 @@ class TestPrebuiltTvShowCollectionPresets:
)
###################################### Perform reformat
reformatted_tv_show_structure_preset = (
"season_by_collection__episode_by_playlist_index_reversed"
)
reformatted_expected_summary_name = "unit/{}/{}/s_{}/is_yt_{}".format(
DEPRECATED_TV_SHOW_PRESET_EQUIVALENTS[media_player_preset],
reformatted_tv_show_structure_preset,
reformatted_tv_show_collection_episode_ordering = "playlist-index-reversed"
reformatted_expected_summary_name = "integration/collection/{}/{}/s_{}/is_yt_{}".format(
media_player_preset.split(" ")[0],
reformatted_tv_show_collection_episode_ordering,
len(season_indices),
int(is_youtube_channel),
)
@ -93,7 +84,7 @@ class TestPrebuiltTvShowCollectionPresets:
config=config,
preset_name=subscription_name,
preset_dict={
"preset": parent_presets + [reformatted_tv_show_structure_preset],
"preset": media_player_preset,
"output_options": {
"migrated_download_archive_name": ".ytdl-sub-{tv_show_name_sanitized}-download-archive.json"
},
@ -102,6 +93,7 @@ class TestPrebuiltTvShowCollectionPresets:
**{
"tv_show_name": "Best Prebuilt TV Show Collection",
"tv_show_directory": output_directory,
"tv_show_collection_episode_ordering": reformatted_tv_show_collection_episode_ordering,
},
),
},
@ -112,7 +104,7 @@ class TestPrebuiltTvShowCollectionPresets:
output_directory=output_directory,
transaction_log=reformatted_transaction_log,
transaction_log_summary_file_name=(
f"{expected_summary_name}_reformatted_to_{reformatted_tv_show_structure_preset}.txt"
f"{expected_summary_name}_reformatted_to_{reformatted_tv_show_collection_episode_ordering}.txt"
),
)
assert_expected_downloads(
@ -141,12 +133,20 @@ class TestPrebuiltTvShowCollectionPresets:
subscription_name=subscription_name,
output_directory=output_directory,
media_player_preset=media_player_preset,
tv_show_structure_preset=DEFAULT_EPISODE_ORDERING_PRESET,
episode_ordering=DEFAULT_EPISODE_ORDERING,
season_indices=season_indices,
)
@pytest.mark.parametrize(
"episode_ordering_preset", TvShowCollectionEpisodeFormattingPresets.preset_names
"episode_ordering",
[
"upload-year-month-day",
"upload-year-month-day-reversed",
"release-year-month-day",
"release-year-month-day-reversed",
"playlist-index",
"playlist-index-reversed",
],
)
@pytest.mark.parametrize("season_indices", [[1], [1, 2]])
def test_episode_ordering_presets(
@ -155,7 +155,7 @@ class TestPrebuiltTvShowCollectionPresets:
subscription_name,
output_directory,
mock_download_collection_entries,
episode_ordering_preset: str,
episode_ordering: str,
season_indices: List[int],
):
@ -167,6 +167,32 @@ class TestPrebuiltTvShowCollectionPresets:
subscription_name=subscription_name,
output_directory=output_directory,
media_player_preset="Kodi TV Show Collection",
tv_show_structure_preset=episode_ordering_preset,
episode_ordering=episode_ordering,
season_indices=season_indices,
)
def test_invalid_episode_ordering(
self, config, subscription_name, output_directory, mock_download_collection_entries
):
expected_message = (
"tv_show_collection_episode_ordering must be one of the following: "
'"upload-year-month-day", '
'"upload-year-month-day-reversed", '
'"release-year-month-day", '
'"release-year-month-day-reversed", '
'"playlist-index", '
'"playlist-index-reversed"'
)
with (
mock_download_collection_entries(is_youtube_channel=True, num_urls=1),
pytest.raises(UserThrownRuntimeError, match=re.escape(expected_message)),
):
self.run(
config=config,
subscription_name=subscription_name,
output_directory=output_directory,
media_player_preset="Kodi TV Show Collection",
episode_ordering="does-not-exist",
season_indices=[1],
)

View file

@ -2,18 +2,18 @@
".ytdl-sub-subscription_test-download-archive.json": "76e202bd03ceef93daaffffee2cfa193",
"JMC/Mock Entry 20-1.info.json": "INFO_JSON",
"JMC/Mock Entry 20-1.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"JMC/Mock Entry 20-1.mp4": "dbaeb2a3bfd1de1c7e9615e8bdacb910",
"JMC/Mock Entry 20-1.mp4": "d8dc9918d1646c92b4a4bd246291d849",
"JMC/Mock Entry 20-1.nfo": "5d001a105d2380844db181911d5df5c6",
"JMC/Mock Entry 20-2.info.json": "INFO_JSON",
"JMC/Mock Entry 20-2.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"JMC/Mock Entry 20-2.mp4": "76c2c70572489c684923a51cb1b50687",
"JMC/Mock Entry 20-2.mp4": "6f925179a5b6bf7ffcaf2f70ad585296",
"JMC/Mock Entry 20-2.nfo": "dccdded1ca7ebb38d2c9a0e5130bea33",
"JMC/Mock Entry 20-3.info.json": "INFO_JSON",
"JMC/Mock Entry 20-3.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"JMC/Mock Entry 20-3.mp4": "7bb4f21d59a6fb91836537b27a24e776",
"JMC/Mock Entry 20-3.mp4": "b48f1803f14be665677e9ac9da787593",
"JMC/Mock Entry 20-3.nfo": "7b1eab48693161e83c21203b1308b64e",
"JMC/Mock Entry 21-1.info.json": "INFO_JSON",
"JMC/Mock Entry 21-1.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"JMC/Mock Entry 21-1.mp4": "7b008531ca5660b51fb8adc83c799084",
"JMC/Mock Entry 21-1.mp4": "5fb865f27ee1cac381597d1e182dfa48",
"JMC/Mock Entry 21-1.nfo": "3b4b0e6820f80370d6738ce828f14a89"
}

View file

@ -0,0 +1,22 @@
{
"Best Prebuilt TV Show by Date/.ytdl-sub-Best Prebuilt TV Show by Date-download-archive.json": "456d8882fc5e35d74f19b386d1d9a059",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.mp4": "1d0714fb3ca4d31a0f88492a06a6142d",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.nfo": "dbe61f2c8ae41041773f713ba5376726",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.mp4": "a8f3f8580989f3927606bf6060a64e30",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.nfo": "0f071078c9fa2569bbcb998664d40681",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.mp4": "265b1104b382fd157a123842e49fcdfd",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.nfo": "837a61dca11bbe1874ea07cb8ef8a7c9",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.mp4": "09fa4599fd48836c6c0da0760c306006",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.nfo": "8ee3845c514a411425b7e9198666b61c",
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

View file

@ -0,0 +1,22 @@
{
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "e30d83126c2c6981c936ed894a6f159b",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.mp4": "c712d7fd5dfdb9bb6c2be99f7c796c6f",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.nfo": "7c0f2a9d38bf617377e474fcb9bf2bc2",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.mp4": "7ec7ac1923053c4b5653bbfb8924fdc6",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.nfo": "1f4dec756555ee345cd9d6489b63e070",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.mp4": "414c243016f406451c790757949173aa",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.nfo": "f6f704ae3ae0c4055590a147eb425609",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.mp4": "e2f858efd08ac82749fd01db65d8341e",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.nfo": "c6e0d368bfe9d70134e5a2ff2d3dccc6",
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

View file

@ -0,0 +1,22 @@
{
"Best Prebuilt TV Show by Date/.ytdl-sub-Best Prebuilt TV Show by Date-download-archive.json": "456d8882fc5e35d74f19b386d1d9a059",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.mp4": "1d0714fb3ca4d31a0f88492a06a6142d",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.nfo": "dbe61f2c8ae41041773f713ba5376726",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.mp4": "a8f3f8580989f3927606bf6060a64e30",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.nfo": "0f071078c9fa2569bbcb998664d40681",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.mp4": "265b1104b382fd157a123842e49fcdfd",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.nfo": "837a61dca11bbe1874ea07cb8ef8a7c9",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.mp4": "09fa4599fd48836c6c0da0760c306006",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.nfo": "8ee3845c514a411425b7e9198666b61c",
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

View file

@ -0,0 +1,22 @@
{
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "e30d83126c2c6981c936ed894a6f159b",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.mp4": "c712d7fd5dfdb9bb6c2be99f7c796c6f",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.nfo": "7c0f2a9d38bf617377e474fcb9bf2bc2",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.mp4": "7ec7ac1923053c4b5653bbfb8924fdc6",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.nfo": "1f4dec756555ee345cd9d6489b63e070",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.mp4": "414c243016f406451c790757949173aa",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.nfo": "f6f704ae3ae0c4055590a147eb425609",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.mp4": "e2f858efd08ac82749fd01db65d8341e",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.nfo": "c6e0d368bfe9d70134e5a2ff2d3dccc6",
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

View file

@ -0,0 +1,22 @@
{
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "95d3790190cece6e49d2f4d5f68868b1",
"Best Prebuilt TV Show by Date/Season 200008/s200008.e0701 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 200008/s200008.e0701 - Mock Entry 20-3.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 200008/s200008.e0701 - Mock Entry 20-3.mp4": "6dd6860e8075e5e99c85e14729c41bdb",
"Best Prebuilt TV Show by Date/Season 200008/s200008.e0701 - Mock Entry 20-3.nfo": "92d978e6f64d97e7e71e7045c9d2fb1f",
"Best Prebuilt TV Show by Date/Season 200008/s200008.e0801 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 200008/s200008.e0801 - Mock Entry 20-2.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 200008/s200008.e0801 - Mock Entry 20-2.mp4": "6b3537db708f88a877d3c5273df02eb0",
"Best Prebuilt TV Show by Date/Season 200008/s200008.e0801 - Mock Entry 20-2.nfo": "b1a3768f43d0f7f4dde1207a8670f9b1",
"Best Prebuilt TV Show by Date/Season 200008/s200008.e0802 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 200008/s200008.e0802 - Mock Entry 20-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 200008/s200008.e0802 - Mock Entry 20-1.mp4": "7e40cf2da8eedd93b3df9a017c78cd2e",
"Best Prebuilt TV Show by Date/Season 200008/s200008.e0802 - Mock Entry 20-1.nfo": "ea37e0d89713f971a87ddf67f8933be1",
"Best Prebuilt TV Show by Date/Season 200108/s200108.e0801 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 200108/s200108.e0801 - Mock Entry 21-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 200108/s200108.e0801 - Mock Entry 21-1.mp4": "c14c0b1901c456e87e1070539d32917e",
"Best Prebuilt TV Show by Date/Season 200108/s200108.e0801 - Mock Entry 21-1.nfo": "4e18e9e0454bc948cf8daa0151a0b137",
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

View file

@ -0,0 +1,22 @@
{
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "d2888d629be62badedce415f8296a995",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e000001 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e000001 - Mock Entry 20-3.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e000001 - Mock Entry 20-3.mp4": "f94c693da1964bd8e916459253ede6d4",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e000001 - Mock Entry 20-3.nfo": "57b7ef31d0b9948bd67ba6a7c83dd22b",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e000002 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e000002 - Mock Entry 20-2.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e000002 - Mock Entry 20-2.mp4": "68a76930c4456ab58f30ad1a3eb109aa",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e000002 - Mock Entry 20-2.nfo": "1cc84f0bbe25315268dc453db8941c7f",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e000003 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e000003 - Mock Entry 20-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e000003 - Mock Entry 20-1.mp4": "b24b301ec95f359ba28ca503e678522f",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e000003 - Mock Entry 20-1.nfo": "e77cc16953d13241f83fbd01792c15ba",
"Best Prebuilt TV Show by Date/Season 2001/s2001.e000004 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2001/s2001.e000004 - Mock Entry 21-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2001/s2001.e000004 - Mock Entry 21-1.mp4": "cf36da70822036f06e5c834dac291dd3",
"Best Prebuilt TV Show by Date/Season 2001/s2001.e000004 - Mock Entry 21-1.nfo": "cbd1abadd656f6d047775969604e9af4",
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

View file

@ -0,0 +1,22 @@
{
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "1e8b4072d6d6aace4cac0c94ac0e711e",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e14698 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e14698 - Mock Entry 20-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e14698 - Mock Entry 20-1.mp4": "f4846a3b9ee6a16df3348916077d6476",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e14698 - Mock Entry 20-1.nfo": "ad715f955b255f356b6263f06941bf05",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e14699 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e14699 - Mock Entry 20-2.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e14699 - Mock Entry 20-2.mp4": "82a1f043b879412d6219e16993b8b188",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e14699 - Mock Entry 20-2.nfo": "2fa884c057a44122a245efe3c33921cd",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e14799 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e14799 - Mock Entry 20-3.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e14799 - Mock Entry 20-3.mp4": "6626433c9c3399c6403ba5753e3b31c2",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e14799 - Mock Entry 20-3.nfo": "63f4405964a11f99ff1595cc18266489",
"Best Prebuilt TV Show by Date/Season 2001/s2001.e14699 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2001/s2001.e14699 - Mock Entry 21-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2001/s2001.e14699 - Mock Entry 21-1.mp4": "bac622134b8f65eaeccce6f3b822be64",
"Best Prebuilt TV Show by Date/Season 2001/s2001.e14699 - Mock Entry 21-1.nfo": "6b9659879d7a1b9f5e2dccee70d7fe66",
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

View file

@ -0,0 +1,22 @@
{
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "f13a43a83e725981df8417ab1d36705a",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e080701 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e080701 - Mock Entry 20-3.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e080701 - Mock Entry 20-3.mp4": "219480c0ee2075ed86ccba740cca3d70",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e080701 - Mock Entry 20-3.nfo": "6085b15cd1a32e8b45b97cee69ca4741",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e080801 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e080801 - Mock Entry 20-2.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e080801 - Mock Entry 20-2.mp4": "7ed6abcf342149769ea2de23a0fee0a1",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e080801 - Mock Entry 20-2.nfo": "599b687960602264db2f7f44dbd2f849",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e080802 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e080802 - Mock Entry 20-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e080802 - Mock Entry 20-1.mp4": "1431320eb3acc337b1c70a895362397a",
"Best Prebuilt TV Show by Date/Season 2000/s2000.e080802 - Mock Entry 20-1.nfo": "6da492b1897671a3309df38e6fa33279",
"Best Prebuilt TV Show by Date/Season 2001/s2001.e080801 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2001/s2001.e080801 - Mock Entry 21-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2001/s2001.e080801 - Mock Entry 21-1.mp4": "5c7f600b9a40039ea77729deda8d103f",
"Best Prebuilt TV Show by Date/Season 2001/s2001.e080801 - Mock Entry 21-1.nfo": "294fb0959c2cf640312f112c0a7aae27",
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

View file

@ -0,0 +1,22 @@
{
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "5269b5e9e4fad7b63b6fdb3fef477b1e",
"Best Prebuilt TV Show by Date/Season 202008/s202008.e0701 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 202008/s202008.e0701 - Mock Entry 20-3.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 202008/s202008.e0701 - Mock Entry 20-3.mp4": "b095358d6b335a25f3be3ae44a6d10ac",
"Best Prebuilt TV Show by Date/Season 202008/s202008.e0701 - Mock Entry 20-3.nfo": "4ba0e01c1e7295952c3f5e6b9b251d7d",
"Best Prebuilt TV Show by Date/Season 202008/s202008.e0801 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 202008/s202008.e0801 - Mock Entry 20-2.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 202008/s202008.e0801 - Mock Entry 20-2.mp4": "43f73b6f99439e4873bf8d4ba1802452",
"Best Prebuilt TV Show by Date/Season 202008/s202008.e0801 - Mock Entry 20-2.nfo": "db9196b729c7b2cd26e3f0fc649b39f0",
"Best Prebuilt TV Show by Date/Season 202008/s202008.e0802 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 202008/s202008.e0802 - Mock Entry 20-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 202008/s202008.e0802 - Mock Entry 20-1.mp4": "632af351287dde4d1a096460f6bd93b1",
"Best Prebuilt TV Show by Date/Season 202008/s202008.e0802 - Mock Entry 20-1.nfo": "ba4a387d9462f416ba176c31c1c55316",
"Best Prebuilt TV Show by Date/Season 202108/s202108.e0801 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 202108/s202108.e0801 - Mock Entry 21-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 202108/s202108.e0801 - Mock Entry 21-1.mp4": "bf0104b783e168b150ea165faa898915",
"Best Prebuilt TV Show by Date/Season 202108/s202108.e0801 - Mock Entry 21-1.nfo": "93dd5c4c493422f9a60e61a6b965b39a",
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

View file

@ -0,0 +1,22 @@
{
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "456d8882fc5e35d74f19b386d1d9a059",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.mp4": "1d0714fb3ca4d31a0f88492a06a6142d",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.nfo": "dbe61f2c8ae41041773f713ba5376726",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.mp4": "a8f3f8580989f3927606bf6060a64e30",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.nfo": "0f071078c9fa2569bbcb998664d40681",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.mp4": "265b1104b382fd157a123842e49fcdfd",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.nfo": "837a61dca11bbe1874ea07cb8ef8a7c9",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.mp4": "09fa4599fd48836c6c0da0760c306006",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.nfo": "8ee3845c514a411425b7e9198666b61c",
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

View file

@ -0,0 +1,22 @@
{
"Best Prebuilt TV Show by Date/.ytdl-sub-Best Prebuilt TV Show by Date-download-archive.json": "456d8882fc5e35d74f19b386d1d9a059",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.mp4": "1d0714fb3ca4d31a0f88492a06a6142d",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.nfo": "dbe61f2c8ae41041773f713ba5376726",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.mp4": "a8f3f8580989f3927606bf6060a64e30",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.nfo": "0f071078c9fa2569bbcb998664d40681",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.mp4": "265b1104b382fd157a123842e49fcdfd",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.nfo": "837a61dca11bbe1874ea07cb8ef8a7c9",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.mp4": "09fa4599fd48836c6c0da0760c306006",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.nfo": "8ee3845c514a411425b7e9198666b61c",
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

View file

@ -0,0 +1,22 @@
{
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "05539b932348da7e949a63eb71f77efc",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.mp4": "c27d1898d39763a265e4fe4451777688",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.nfo": "88fee5353ad7eecd96865b34654bbde6",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.mp4": "80c087803d5ac9c0efe02361fdaaa5bd",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.nfo": "d781b80ef24916acbf3bf181ab32ddea",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.mp4": "1b5084c06b33f7e0d6daf7cff03adc73",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.nfo": "252b3639b4d1686f8c79476943f89439",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.mp4": "a83870aaca55947b7a91ac90109163af",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.nfo": "e7374b766d9ec0599ecf209a0f78c0ba",
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

View file

@ -0,0 +1,22 @@
{
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "e30d83126c2c6981c936ed894a6f159b",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.mp4": "c712d7fd5dfdb9bb6c2be99f7c796c6f",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.nfo": "7c0f2a9d38bf617377e474fcb9bf2bc2",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.mp4": "7ec7ac1923053c4b5653bbfb8924fdc6",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.nfo": "1f4dec756555ee345cd9d6489b63e070",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.mp4": "414c243016f406451c790757949173aa",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.nfo": "f6f704ae3ae0c4055590a147eb425609",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.mp4": "e2f858efd08ac82749fd01db65d8341e",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.nfo": "c6e0d368bfe9d70134e5a2ff2d3dccc6",
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

View file

@ -0,0 +1,17 @@
{
"Best Prebuilt TV Show by Date/.ytdl-sub-Best Prebuilt TV Show by Date-download-archive.json": "0011c85c3339ac1301d80be2f3330e67",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry --thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry -.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry -.mp4": "1d0714fb3ca4d31a0f88492a06a6142d",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry --thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry -.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry -.mp4": "a8f3f8580989f3927606bf6060a64e30",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry --thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry -.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry -.mp4": "265b1104b382fd157a123842e49fcdfd",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry --thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry -.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry -.mp4": "09fa4599fd48836c6c0da0760c306006",
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a"
}

View file

@ -0,0 +1,17 @@
{
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "6cd67e6b935bc5b2521f6ed0fa2ea2ed",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry --thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry -.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry -.mp4": "c712d7fd5dfdb9bb6c2be99f7c796c6f",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry --thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry -.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry -.mp4": "7ec7ac1923053c4b5653bbfb8924fdc6",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry --thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry -.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry -.mp4": "414c243016f406451c790757949173aa",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry --thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry -.info.json": "INFO_JSON",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry -.mp4": "e2f858efd08ac82749fd01db65d8341e",
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a"
}

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