├── settings.toml
├── .python-version
├── __init__.py
├── tonie_podcast_sync
├── __init__.py
├── constants.py
├── config.py
├── podcast.py
├── cli.py
└── toniepodcastsync.py
├── ressources
├── tps.gif
└── tps_asciinema.cast
├── AGENT.md
├── .github
└── workflows
│ ├── test_publish.yml
│ ├── format_linting.yml
│ └── run_tests.yml
├── .pre-commit-config.yaml
├── LICENSE
├── pyproject.toml
├── mise.toml
├── .all-contributorsrc
├── tests
├── res
│ ├── responses.yaml
│ └── pumuckl.xml
├── test_episode_duration_handling.py
├── test_podcast_sync.py
├── test_download_retry_logic.py
├── test_performance_optimizations.py
├── test_cli_episode_max_duration.py
├── test_podcast.py
├── test_download_fallback.py
├── test_upload_failure_handling.py
├── test_reshuffle_logic.py
└── test_episode_selection_with_max_duration.py
├── .gitignore
└── README.md
/settings.toml:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
1 | 3.10.11
2 |
--------------------------------------------------------------------------------
/__init__.py:
--------------------------------------------------------------------------------
1 | """empty init."""
2 |
--------------------------------------------------------------------------------
/tonie_podcast_sync/__init__.py:
--------------------------------------------------------------------------------
1 | # ruff: noqa: D104
2 |
--------------------------------------------------------------------------------
/ressources/tps.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexhartm/tonie-podcast-sync/HEAD/ressources/tps.gif
--------------------------------------------------------------------------------
/tonie_podcast_sync/constants.py:
--------------------------------------------------------------------------------
1 | """Shared constants for tonie-podcast-sync."""
2 |
3 | MAXIMUM_TONIE_MINUTES = 90
4 | DOWNLOAD_RETRY_COUNT = 3
5 | UPLOAD_RETRY_COUNT = 3
6 | RETRY_DELAY_SECONDS = 3
7 | MAX_SHUFFLE_ATTEMPTS = 5
8 |
--------------------------------------------------------------------------------
/AGENT.md:
--------------------------------------------------------------------------------
1 | # Agent Instructions for tonie-podcast-sync
2 |
3 | ## Project Overview
4 | tonie-podcast-sync is a Python package that syncs podcast episodes to creative tonies (Toniebox). It downloads podcast episodes and uploads them to Tonie Cloud, which then syncs to physical Tonie figurines.
5 |
--------------------------------------------------------------------------------
/tonie_podcast_sync/config.py:
--------------------------------------------------------------------------------
1 | """The configuration module for the tonie_podcast_sync."""
2 |
3 | from pathlib import Path
4 |
5 | from dynaconf import Dynaconf
6 |
7 | APP_NAME = "tonie-podcast-sync-cli"
8 | APP_SETTINGS_DIR = Path.home() / ".toniepodcastsync"
9 |
10 | settings = Dynaconf(
11 | envvar_prefix="TPS",
12 | root_path=str(APP_SETTINGS_DIR),
13 | settings_files=["settings.toml", ".secrets.toml"],
14 | )
15 |
16 | # `envvar_prefix` = export envvars with `export TPS_FOO=bar`.
17 | # `settings_files` = Load these files in the order.
18 |
--------------------------------------------------------------------------------
/.github/workflows/test_publish.yml:
--------------------------------------------------------------------------------
1 | name: Build and publish python package (Test PyPI)
2 |
3 | on:
4 | workflow_dispatch:
5 |
6 | jobs:
7 | publish-service-client-package:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Publish PyPi package (Test PyPI)
11 | uses: code-specialist/pypi-poetry-publish@v1
12 | with:
13 | PACKAGE_DIRECTORY: "./"
14 | PYTHON_VERSION: "3.11"
15 | ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
16 | PUBLISH_REGISTRY_PASSWORD: ${{ secrets.TEST_PYPI_TOKEN }}
17 | PUBLISH_REGISTRY: "https://test.pypi.org/legacy/"
18 |
--------------------------------------------------------------------------------
/.github/workflows/format_linting.yml:
--------------------------------------------------------------------------------
1 | name: Format and Lint
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | strategy:
13 | matrix:
14 | python-version: ["3.11"]
15 |
16 | steps:
17 | - uses: actions/checkout@v4
18 | - name: Install uv
19 | uses: astral-sh/setup-uv@v6
20 | - name: Install dependencies
21 | run: uv sync --locked --all-extras --dev
22 | - name: Lint with ruff
23 | run: |
24 | uv run ruff check .
25 | - name: Format with ruff
26 | run: |
27 | uv run ruff format --check --diff
28 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/pre-commit/pre-commit-hooks
3 | rev: v4.4.0
4 | hooks:
5 | - id: check-yaml
6 | - id: end-of-file-fixer
7 | - id: trailing-whitespace
8 | - repo: https://github.com/astral-sh/uv-pre-commit
9 | # uv version.
10 | rev: 0.7.4
11 | hooks:
12 | - id: uv-lock
13 | - repo: local
14 | hooks:
15 | - id: ruff format
16 | name: ruff format
17 | entry: ruff format --check --diff
18 | language: system
19 | types: [python]
20 | - id: ruff check
21 | name: ruff check
22 | entry: ruff check .
23 | language: system
24 | types: [python]
25 |
--------------------------------------------------------------------------------
/.github/workflows/run_tests.yml:
--------------------------------------------------------------------------------
1 | name: Run Tests
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | jobs:
10 | tests:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v4
15 | - name: Install en_US.UTF-8 locale
16 | run: |
17 | sudo locale-gen en_US.UTF-8
18 | sudo update-locale LANG=en_US.UTF-8
19 | - name: install ffmpeg dependency
20 | run: |
21 | sudo apt-get update
22 | sudo apt-get install --yes --no-install-recommends \
23 | ffmpeg
24 | - name: Install uv
25 | uses: astral-sh/setup-uv@v6
26 | - name: Install dependencies
27 | run: uv sync --locked --all-extras --dev
28 | - name: Run unit tests
29 | run: uv run pytest tests/
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Alexander Hartmann
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "tonie-podcast-sync"
3 | version = "3.3.3"
4 | description = "allows synching podcast episodes to creative tonies"
5 | authors = [{ name = "Alexander Hartmann", email = "16985220+alexhartm@users.noreply.github.com" }]
6 | license = "MIT"
7 | readme = "README.md"
8 | requires-python = ">=3.10"
9 | keywords = ["toniebox", "podcast"]
10 | dependencies = [
11 | "feedparser>=6.0.10",
12 | "tonie-api>=0.1.1",
13 | "rich>=13.5.2",
14 | "pathvalidate>=3.2.0",
15 | "pydub>=0.25.1",
16 | "python-slugify>=8.0.1",
17 | "dynaconf>=3.2.3",
18 | "typer>=0.16.0",
19 | "tomli-w>=1.0.0",
20 | "audioop-lts>=0.2.0; python_version >= '3.13'",
21 | ]
22 |
23 | [project.scripts]
24 | tonie-podcast-sync = "tonie_podcast_sync.cli:app"
25 |
26 | [project.optional-dependencies]
27 | dev = [
28 | "pytest>=7.4.0",
29 | "pytest-mock>=3.11.1",
30 | "ruff>=0.1.13",
31 | "pre-commit>=3.6.0",
32 | "responses>=0.23.3",
33 | ]
34 |
35 | [tool.ruff]
36 | # Add "Q" to the list of enabled codes.
37 | lint.select = ["ALL"]
38 | lint.ignore = ["D105", "N999", "COM812"]
39 | line-length = 120
40 | target-version = "py310"
41 |
42 | [tool.ruff.lint.pydocstyle]
43 | convention = "google"
44 |
45 | [tool.ruff.lint.flake8-quotes]
46 | docstring-quotes = "double"
47 |
48 | [tool.ruff.lint.per-file-ignores]
49 | "tests/*.py" = [
50 | "D",
51 | "A",
52 | "ANN",
53 | "S",
54 | "SLF",
55 | "PLR2004",
56 | "ARG002",
57 | "INP001",
58 | "FLY",
59 | ]
60 |
61 | [build-system]
62 | requires = ["hatchling"]
63 | build-backend = "hatchling.build"
64 |
--------------------------------------------------------------------------------
/mise.toml:
--------------------------------------------------------------------------------
1 | [env]
2 | # Use the project name derived from the current directory
3 | PROJECT_NAME = "{{ config_root | basename }}"
4 | _.file = '.env'
5 |
6 | # Automatic virtualenv activation
7 | _.python.venv = { path = ".venv", create = true }
8 |
9 | [tools]
10 | python = "3.10"
11 | poetry = "latest"
12 | ruff = "latest"
13 | yarn = "latest"
14 |
15 | [tasks.install]
16 | description = "Install dependencies"
17 | alias = "i"
18 | run = "uv sync"
19 |
20 | [tasks.info]
21 | description = "Print project information"
22 | run = '''
23 | echo "Project: $PROJECT_NAME"
24 | echo "Virtual Environment: $VIRTUAL_ENV"
25 | '''
26 |
27 | [tasks.setup]
28 | description = "Setup the project"
29 | # since mise creates a virtualenv, we can use uv to install dependencies
30 | run = '''
31 | uv sync
32 | uv pip install -e ."[dev]"
33 | uv run pre-commit install
34 | '''
35 |
36 | [tasks.contributors-check]
37 | description = "run all-contributors CLI"
38 | run = 'npm exec all-contributors-cli'
39 |
40 | [tasks.test-unittests]
41 | description = "Run unit tests"
42 | alias = ["ut", "tests", "test"]
43 | run = '''
44 | uv pip install -e .[dev]
45 | uv run pytest tests/
46 | '''
47 |
48 | [tasks.build]
49 | depends = ["lint", "format"]
50 | description = "Build"
51 | run = '''
52 | uv sync
53 | uv build
54 | '''
55 |
56 | [tasks.format]
57 | description = "Format the code"
58 | run = "ruff format --check --diff"
59 |
60 | [tasks.lint]
61 | description = "Lint the code"
62 | run = "ruff check ."
63 |
64 | [tasks.test-publish-release]
65 | description = "Test the release process. Build first, then publish to test.pypi.org"
66 | depends = ["build"]
67 | run = '''
68 | source .env
69 | uv publish \
70 | --username $UV_PUBLISH_USERNAME \
71 | --password $UV_PUBLISH_PASSWORD_TEST \
72 | --publish-url https://test.pypi.org/legacy/
73 | '''
74 |
75 | [tasks.publish-release]
76 | description = "Release builds to PyPI"
77 | run = '''
78 | source .env
79 | uv publish \
80 | --username $UV_PUBLISH_USERNAME \
81 | --password $UV_PUBLISH_PASSWORD_PROD \
82 | --publish-url https://upload.pypi.org/legacy/
83 | '''
84 |
--------------------------------------------------------------------------------
/.all-contributorsrc:
--------------------------------------------------------------------------------
1 | {
2 | "projectName": "tonie-podcast-sync",
3 | "projectOwner": "alexhartm",
4 | "repoType": "github",
5 | "repoHost": "https://github.com",
6 | "files": [
7 | "README.md"
8 | ],
9 | "imageSize": 100,
10 | "commit": false,
11 | "commitConvention": "atom",
12 | "contributors": [
13 | {
14 | "login": "alexhartm",
15 | "name": "Alexander Hartmann",
16 | "avatar_url": "https://avatars.githubusercontent.com/u/16985220?v=4",
17 | "profile": "https://github.com/alexhartm",
18 | "contributions": [
19 | "code",
20 | "ideas",
21 | "maintenance"
22 | ]
23 | },
24 | {
25 | "login": "Wilhelmsson177",
26 | "name": "Wilhelmsson177",
27 | "avatar_url": "https://avatars.githubusercontent.com/u/16141053?v=4",
28 | "profile": "https://github.com/Wilhelmsson177",
29 | "contributions": [
30 | "code",
31 | "ideas",
32 | "maintenance",
33 | "test"
34 | ]
35 | },
36 | {
37 | "login": "maltebaer",
38 | "name": "Malte Bär",
39 | "avatar_url": "https://avatars.githubusercontent.com/u/29504917?v=4",
40 | "profile": "https://cv.maltebaer.vercel.app/",
41 | "contributions": [
42 | "bug"
43 | ]
44 | },
45 | {
46 | "login": "einvalentin",
47 | "name": "Valentin v. Seggern",
48 | "avatar_url": "https://avatars.githubusercontent.com/u/230592?v=4",
49 | "profile": "https://github.com/einvalentin",
50 | "contributions": [
51 | "code"
52 | ]
53 | },
54 | {
55 | "login": "stefan14808",
56 | "name": "stefan14808",
57 | "avatar_url": "https://avatars.githubusercontent.com/u/79793534?v=4",
58 | "profile": "https://github.com/stefan14808",
59 | "contributions": [
60 | "code",
61 | "ideas"
62 | ]
63 | },
64 | {
65 | "login": "goldbricklemon",
66 | "name": "GoldBrickLemon",
67 | "avatar_url": "https://avatars.githubusercontent.com/u/9368670?v=4",
68 | "profile": "https://github.com/goldbricklemon",
69 | "contributions": [
70 | "bug",
71 | "code"
72 | ]
73 | }
74 | ],
75 | "contributorsPerLine": 7,
76 | "linkToUsage": false
77 | }
78 |
--------------------------------------------------------------------------------
/tests/res/responses.yaml:
--------------------------------------------------------------------------------
1 | responses:
2 | - response:
3 | auto_calculate_content_length: false
4 | body: "ID3\x03\0\0\0\0\x04WTDAT\0\0\0\v\0\0\x01\uFFFD\uFFFD2\02\00\08\0TYER\0\0\
5 | \0\v\0\0\x01\uFFFD\uFFFD2\00\02\03\0TLAN\0\0\0\t\0\0\x01\uFFFD\uFFFDD\0E\0U\0\
6 | \04\uFFFD\0\0\x04UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU"
7 | content_type: text/plain; charset=utf-8
8 | method: GET
9 | status: 200
10 | url: https://podcast-mp3.dradio.de/podcast/2023/08/22/muss_ich_mich_vor_hexen_fuerchten_neu_drk_20230822_1520_80d53d3b.mp3?refId=kakadu-104
11 | - response:
12 | auto_calculate_content_length: false
13 | body: "ID3\x03\0\0\0\0\x06!TDAT\0\0\0\v\0\0\x01\uFFFD\uFFFD1\07\00\08\0TYER\0\0\
14 | \0\v\0\0\x01\uFFFD\uFFFD2\00\02\03\0TLAN\0\0\0\t\0\0\x01\uFFFD\uFFFDD\0E\0U\0\
15 | \uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\
16 | \uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD"
17 | content_type: text/plain; charset=utf-8
18 | method: GET
19 | status: 200
20 | url: https://podcast-mp3.dradio.de/podcast/2023/08/17/kakadu_update_17082023_wie_entstehen_steine_kopfweh_durch_drk_20230817_1400_b618758c.mp3?refId=kakadu-104
21 | - response:
22 | auto_calculate_content_length: false
23 | body: "ID3\x03\0\0\0\0\x05\aTDAT\0\0\0\v\0\0\x01\uFFFD\uFFFD1\05\00\08\0TYER\0\
24 | \0\0\v\0\0\x01\uFFFD\uFFFD2\00\02\03\0TLAN\0\0\0\t\0\0\x01\uFFFD\uFFFDD\0E\0\
25 | U\0TALB\0\0\0\x0F\0\0\x01\uFFFD\uFFFDK\0A\0K\0A\0D\0U\0TIT2\0\0\0m\0\0\x01\uFFFD\
26 | \uFFFD\uFFFDd@\uFFFD\uFFFD\0\0i\0\0\0\b\0\0\r \0\0\x01\0\0\x01\uFFFD\0\0\0 \0\
27 | \04\uFFFD\0\0\x04UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU"
28 | content_type: text/plain; charset=utf-8
29 | method: GET
30 | status: 200
31 | url: https://podcast-mp3.dradio.de/podcast/2023/08/15/kakadu_podcast_warum_spielen_wir_voe_am_15082023_drk_20230815_1400_26ceac12.mp3?refId=kakadu-104
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | pip-wheel-metadata/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .nox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | *.py,cover
51 | .hypothesis/
52 | .pytest_cache/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | target/
76 |
77 | # Jupyter Notebook
78 | .ipynb_checkpoints
79 |
80 | # IPython
81 | profile_default/
82 | ipython_config.py
83 |
84 | # pyenv
85 | .python-version
86 |
87 | # direnv
88 | .envrc
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
98 | __pypackages__/
99 |
100 | # Celery stuff
101 | celerybeat-schedule
102 | celerybeat.pid
103 |
104 | # SageMath parsed files
105 | *.sage.py
106 |
107 | # Environments
108 | .env
109 | .venv
110 | env/
111 | venv/
112 | ENV/
113 | env.bak/
114 | venv.bak/
115 |
116 | # Spyder project settings
117 | .spyderproject
118 | .spyproject
119 |
120 | # Rope project settings
121 | .ropeproject
122 |
123 | # mkdocs documentation
124 | /site
125 |
126 | # mypy
127 | .mypy_cache/
128 | .dmypy.json
129 | dmypy.json
130 |
131 | # Pyre type checker
132 | .pyre/
133 |
134 | # Other
135 | mise.local.toml
136 | .ruff_cache/
137 | .vscode/
138 | *.code-workspace
139 | /test*.py
140 | todo.md
141 | !.pre-commit-config.yaml
142 |
143 | # node stuff
144 | node_modules/
145 | package-lock.json
146 | package.json
147 |
148 | # vscode
149 | .vscode
150 | # Ignore dynaconf secret files
151 | .secrets.*
152 |
--------------------------------------------------------------------------------
/tests/test_episode_duration_handling.py:
--------------------------------------------------------------------------------
1 | """Test for missing or malformed duration handling in Episode class."""
2 |
3 | from tonie_podcast_sync.podcast import Episode
4 |
5 |
6 | def test_episode_missing_duration_should_default_to_zero():
7 | """
8 | Test that episodes without itunes_duration field don't crash.
9 |
10 | Bug: If an RSS feed doesn't include itunes_duration (which is optional in
11 | the RSS spec), Episode.__init__ raises KeyError and crashes the entire sync.
12 |
13 | Real RSS feeds in the wild may not always include duration information.
14 | The code should handle this gracefully with a sensible default.
15 | """
16 | test_feed_data = {
17 | "title": "Episode Without Duration",
18 | "published": "Mon, 01 Jan 2024 10:00:00 +0000",
19 | "published_parsed": (2024, 1, 1, 10, 0, 0, 0, 1, 0),
20 | "id": "test-guid-no-duration",
21 | # itunes_duration is missing
22 | }
23 |
24 | # Should not raise exception, should default to 0
25 | ep = Episode(podcast="Test Podcast", raw=test_feed_data, url="http://example.com/test.mp3")
26 |
27 | assert ep.duration_sec == 0, "Missing duration should default to 0 seconds"
28 | assert ep.duration_str == "0", "Missing duration string should default to '0'"
29 |
30 |
31 | def test_episode_empty_duration_should_default_to_zero():
32 | """Test that empty duration strings are handled gracefully."""
33 | test_feed_data = {
34 | "title": "Episode With Empty Duration",
35 | "published": "Mon, 01 Jan 2024 10:00:00 +0000",
36 | "published_parsed": (2024, 1, 1, 10, 0, 0, 0, 1, 0),
37 | "id": "test-guid-empty-duration",
38 | "itunes_duration": "", # Empty string
39 | }
40 |
41 | ep = Episode(podcast="Test Podcast", raw=test_feed_data, url="http://example.com/test.mp3")
42 |
43 | assert ep.duration_sec == 0, "Empty duration should default to 0 seconds"
44 |
45 |
46 | def test_episode_malformed_duration_should_default_to_zero():
47 | """Test that malformed duration strings are handled gracefully."""
48 | test_feed_data = {
49 | "title": "Episode With Malformed Duration",
50 | "published": "Mon, 01 Jan 2024 10:00:00 +0000",
51 | "published_parsed": (2024, 1, 1, 10, 0, 0, 0, 1, 0),
52 | "id": "test-guid-malformed",
53 | "itunes_duration": "invalid::time::format",
54 | }
55 |
56 | ep = Episode(podcast="Test Podcast", raw=test_feed_data, url="http://example.com/test.mp3")
57 |
58 | assert ep.duration_sec == 0, "Malformed duration should default to 0 seconds"
59 |
60 |
61 | def test_episode_valid_duration_formats():
62 | """Test that valid duration formats are parsed correctly."""
63 | test_cases = [
64 | ("30", 30), # Seconds only
65 | ("5:30", 330), # Minutes:seconds
66 | ("1:05:30", 3930), # Hours:minutes:seconds
67 | ]
68 |
69 | for duration_str, expected_seconds in test_cases:
70 | test_feed_data = {
71 | "title": f"Episode {duration_str}",
72 | "published": "Mon, 01 Jan 2024 10:00:00 +0000",
73 | "published_parsed": (2024, 1, 1, 10, 0, 0, 0, 1, 0),
74 | "id": f"test-guid-{duration_str}",
75 | "itunes_duration": duration_str,
76 | }
77 |
78 | ep = Episode(podcast="Test Podcast", raw=test_feed_data, url="http://example.com/test.mp3")
79 |
80 | assert ep.duration_sec == expected_seconds, (
81 | f"Duration '{duration_str}' should parse to {expected_seconds} seconds, got {ep.duration_sec}"
82 | )
83 |
84 |
85 | def test_episode_non_numeric_duration_should_default_to_zero():
86 | """Test that non-numeric duration values are handled gracefully."""
87 | test_feed_data = {
88 | "title": "Episode With Text Duration",
89 | "published": "Mon, 01 Jan 2024 10:00:00 +0000",
90 | "published_parsed": (2024, 1, 1, 10, 0, 0, 0, 1, 0),
91 | "id": "test-guid-text",
92 | "itunes_duration": "about 30 minutes", # Text instead of numbers
93 | }
94 |
95 | ep = Episode(podcast="Test Podcast", raw=test_feed_data, url="http://example.com/test.mp3")
96 |
97 | assert ep.duration_sec == 0, "Non-numeric duration should default to 0 seconds"
98 |
--------------------------------------------------------------------------------
/tests/test_podcast_sync.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import locale
3 | from unittest import mock
4 |
5 | import pytest
6 | import responses
7 | from tonie_api.models import Chapter, CreativeTonie, Household
8 |
9 | from tonie_podcast_sync.podcast import Podcast
10 | from tonie_podcast_sync.toniepodcastsync import ToniePodcastSync
11 |
12 | locale.setlocale(locale.LC_TIME, "en_US.UTF-8") # is only set for consistent tests
13 |
14 | HOUSEHOLD = Household(id="1234", name="My House", ownerName="John", access="owner", canLeave=True)
15 | CHAPTER_1 = Chapter(id="chap-1", title="The great chapter", file="123456789A", seconds=4711, transcoding=False)
16 | CHAPTER_2 = Chapter(id="chap-2", title="The second chapter", file="223456789A", seconds=73, transcoding=False)
17 |
18 | TONIE_1 = CreativeTonie(
19 | id="42",
20 | householdId="1234",
21 | name="Tonie #1",
22 | imageUrl="http://example.com/img.png",
23 | secondsRemaining=90 * 60,
24 | secondsPresent=0,
25 | chaptersPresent=0,
26 | chaptersRemaining=99,
27 | transcoding=False,
28 | lastUpdate=None,
29 | chapters=[],
30 | )
31 | TONIE_2 = CreativeTonie(
32 | id="73",
33 | householdId="1234",
34 | name="Tonie #2",
35 | imageUrl="http://example.com/img-1.png",
36 | secondsRemaining=90 * 60 - CHAPTER_1.seconds - CHAPTER_2.seconds,
37 | secondsPresent=CHAPTER_1.seconds + CHAPTER_2.seconds,
38 | chaptersPresent=2,
39 | chaptersRemaining=97,
40 | transcoding=False,
41 | lastUpdate=datetime.datetime(2016, 11, 25, 12, 00, tzinfo=datetime.timezone.utc),
42 | chapters=[CHAPTER_1, CHAPTER_2],
43 | )
44 |
45 |
46 | def _get_tonie_api_mock() -> mock.MagicMock:
47 | tonie_api_mock = mock.MagicMock()
48 | tonie_api_mock.get_households.return_value = [
49 | HOUSEHOLD,
50 | ]
51 | tonie_api_mock.get_all_creative_tonies.return_value = [TONIE_1, TONIE_2]
52 | return tonie_api_mock
53 |
54 |
55 | @pytest.fixture
56 | def mocked_tonie_api():
57 | with mock.patch("tonie_podcast_sync.toniepodcastsync.TonieAPI") as _mock:
58 | yield _mock
59 |
60 |
61 | @pytest.fixture
62 | def mocked_responses():
63 | with responses.RequestsMock() as rsps:
64 | rsps._add_from_file("tests/res/responses.yaml")
65 | yield rsps
66 |
67 |
68 | def test_show_overview(mocked_tonie_api: mock.Mock, capfd: pytest.CaptureFixture):
69 | tonie_api_mock = _get_tonie_api_mock()
70 | mocked_tonie_api.return_value = tonie_api_mock
71 | tps = ToniePodcastSync("some user", "some_pass")
72 | tps.print_tonies_overview()
73 | mocked_tonie_api.assert_called_once_with("some user", "some_pass")
74 | tonie_api_mock.get_households.assert_called_once()
75 | captured = capfd.readouterr()
76 | assert "List of all creative tonies." in captured.out
77 | assert "ID" in captured.out
78 | assert "Name of Tonie" in captured.out
79 | assert "Time of last update" in captured.out
80 | assert "Household" in captured.out
81 | assert "Latest Episode name" in captured.out
82 | assert "42" in captured.out
83 | assert "Tonie #1" in captured.out
84 | assert "No latest chapter" in captured.out
85 | assert "73" in captured.out
86 | assert "Tonie #2" in captured.out
87 | assert "The great chapter" in captured.out
88 | assert "My House" in captured.out
89 |
90 |
91 | @mock.patch("tonie_podcast_sync.toniepodcastsync.tempfile.TemporaryDirectory")
92 | def test_upload_podcast(mock_tempdir, mocked_tonie_api: mock.Mock, mocked_responses: responses.RequestsMock, tmp_path):
93 | mock_tempdir.return_value.__enter__.return_value = str(tmp_path)
94 | tonie_api_mock = _get_tonie_api_mock()
95 | mocked_tonie_api.return_value = tonie_api_mock
96 | tps = ToniePodcastSync("some user", "some_pass")
97 | tps.sync_podcast_to_tonie(Podcast("tests/res/kakadu.xml"), "42")
98 | assert mocked_responses.assert_all_requests_are_fired
99 | tonie_api_mock.upload_file_to_tonie.assert_any_call(
100 | TONIE_1,
101 | tmp_path
102 | / "Kakadu - Der Kinderpodcast"
103 | / "Mon, 14 Aug 2023 103524 +0200 Vom Gewinnen und Verlieren - Warum spielen wir so gern.mp3",
104 | "Vom Gewinnen und Verlieren - Warum spielen wir so gern? (Mon, 14 Aug 2023 10:35:24 +0200)",
105 | )
106 |
--------------------------------------------------------------------------------
/tests/test_download_retry_logic.py:
--------------------------------------------------------------------------------
1 | """Test for download retry logic bug in toniepodcastsync.py."""
2 |
3 | from unittest import mock
4 |
5 | import pytest
6 | from requests.exceptions import RequestException
7 |
8 | from tonie_podcast_sync.constants import DOWNLOAD_RETRY_COUNT
9 | from tonie_podcast_sync.podcast import Episode
10 | from tonie_podcast_sync.toniepodcastsync import ToniePodcastSync
11 |
12 |
13 | @pytest.fixture
14 | def mock_tonie_api():
15 | """Mock TonieAPI."""
16 | with mock.patch("tonie_podcast_sync.toniepodcastsync.TonieAPI") as _mock:
17 | api_mock = mock.MagicMock()
18 | api_mock.get_households.return_value = []
19 | api_mock.get_all_creative_tonies.return_value = []
20 | _mock.return_value = api_mock
21 | yield _mock
22 |
23 |
24 | @pytest.fixture
25 | def temp_cache_dir(tmp_path):
26 | """Create a temporary cache directory."""
27 | return tmp_path / "cache"
28 |
29 |
30 | @pytest.mark.usefixtures("mock_tonie_api")
31 | def test_download_respects_retry_count(temp_cache_dir):
32 | """
33 | Test that downloads respect DOWNLOAD_RETRY_COUNT and don't make extra requests.
34 |
35 | Bug: The current code has duplicate download logic after the retry loop,
36 | which causes an additional download attempt after all retries are exhausted.
37 | This means if DOWNLOAD_RETRY_COUNT=3, it actually makes 4 attempts total.
38 | """
39 | tps = ToniePodcastSync("user", "pass")
40 | tps.podcast_cache_directory = temp_cache_dir
41 |
42 | # Create a test episode
43 | test_feed_data = {
44 | "title": "Test Episode",
45 | "published": "Mon, 01 Jan 2024 10:00:00 +0000",
46 | "published_parsed": (2024, 1, 1, 10, 0, 0, 0, 1, 0),
47 | "id": "test-guid-123",
48 | "itunes_duration": "10:30",
49 | }
50 |
51 | ep = Episode(podcast="Test Podcast", raw=test_feed_data, url="http://example.com/test.mp3")
52 |
53 | # Mock requests to always fail
54 | call_count = 0
55 |
56 | def mock_get(*_args, **_kwargs):
57 | nonlocal call_count
58 | call_count += 1
59 | msg = "Network error"
60 | raise RequestException(msg)
61 |
62 | tps._session.get = mock_get
63 | result = tps._ToniePodcastSync__cache_episode(ep)
64 |
65 | # Should fail after DOWNLOAD_RETRY_COUNT attempts, not more
66 | assert result is False
67 | assert call_count == DOWNLOAD_RETRY_COUNT, (
68 | f"Expected exactly {DOWNLOAD_RETRY_COUNT} download attempts, "
69 | f"but got {call_count}. The bug causes an extra attempt after retry loop."
70 | )
71 |
72 |
73 | @pytest.mark.usefixtures("mock_tonie_api")
74 | def test_download_succeeds_on_first_attempt(temp_cache_dir):
75 | """Test that successful downloads on first attempt don't trigger retries."""
76 | tps = ToniePodcastSync("user", "pass")
77 | tps.podcast_cache_directory = temp_cache_dir
78 |
79 | test_feed_data = {
80 | "title": "Test Episode",
81 | "published": "Mon, 01 Jan 2024 10:00:00 +0000",
82 | "published_parsed": (2024, 1, 1, 10, 0, 0, 0, 1, 0),
83 | "id": "test-guid-123",
84 | "itunes_duration": "10:30",
85 | }
86 |
87 | ep = Episode(podcast="Test Podcast", raw=test_feed_data, url="http://example.com/test.mp3")
88 |
89 | call_count = 0
90 |
91 | def mock_get(*_args, **_kwargs):
92 | nonlocal call_count
93 | call_count += 1
94 | response = mock.MagicMock()
95 | response.ok = True
96 | response.iter_content = mock.MagicMock(return_value=[b"fake audio content"])
97 | response.raise_for_status = mock.MagicMock()
98 | return response
99 |
100 | tps._session.get = mock_get
101 | result = tps._ToniePodcastSync__cache_episode(ep)
102 |
103 | assert result is True
104 | assert call_count == 1, f"Expected exactly 1 download attempt for successful download, but got {call_count}"
105 |
106 |
107 | @pytest.mark.usefixtures("mock_tonie_api")
108 | def test_download_succeeds_on_retry(temp_cache_dir):
109 | """Test that download succeeds on second attempt after first fails."""
110 | tps = ToniePodcastSync("user", "pass")
111 | tps.podcast_cache_directory = temp_cache_dir
112 |
113 | test_feed_data = {
114 | "title": "Test Episode",
115 | "published": "Mon, 01 Jan 2024 10:00:00 +0000",
116 | "published_parsed": (2024, 1, 1, 10, 0, 0, 0, 1, 0),
117 | "id": "test-guid-123",
118 | "itunes_duration": "10:30",
119 | }
120 |
121 | ep = Episode(podcast="Test Podcast", raw=test_feed_data, url="http://example.com/test.mp3")
122 |
123 | call_count = 0
124 |
125 | def mock_get(*_args, **_kwargs):
126 | nonlocal call_count
127 | call_count += 1
128 | if call_count == 1:
129 | msg = "Network error"
130 | raise RequestException(msg)
131 | response = mock.MagicMock()
132 | response.ok = True
133 | response.iter_content = mock.MagicMock(return_value=[b"fake audio content"])
134 | response.raise_for_status = mock.MagicMock()
135 | return response
136 |
137 | with mock.patch("tonie_podcast_sync.toniepodcastsync.time.sleep"): # Skip actual sleep
138 | tps._session.get = mock_get
139 | result = tps._ToniePodcastSync__cache_episode(ep)
140 |
141 | assert result is True
142 | assert call_count == 2, f"Expected 2 download attempts (1 fail + 1 success), but got {call_count}"
143 |
--------------------------------------------------------------------------------
/tests/test_performance_optimizations.py:
--------------------------------------------------------------------------------
1 | """Tests for performance optimizations."""
2 |
3 | from collections import deque
4 | from unittest import mock
5 |
6 | import pytest
7 |
8 | from tonie_podcast_sync.podcast import Episode
9 | from tonie_podcast_sync.toniepodcastsync import ToniePodcastSync
10 |
11 |
12 | @pytest.fixture
13 | def mock_tonie_api():
14 | """Mock TonieAPI."""
15 | with mock.patch("tonie_podcast_sync.toniepodcastsync.TonieAPI") as _mock:
16 | api_mock = mock.MagicMock()
17 | api_mock.get_households.return_value = []
18 | api_mock.get_all_creative_tonies.return_value = []
19 | _mock.return_value = api_mock
20 | yield _mock
21 |
22 |
23 | @pytest.fixture
24 | def temp_cache_dir(tmp_path):
25 | """Create temporary cache directory."""
26 | cache_dir = tmp_path / "cache"
27 | cache_dir.mkdir()
28 | return cache_dir
29 |
30 |
31 | @pytest.mark.usefixtures("mock_tonie_api")
32 | def test_session_reuse_for_http_requests(temp_cache_dir):
33 | """Test that ToniePodcastSync reuses HTTP session for connection pooling."""
34 | tps = ToniePodcastSync("user", "pass")
35 | tps.podcast_cache_directory = temp_cache_dir
36 |
37 | # Verify session is created on initialization
38 | assert hasattr(tps, "_session")
39 | assert tps._session is not None
40 |
41 |
42 | @pytest.mark.usefixtures("mock_tonie_api")
43 | def test_streaming_download_uses_iter_content(temp_cache_dir):
44 | """Test that downloads use streaming (iter_content) instead of loading entire file to memory."""
45 | tps = ToniePodcastSync("user", "pass")
46 | tps.podcast_cache_directory = temp_cache_dir
47 |
48 | test_feed_data = {
49 | "title": "Test Episode",
50 | "published": "Mon, 01 Jan 2024 10:00:00 +0000",
51 | "published_parsed": (2024, 1, 1, 10, 0, 0, 0, 1, 0),
52 | "id": "test-guid-123",
53 | "itunes_duration": "10:30",
54 | }
55 | ep = Episode(podcast="Test Podcast", raw=test_feed_data, url="http://example.com/test.mp3")
56 |
57 | # Mock response with iter_content
58 | mock_response = mock.MagicMock()
59 | mock_response.ok = True
60 | mock_response.raise_for_status = mock.MagicMock()
61 |
62 | # Track whether iter_content was called (streaming) vs content property (in-memory)
63 | iter_content_called = False
64 |
65 | def mock_iter_content(chunk_size=8192): # noqa: ARG001
66 | nonlocal iter_content_called
67 | iter_content_called = True
68 | yield b"chunk1"
69 | yield b"chunk2"
70 |
71 | mock_response.iter_content = mock_iter_content
72 | tps._session.get = mock.MagicMock(return_value=mock_response)
73 |
74 | result = tps._ToniePodcastSync__cache_episode(ep)
75 |
76 | assert result is True
77 | assert iter_content_called, "Download should use iter_content for streaming, not load entire file to memory"
78 |
79 |
80 | @pytest.mark.usefixtures("mock_tonie_api")
81 | def test_volume_adjustment_loads_to_memory_when_needed(temp_cache_dir):
82 | """Test that volume adjustment loads file to memory (required for pydub processing)."""
83 | tps = ToniePodcastSync("user", "pass")
84 | tps.podcast_cache_directory = temp_cache_dir
85 |
86 | test_feed_data = {
87 | "title": "Test Episode",
88 | "published": "Mon, 01 Jan 2024 10:00:00 +0000",
89 | "published_parsed": (2024, 1, 1, 10, 0, 0, 0, 1, 0),
90 | "id": "test-guid-123",
91 | "itunes_duration": "10:30",
92 | }
93 | ep = Episode(
94 | podcast="Test Podcast",
95 | raw=test_feed_data,
96 | url="http://example.com/test.mp3",
97 | volume_adjustment=5, # Volume adjustment required
98 | )
99 |
100 | # Mock response
101 | mock_response = mock.MagicMock()
102 | mock_response.ok = True
103 | mock_response.raise_for_status = mock.MagicMock()
104 | mock_response.iter_content = mock.MagicMock(return_value=[b"fake audio content"])
105 | tps._session.get = mock.MagicMock(return_value=mock_response)
106 |
107 | # Mock ffmpeg availability
108 | with (
109 | mock.patch.object(tps, "_is_ffmpeg_available", return_value=True),
110 | mock.patch.object(tps, "_adjust_volume", return_value=b"adjusted audio") as mock_adjust,
111 | ):
112 | result = tps._ToniePodcastSync__cache_episode(ep)
113 |
114 | assert result is True
115 | # When volume adjustment is needed, audio is loaded to memory for processing
116 | assert mock_adjust.called
117 |
118 |
119 | @pytest.mark.usefixtures("mock_tonie_api")
120 | def test_deque_used_for_fallback_episodes(temp_cache_dir):
121 | """Test that fallback episode selection uses efficient data structure."""
122 |
123 | tps = ToniePodcastSync("user", "pass")
124 | tps.podcast_cache_directory = temp_cache_dir
125 |
126 | # Create test episodes
127 | test_episodes = []
128 | for i in range(1, 4):
129 | test_feed_data = {
130 | "title": f"Episode {i}",
131 | "published": f"Mon, 0{i} Jan 2024 10:00:00 +0000",
132 | "published_parsed": (2024, 1, i, 10, 0, 0, 0, 1, 0),
133 | "id": f"test-guid-{i}",
134 | "itunes_duration": "10:00",
135 | }
136 | ep = Episode(podcast="Test Podcast", raw=test_feed_data, url=f"http://example.com/ep{i}.mp3")
137 | test_episodes.append(ep)
138 |
139 | # Test that _find_replacement_episode accepts deque
140 | test_deque = deque(test_episodes)
141 | replacement = tps._find_replacement_episode(test_deque, max_seconds=1800, current_seconds=0)
142 |
143 | assert replacement is not None
144 | assert replacement.title == "Episode 1"
145 | # Verify the episode was removed from the deque (efficient removal)
146 | assert len(test_deque) == 2
147 |
--------------------------------------------------------------------------------
/tests/test_cli_episode_max_duration.py:
--------------------------------------------------------------------------------
1 | """Test for episode_max_duration_sec default value bug in CLI."""
2 |
3 | from unittest import mock
4 |
5 |
6 | def test_episode_max_duration_defaults_to_5399_not_maximum_length():
7 | """
8 | Test that episode_max_duration_sec defaults to MAXIMUM_TONIE_MINUTES * 60 (5400 seconds),
9 | NOT to maximum_length * 60.
10 |
11 | Bug: When episode_max_duration_sec is not in config, it was incorrectly defaulting
12 | to maximum_length * 60, which is wrong. It should default to MAXIMUM_TONIE_MINUTES * 60.
13 |
14 | Example: If maximum_length=30 (30 min total for tonie), the buggy code
15 | set episode_max_duration_sec=1800 (30 min), filtering out individual episodes
16 | longer than 30 minutes. This is incorrect - episodes up to 90 minutes should
17 | be allowed, and maximum_length only controls the total duration on the tonie.
18 | """
19 | # Create mock settings
20 | mock_settings = mock.MagicMock()
21 | mock_settings.TONIE_CLOUD_ACCESS.USERNAME = "test_user"
22 | mock_settings.TONIE_CLOUD_ACCESS.PASSWORD = "test_pass"
23 |
24 | # Configure a tonie with maximum_length=30 minutes but no episode_max_duration_sec
25 | mock_tonie_config = mock.MagicMock()
26 | mock_tonie_config.podcast = "https://example.com/feed.xml"
27 | mock_tonie_config.episode_sorting = "by_date_newest_first"
28 | mock_tonie_config.volume_adjustment = 0
29 | mock_tonie_config.episode_min_duration_sec = 0
30 | mock_tonie_config.maximum_length = 30 # 30 minutes total
31 |
32 | # Simulate that episode_max_duration_sec is not set in config
33 | mock_tonie_config.get = mock.MagicMock(
34 | side_effect=lambda key, default=None: {
35 | "excluded_title_strings": [],
36 | "episode_max_duration_sec": default, # Will return the default value
37 | }.get(key, default)
38 | )
39 |
40 | mock_settings.CREATIVE_TONIES = {"test-tonie-id": mock_tonie_config}
41 |
42 | # Mock the dependencies
43 | with (
44 | mock.patch("tonie_podcast_sync.cli.settings", mock_settings),
45 | mock.patch("tonie_podcast_sync.cli.ToniePodcastSync") as mock_tps_class,
46 | mock.patch("tonie_podcast_sync.cli.Podcast") as mock_podcast_class,
47 | ):
48 | # Mock ToniePodcastSync instance
49 | mock_tps_instance = mock.MagicMock()
50 | mock_tps_class.return_value = mock_tps_instance
51 |
52 | # Mock Podcast class to capture initialization arguments
53 | mock_podcast_instance = mock.MagicMock()
54 | mock_podcast_class.return_value = mock_podcast_instance
55 |
56 | # Import and run the update_tonies function
57 | from tonie_podcast_sync.cli import update_tonies # noqa: PLC0415
58 |
59 | update_tonies()
60 |
61 | # Verify ToniePodcastSync was instantiated
62 | mock_tps_class.assert_called_once_with("test_user", "test_pass")
63 |
64 | # Verify Podcast was created with correct arguments
65 | mock_podcast_class.assert_called_once()
66 | call_args = mock_podcast_class.call_args
67 |
68 | # Extract the episode_max_duration_sec argument
69 | episode_max_duration_sec = call_args.kwargs.get("episode_max_duration_sec")
70 |
71 | # The fix: Now this should be 5400 (90 * 60), not 1800 (maximum_length * 60)
72 | assert episode_max_duration_sec == 5400, (
73 | f"episode_max_duration_sec should default to 5400 (MAXIMUM_TONIE_MINUTES * 60), "
74 | f"not {episode_max_duration_sec} (maximum_length * 60). "
75 | f"Individual episode duration limit should be independent of "
76 | f"total tonie duration limit."
77 | )
78 |
79 |
80 | def test_episode_max_duration_respects_explicit_value():
81 | """
82 | Test that when episode_max_duration_sec IS explicitly set in config,
83 | that value is used (not the default).
84 | """
85 | # Create mock settings
86 | mock_settings = mock.MagicMock()
87 | mock_settings.TONIE_CLOUD_ACCESS.USERNAME = "test_user"
88 | mock_settings.TONIE_CLOUD_ACCESS.PASSWORD = "test_pass"
89 |
90 | # Configure a tonie with explicit episode_max_duration_sec
91 | mock_tonie_config = mock.MagicMock()
92 | mock_tonie_config.podcast = "https://example.com/feed.xml"
93 | mock_tonie_config.episode_sorting = "by_date_newest_first"
94 | mock_tonie_config.volume_adjustment = 0
95 | mock_tonie_config.episode_min_duration_sec = 0
96 | mock_tonie_config.maximum_length = 30 # 30 minutes total
97 |
98 | # Set explicit value
99 | explicit_max_duration = 3600 # 60 minutes
100 | mock_tonie_config.get = mock.MagicMock(
101 | side_effect=lambda key, default=None: {
102 | "excluded_title_strings": [],
103 | "episode_max_duration_sec": explicit_max_duration,
104 | }.get(key, default)
105 | )
106 |
107 | mock_settings.CREATIVE_TONIES = {"test-tonie-id": mock_tonie_config}
108 |
109 | # Mock the dependencies
110 | with (
111 | mock.patch("tonie_podcast_sync.cli.settings", mock_settings),
112 | mock.patch("tonie_podcast_sync.cli.ToniePodcastSync") as mock_tps_class,
113 | mock.patch("tonie_podcast_sync.cli.Podcast") as mock_podcast_class,
114 | ):
115 | # Mock ToniePodcastSync instance
116 | mock_tps_instance = mock.MagicMock()
117 | mock_tps_class.return_value = mock_tps_instance
118 |
119 | # Mock Podcast class
120 | mock_podcast_instance = mock.MagicMock()
121 | mock_podcast_class.return_value = mock_podcast_instance
122 |
123 | # Import and run the update_tonies function
124 | from tonie_podcast_sync.cli import update_tonies # noqa: PLC0415
125 |
126 | update_tonies()
127 |
128 | # Verify Podcast was created with the explicit value
129 | call_args = mock_podcast_class.call_args
130 | episode_max_duration_sec = call_args.kwargs.get("episode_max_duration_sec")
131 |
132 | assert episode_max_duration_sec == explicit_max_duration, (
133 | f"episode_max_duration_sec should use the explicit config value {explicit_max_duration}, "
134 | f"but got {episode_max_duration_sec}"
135 | )
136 |
--------------------------------------------------------------------------------
/tests/test_podcast.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | import pytest
4 |
5 | from tonie_podcast_sync.podcast import EpisodeSorting, Podcast
6 |
7 |
8 | def test_url_type():
9 | """This test uses a rss feed from the real web, if this reference is not available it might fail"""
10 | podcast = Podcast(r"https://kinder.wdr.de/radio/diemaus/audio/maus-zoom/maus-zoom-106.podcast")
11 | assert podcast.epList
12 |
13 |
14 | @pytest.mark.parametrize(
15 | ("feed", "title", "length"),
16 | [
17 | (str(Path(__file__).parent / "res" / "sandmann.xml"), "Unser Sandmännchen", 67),
18 | (str(Path(__file__).parent / "res" / "kakadu.xml"), "Kakadu - Der Kinderpodcast", 51),
19 | (str(Path(__file__).parent / "res" / "true_crime.xml"), "Crime Junkie", 333),
20 | (str(Path(__file__).parent / "res" / "pumuckl.xml"), "Pumuckl - Der Hörspiel-Klassiker", 15),
21 | (str(Path(__file__).parent / "res" / "diemaus.xml"), "Die Maus zum Hören", 47),
22 | ],
23 | )
24 | class TestPodcast:
25 | def test_podcast_initialization(self, feed, title, length):
26 | podcast = Podcast(feed)
27 | assert podcast.title == title
28 | assert len(podcast.epList) == length
29 |
30 | def test_episode_sorting_default(self, feed, title, length):
31 | podcast = Podcast(feed)
32 | assert podcast.epList[0].published_parsed > podcast.epList[-1].published_parsed
33 |
34 | def test_episode_sorting_newest_first(self, feed, title, length):
35 | podcast = Podcast(feed, episode_sorting=EpisodeSorting.BY_DATE_NEWEST_FIRST)
36 | assert podcast.epList[0].published_parsed > podcast.epList[-1].published_parsed
37 |
38 | def test_episode_sorting_oldest_first(self, feed, title, length):
39 | podcast = Podcast(feed, episode_sorting=EpisodeSorting.BY_DATE_OLDEST_FIRST)
40 | assert podcast.epList[0].published_parsed < podcast.epList[-1].published_parsed
41 |
42 | def test_episode_sorting_random(self, feed, title, length):
43 | podcast = Podcast(feed, episode_sorting=EpisodeSorting.RANDOM)
44 | assert is_list_sorted(podcast.epList)
45 |
46 |
47 | def is_list_sorted(objects: list) -> bool:
48 | sorted_objects = sorted(objects, key=lambda x: x.published_parsed)
49 | return any(sorted_objects[i] != objects[i] for i in range(len(sorted_objects)))
50 |
51 |
52 | def test_excluded_title_strings():
53 | """Test that episodes with excluded title strings are filtered out."""
54 | feed = str(Path(__file__).parent / "res" / "kakadu.xml")
55 |
56 | # Test without exclusion - should have 51 episodes
57 | podcast_no_filter = Podcast(feed)
58 | assert len(podcast_no_filter.epList) == 51
59 |
60 | # Test with exclusion for "Update:" - should filter out episodes containing this string
61 | podcast_with_filter = Podcast(feed, excluded_title_strings=["Update:"])
62 | # At least one episode should be filtered
63 | assert len(podcast_with_filter.epList) < 51
64 | # Verify no remaining episodes contain the excluded string
65 | for episode in podcast_with_filter.epList:
66 | assert "update:" not in episode.title.lower()
67 |
68 |
69 | def test_excluded_title_strings_case_insensitive():
70 | """Test that title filtering is case-insensitive."""
71 | feed = str(Path(__file__).parent / "res" / "kakadu.xml")
72 |
73 | # Test with different case variations
74 | podcast_upper = Podcast(feed, excluded_title_strings=["UPDATE"])
75 | podcast_lower = Podcast(feed, excluded_title_strings=["update"])
76 | podcast_mixed = Podcast(feed, excluded_title_strings=["UpDaTe"])
77 |
78 | # All should filter the same episodes
79 | assert len(podcast_upper.epList) == len(podcast_lower.epList)
80 | assert len(podcast_lower.epList) == len(podcast_mixed.epList)
81 |
82 |
83 | def test_excluded_title_strings_multiple():
84 | """Test filtering with multiple excluded strings."""
85 | feed = str(Path(__file__).parent / "res" / "kakadu.xml")
86 |
87 | # Test with multiple exclusion strings
88 | podcast_multi = Podcast(feed, excluded_title_strings=["Update:", "Vorurteile"])
89 |
90 | # Verify no episodes contain any of the excluded strings
91 | for episode in podcast_multi.epList:
92 | assert "update:" not in episode.title.lower()
93 | assert "vorurteile" not in episode.title.lower()
94 |
95 |
96 | def test_excluded_title_strings_empty_list():
97 | """Test that empty exclusion list doesn't filter anything."""
98 | feed = str(Path(__file__).parent / "res" / "kakadu.xml")
99 |
100 | podcast_empty_list = Podcast(feed, excluded_title_strings=[])
101 | podcast_no_param = Podcast(feed)
102 |
103 | # Both should have the same number of episodes
104 | assert len(podcast_empty_list.epList) == len(podcast_no_param.epList)
105 |
106 |
107 | def test_episode_min_duration_filtering():
108 | """Test that episodes shorter than min duration are filtered out."""
109 | feed = str(Path(__file__).parent / "res" / "kakadu.xml")
110 |
111 | # Get baseline
112 | podcast_no_filter = Podcast(feed)
113 | baseline_count = len(podcast_no_filter.epList)
114 |
115 | # Filter out episodes shorter than 1500 seconds (25 minutes)
116 | podcast_with_min = Podcast(feed, episode_min_duration_sec=1500)
117 |
118 | # Should have fewer episodes (kakadu has episodes between 1190s and 1639s)
119 | assert len(podcast_with_min.epList) < baseline_count
120 | # All remaining episodes should be at least 1500 seconds
121 | for episode in podcast_with_min.epList:
122 | assert episode.duration_sec >= 1500
123 |
124 |
125 | def test_episode_max_duration_filtering():
126 | """Test that episodes longer than max duration are filtered out."""
127 | feed = str(Path(__file__).parent / "res" / "kakadu.xml")
128 |
129 | # Get baseline
130 | podcast_no_filter = Podcast(feed)
131 | baseline_count = len(podcast_no_filter.epList)
132 |
133 | # Filter out episodes longer than 1200 seconds (20 minutes)
134 | podcast_with_max = Podcast(feed, episode_max_duration_sec=1200)
135 |
136 | # Should have fewer or equal episodes
137 | assert len(podcast_with_max.epList) <= baseline_count
138 | # All remaining episodes should be at most 1200 seconds
139 | for episode in podcast_with_max.epList:
140 | assert episode.duration_sec <= 1200
141 |
--------------------------------------------------------------------------------
/tests/test_download_fallback.py:
--------------------------------------------------------------------------------
1 | """Test for download fallback when episode download fails."""
2 |
3 | from unittest import mock
4 |
5 | import pytest
6 | from requests.exceptions import RequestException
7 |
8 | from tonie_podcast_sync.podcast import Episode, EpisodeSorting
9 | from tonie_podcast_sync.toniepodcastsync import ToniePodcastSync
10 |
11 |
12 | @pytest.fixture
13 | def mock_tonie_api():
14 | """Mock TonieAPI."""
15 | with mock.patch("tonie_podcast_sync.toniepodcastsync.TonieAPI") as _mock:
16 | api_mock = mock.MagicMock()
17 | api_mock.get_households.return_value = []
18 | api_mock.get_all_creative_tonies.return_value = []
19 | _mock.return_value = api_mock
20 | yield _mock
21 |
22 |
23 | @pytest.fixture
24 | def temp_cache_dir(tmp_path):
25 | """Create temporary cache directory."""
26 | cache_dir = tmp_path / "cache"
27 | cache_dir.mkdir()
28 | return cache_dir
29 |
30 |
31 | @pytest.mark.usefixtures("mock_tonie_api")
32 | def test_fallback_to_next_episode_on_download_failure(temp_cache_dir):
33 | """Test that when an episode fails to download, the next available one is used as fallback."""
34 | tps = ToniePodcastSync("user", "pass")
35 | tps.podcast_cache_directory = temp_cache_dir
36 |
37 | # Create test episodes
38 | test_episodes = []
39 | for i in range(1, 6):
40 | test_feed_data = {
41 | "title": f"Episode {i}",
42 | "published": f"Mon, 0{i} Jan 2024 10:00:00 +0000",
43 | "published_parsed": (2024, 1, i, 10, 0, 0, 0, 1, 0),
44 | "id": f"test-guid-{i}",
45 | "itunes_duration": "10:00", # 10 minutes each
46 | }
47 | ep = Episode(podcast="Test Podcast", raw=test_feed_data, url=f"http://example.com/ep{i}.mp3")
48 | test_episodes.append(ep)
49 |
50 | # Create a mock podcast with 5 episodes (sorted newest first)
51 | podcast = mock.MagicMock()
52 | podcast.epList = test_episodes
53 | podcast.title = "Test Podcast"
54 | podcast.epSorting = EpisodeSorting.BY_DATE_NEWEST_FIRST
55 |
56 | # Mock requests to fail for Episode 2, but succeed for others
57 | call_count = 0
58 |
59 | def mock_get(url, *_args, **_kwargs):
60 | nonlocal call_count
61 | call_count += 1
62 | if "ep2.mp3" in url:
63 | msg = "Network error for ep2"
64 | raise RequestException(msg)
65 | response = mock.MagicMock()
66 | response.ok = True
67 | response.raise_for_status = mock.MagicMock()
68 | response.iter_content = mock.MagicMock(return_value=[b"fake audio data"])
69 | return response
70 |
71 | tps._session.get = mock_get
72 | # Request 30 minutes total, which would fit 3 episodes normally
73 | cached_episodes = tps._ToniePodcastSync__cache_podcast_episodes(podcast, max_minutes=30)
74 |
75 | # Should have 3 episodes: Episode 1, Episode 4 (fallback for failed Episode 2), Episode 3
76 | assert len(cached_episodes) == 3
77 | assert cached_episodes[0].title == "Episode 1"
78 | # Episode 2 failed, so Episode 4 was used as fallback (first available episode)
79 | assert cached_episodes[1].title == "Episode 4"
80 | assert cached_episodes[2].title == "Episode 3"
81 |
82 |
83 | @pytest.mark.usefixtures("mock_tonie_api")
84 | def test_fallback_in_random_mode(temp_cache_dir):
85 | """Test that fallback works in random mode without reshuffling."""
86 | tps = ToniePodcastSync("user", "pass")
87 | tps.podcast_cache_directory = temp_cache_dir
88 |
89 | # Create test episodes
90 | test_episodes = []
91 | for i in range(1, 6):
92 | test_feed_data = {
93 | "title": f"Episode {i}",
94 | "published": f"Mon, 0{i} Jan 2024 10:00:00 +0000",
95 | "published_parsed": (2024, 1, i, 10, 0, 0, 0, 1, 0),
96 | "id": f"test-guid-{i}",
97 | "itunes_duration": "10:00", # 10 minutes each
98 | }
99 | ep = Episode(podcast="Test Podcast", raw=test_feed_data, url=f"http://example.com/ep{i}.mp3")
100 | test_episodes.append(ep)
101 |
102 | # Create a mock podcast in RANDOM mode
103 | podcast = mock.MagicMock()
104 | podcast.epList = test_episodes # Already shuffled
105 | podcast.title = "Test Podcast"
106 | podcast.epSorting = EpisodeSorting.RANDOM
107 |
108 | # Mock requests to fail for first episode, succeed for others
109 | call_count = 0
110 |
111 | def mock_get(url, *_args, **_kwargs):
112 | nonlocal call_count
113 | call_count += 1
114 | if "ep1.mp3" in url:
115 | msg = "Network error for ep1"
116 | raise RequestException(msg)
117 | response = mock.MagicMock()
118 | response.ok = True
119 | response.raise_for_status = mock.MagicMock()
120 | response.iter_content = mock.MagicMock(return_value=[b"fake audio data"])
121 | return response
122 |
123 | tps._session.get = mock_get
124 | # Request 30 minutes total
125 | cached_episodes = tps._ToniePodcastSync__cache_podcast_episodes(podcast, max_minutes=30)
126 |
127 | # Should have 3 episodes, with Episode 4 as fallback for failed Episode 1
128 | assert len(cached_episodes) == 3
129 | # Episode 1 failed, so next available (Episode 4) was used
130 | episode_titles = [ep.title for ep in cached_episodes]
131 | assert "Episode 1" not in episode_titles
132 | assert "Episode 4" in episode_titles
133 |
134 |
135 | @pytest.mark.usefixtures("mock_tonie_api")
136 | def test_no_fallback_when_all_remaining_episodes_too_long(temp_cache_dir):
137 | """Test that no fallback is used if remaining episodes would exceed time limit."""
138 | tps = ToniePodcastSync("user", "pass")
139 | tps.podcast_cache_directory = temp_cache_dir
140 |
141 | # Create test episodes with varying durations
142 | test_episodes = []
143 | durations = ["10:00", "10:00", "10:00", "50:00"] # Last one is 50 minutes
144 | for i in range(1, 5):
145 | test_feed_data = {
146 | "title": f"Episode {i}",
147 | "published": f"Mon, 0{i} Jan 2024 10:00:00 +0000",
148 | "published_parsed": (2024, 1, i, 10, 0, 0, 0, 1, 0),
149 | "id": f"test-guid-{i}",
150 | "itunes_duration": durations[i - 1],
151 | }
152 | ep = Episode(podcast="Test Podcast", raw=test_feed_data, url=f"http://example.com/ep{i}.mp3")
153 | test_episodes.append(ep)
154 |
155 | podcast = mock.MagicMock()
156 | podcast.epList = test_episodes
157 | podcast.title = "Test Podcast"
158 | podcast.epSorting = EpisodeSorting.BY_DATE_NEWEST_FIRST
159 |
160 | # Mock requests to fail for Episode 3
161 | def mock_get(url, *_args, **_kwargs):
162 | if "ep3.mp3" in url:
163 | msg = "Network error for ep3"
164 | raise RequestException(msg)
165 | response = mock.MagicMock()
166 | response.ok = True
167 | response.raise_for_status = mock.MagicMock()
168 | response.iter_content = mock.MagicMock(return_value=[b"fake audio data"])
169 | return response
170 |
171 | tps._session.get = mock_get
172 | # Request 30 minutes total - can fit episodes 1, 2, 3
173 | # Episode 3 fails, but Episode 4 (50 min) is too long as replacement
174 | cached_episodes = tps._ToniePodcastSync__cache_podcast_episodes(podcast, max_minutes=30)
175 |
176 | # Should have only 2 episodes (1 and 2), no fallback for 3
177 | assert len(cached_episodes) == 2
178 | assert cached_episodes[0].title == "Episode 1"
179 | assert cached_episodes[1].title == "Episode 2"
180 |
--------------------------------------------------------------------------------
/tonie_podcast_sync/podcast.py:
--------------------------------------------------------------------------------
1 | """The podcast module to fetch all information of a podcast feed."""
2 |
3 | from __future__ import annotations
4 |
5 | import logging
6 | import random
7 | from dataclasses import dataclass, field
8 | from enum import Enum
9 | from typing import TYPE_CHECKING
10 |
11 | import feedparser
12 |
13 | from tonie_podcast_sync.constants import MAXIMUM_TONIE_MINUTES
14 |
15 | if TYPE_CHECKING:
16 | from pathlib import Path
17 | from time import struct_time
18 |
19 | log = logging.getLogger(__name__)
20 | log.addHandler(logging.NullHandler())
21 |
22 | MAX_EPISODE_TITLES_IN_WARNING = 3
23 |
24 |
25 | class EpisodeSorting(str, Enum):
26 | """Enum to select the sorting method for podcast episodes."""
27 |
28 | BY_DATE_NEWEST_FIRST = "by_date_newest_first"
29 | BY_DATE_OLDEST_FIRST = "by_date_oldest_first"
30 | RANDOM = "random"
31 |
32 |
33 | class Podcast:
34 | """Representation of a podcast feed."""
35 |
36 | def __init__( # noqa: PLR0913
37 | self,
38 | url: str,
39 | episode_sorting: EpisodeSorting = EpisodeSorting.BY_DATE_NEWEST_FIRST,
40 | volume_adjustment: int = 0,
41 | episode_min_duration_sec: int = 0,
42 | episode_max_duration_sec: int = MAXIMUM_TONIE_MINUTES * 60,
43 | excluded_title_strings: list[str] | None = None,
44 | ) -> None:
45 | """Initialize the podcast feed and fetch all episodes.
46 |
47 | Args:
48 | url: The URL of the podcast feed
49 | episode_sorting: How to sort episodes. Defaults to BY_DATE_NEWEST_FIRST.
50 | volume_adjustment: Volume adjustment in dB (0 = no adjustment)
51 | episode_min_duration_sec: Minimum episode duration to include (in seconds)
52 | episode_max_duration_sec: Maximum individual episode duration (in seconds).
53 | Defaults to MAXIMUM_TONIE_MINUTES * 60 (90 min)
54 | excluded_title_strings: List of strings to filter out from episode titles
55 | (case-insensitive matching)
56 | """
57 | self.volume_adjustment = volume_adjustment
58 | self.episode_min_duration_sec = episode_min_duration_sec
59 | self.episode_max_duration_sec = episode_max_duration_sec
60 | self.excluded_title_strings = [s.lower() for s in excluded_title_strings] if excluded_title_strings else []
61 |
62 | self.epList = []
63 | self.epSorting = episode_sorting
64 |
65 | self.feed = feedparser.parse(url)
66 | if self.feed.bozo:
67 | raise self.feed.bozo_exception
68 | self.title = self.feed.feed.title
69 | self.refresh_feed()
70 |
71 | def _should_include_episode(self, episode: Episode) -> bool:
72 | """Check if an episode should be included based on filters.
73 |
74 | Args:
75 | episode: The episode to check
76 |
77 | Returns:
78 | True if episode passes all filters, False otherwise
79 | """
80 | if episode.duration_sec < self.episode_min_duration_sec:
81 | log.info(
82 | "%s: skipping episode '%s' as too short (%d sec, min is %d sec)",
83 | self.title,
84 | episode.title,
85 | episode.duration_sec,
86 | self.episode_min_duration_sec,
87 | )
88 | return False
89 |
90 | if episode.duration_sec > self.episode_max_duration_sec:
91 | log.info(
92 | "%s: skipping episode '%s' as too long (%d sec, max is %d sec)",
93 | self.title,
94 | episode.title,
95 | episode.duration_sec,
96 | self.episode_max_duration_sec,
97 | )
98 | return False
99 |
100 | if self.excluded_title_strings and any(
101 | excluded_string in episode.title.lower() for excluded_string in self.excluded_title_strings
102 | ):
103 | log.info(
104 | "%s: skipping episode '%s' as title contains excluded string",
105 | self.title,
106 | episode.title,
107 | )
108 | return False
109 |
110 | return True
111 |
112 | def refresh_feed(self) -> None:
113 | """Refresh the podcast feed and populate the episodes list."""
114 | episodes_without_duration = []
115 |
116 | for item in self.feed.entries:
117 | url = self._extract_episode_url(item)
118 |
119 | if self._is_missing_duration(item):
120 | episodes_without_duration.append(item.title)
121 |
122 | episode = Episode(
123 | podcast=self.title,
124 | raw=item,
125 | url=url,
126 | volume_adjustment=self.volume_adjustment,
127 | )
128 |
129 | if self._should_include_episode(episode):
130 | self.epList.append(episode)
131 |
132 | self._warn_about_missing_durations(episodes_without_duration)
133 | self._sort_episodes()
134 | log.info("%s: feed refreshed, %d episodes found", self.title, len(self.epList))
135 |
136 | def _extract_episode_url(self, item: dict) -> str:
137 | """Extract the episode audio URL from a feed item.
138 |
139 | Args:
140 | item: The feed item dictionary
141 |
142 | Returns:
143 | The URL to the audio file
144 | """
145 | url = item.id
146 | for link in item.links:
147 | if link["rel"] == "enclosure":
148 | url = link["href"]
149 | break
150 | return url
151 |
152 | def _is_missing_duration(self, item: dict) -> bool:
153 | """Check if a feed item is missing duration information.
154 |
155 | Args:
156 | item: The feed item dictionary
157 |
158 | Returns:
159 | True if duration is missing or empty, False otherwise
160 | """
161 | return "itunes_duration" not in item or not item.get("itunes_duration", "").strip()
162 |
163 | def _warn_about_missing_durations(self, episodes_without_duration: list[str]) -> None:
164 | """Log a warning if episodes are missing duration information.
165 |
166 | Args:
167 | episodes_without_duration: List of episode titles without duration
168 | """
169 | if not episodes_without_duration:
170 | return
171 |
172 | displayed_titles = episodes_without_duration[:MAX_EPISODE_TITLES_IN_WARNING]
173 | title_list = ", ".join(displayed_titles)
174 | if len(episodes_without_duration) > MAX_EPISODE_TITLES_IN_WARNING:
175 | title_list += "..."
176 |
177 | log.warning(
178 | "%s: %d episode(s) in feed are missing duration information: %s",
179 | self.title,
180 | len(episodes_without_duration),
181 | title_list,
182 | )
183 |
184 | def _sort_episodes(self) -> None:
185 | """Sort episodes according to the configured sorting method."""
186 | match self.epSorting:
187 | case EpisodeSorting.BY_DATE_NEWEST_FIRST:
188 | self.epList.sort(key=lambda x: x.published_parsed, reverse=True)
189 | case EpisodeSorting.BY_DATE_OLDEST_FIRST:
190 | self.epList.sort(key=lambda x: x.published_parsed)
191 | case EpisodeSorting.RANDOM:
192 | random.shuffle(self.epList)
193 |
194 |
195 | @dataclass
196 | class Episode:
197 | """A dataclass representing a podcast episode."""
198 |
199 | podcast: str
200 | raw: dict
201 | title: str = field(init=False)
202 | published: str = field(init=False)
203 | published_parsed: struct_time = field(init=False)
204 | url: str = ""
205 | guid: str = field(init=False)
206 | fpath: Path = field(init=False, compare=False)
207 | duration_str: str = field(init=False)
208 | duration_sec: int = field(init=False)
209 | volume_adjustment: int = 0
210 |
211 | def __post_init__(self) -> None:
212 | """Initialize derived fields from raw feed data."""
213 | self.title = self.raw["title"]
214 | self.published = self.raw["published"]
215 | self.published_parsed = self.raw["published_parsed"]
216 | self.guid = self.raw["id"]
217 | self.duration_str = self.raw.get("itunes_duration", "0")
218 | self.duration_sec = self._parse_duration(self.duration_str)
219 |
220 | @staticmethod
221 | def _parse_duration(duration_str: str) -> int:
222 | """Parse duration string into seconds.
223 |
224 | Handles formats: "SS", "MM:SS", or "HH:MM:SS"
225 |
226 | Args:
227 | duration_str: The duration string to parse
228 |
229 | Returns:
230 | Duration in seconds, or 0 if parsing fails
231 | """
232 | if not duration_str or not duration_str.strip():
233 | return 0
234 |
235 | try:
236 | parts = duration_str.split(":")
237 |
238 | match len(parts):
239 | case 1:
240 | return int(parts[0])
241 | case 2:
242 | minutes, seconds = parts
243 | return int(minutes) * 60 + int(seconds)
244 | case 3:
245 | hours, minutes, seconds = parts
246 | return int(hours) * 3600 + int(minutes) * 60 + int(seconds)
247 | case _:
248 | return 0
249 | except (ValueError, TypeError):
250 | return 0
251 |
--------------------------------------------------------------------------------
/tests/test_upload_failure_handling.py:
--------------------------------------------------------------------------------
1 | """Test for upload failure handling bug in toniepodcastsync.py."""
2 |
3 | from unittest import mock
4 |
5 | import pytest
6 | from requests.exceptions import HTTPError
7 | from tonie_api.models import CreativeTonie, Household
8 |
9 | from tonie_podcast_sync.constants import UPLOAD_RETRY_COUNT
10 | from tonie_podcast_sync.podcast import Episode, EpisodeSorting
11 | from tonie_podcast_sync.toniepodcastsync import ToniePodcastSync
12 |
13 |
14 | @pytest.fixture
15 | def mock_tonie_api_with_tonie():
16 | """Mock TonieAPI with a configured tonie."""
17 | with mock.patch("tonie_podcast_sync.toniepodcastsync.TonieAPI") as _mock:
18 | household = Household(
19 | id="household-1", name="Test House", ownerName="Test Owner", access="owner", canLeave=True
20 | )
21 |
22 | tonie = CreativeTonie(
23 | id="tonie-123",
24 | householdId="household-1",
25 | name="Test Tonie",
26 | imageUrl="http://example.com/img.png",
27 | secondsRemaining=5400,
28 | secondsPresent=0,
29 | chaptersPresent=0,
30 | chaptersRemaining=99,
31 | transcoding=False,
32 | lastUpdate=None,
33 | chapters=[],
34 | )
35 |
36 | api_mock = mock.MagicMock()
37 | api_mock.get_households.return_value = [household]
38 | api_mock.get_all_creative_tonies.return_value = [tonie]
39 | _mock.return_value = api_mock
40 | yield api_mock
41 |
42 |
43 | @pytest.fixture
44 | def temp_podcast_with_episodes(tmp_path):
45 | """Create a temporary directory with mock episode files."""
46 | cache_dir = tmp_path / "cache"
47 | cache_dir.mkdir()
48 |
49 | # Create mock episode files
50 | podcast_dir = cache_dir / "Test Podcast"
51 | podcast_dir.mkdir()
52 |
53 | episodes = []
54 | for i in range(3):
55 | test_feed_data = {
56 | "title": f"Episode {i + 1}",
57 | "published": f"Mon, 0{i + 1} Jan 2024 10:00:00 +0000",
58 | "published_parsed": (2024, 1, i + 1, 10, 0, 0, 0, 1, 0),
59 | "id": f"test-guid-{i + 1}",
60 | "itunes_duration": "10:30",
61 | }
62 | ep = Episode(podcast="Test Podcast", raw=test_feed_data, url=f"http://example.com/ep{i + 1}.mp3")
63 |
64 | # Create a fake file for the episode
65 | ep_file = podcast_dir / f"episode_{i + 1}.mp3"
66 | ep_file.write_bytes(b"fake audio content")
67 | ep.fpath = ep_file
68 |
69 | episodes.append(ep)
70 |
71 | return cache_dir, episodes
72 |
73 |
74 | def test_upload_failure_should_not_report_success(mock_tonie_api_with_tonie, temp_podcast_with_episodes, capsys):
75 | """
76 | Test that when uploads fail, the function doesn't report success.
77 |
78 | Bug: Currently __upload_episode returns True/False but the return value is ignored.
79 | The function always prints "Successfully uploaded" even if uploads failed.
80 | """
81 | cache_dir, episodes = temp_podcast_with_episodes
82 |
83 | tps = ToniePodcastSync("user", "pass")
84 | tps.podcast_cache_directory = cache_dir
85 |
86 | # Mock the upload to always fail
87 | mock_tonie_api_with_tonie.upload_file_to_tonie.side_effect = HTTPError("Upload failed")
88 |
89 | # Create a mock podcast
90 | podcast = mock.MagicMock()
91 | podcast.epList = episodes
92 | podcast.title = "Test Podcast"
93 | podcast.epSorting = EpisodeSorting.BY_DATE_NEWEST_FIRST
94 |
95 | # Mock download to succeed so we actually reach the upload part
96 | mock_response = mock.MagicMock()
97 | mock_response.ok = True
98 | mock_response.iter_content = mock.MagicMock(return_value=[b"fake audio"])
99 | mock_response.raise_for_status = mock.MagicMock()
100 | tps._session.get = mock.MagicMock(return_value=mock_response)
101 |
102 | # Mock time.sleep to speed up test
103 | with mock.patch("tonie_podcast_sync.toniepodcastsync.time.sleep"):
104 | # This should NOT print "Successfully uploaded" when uploads fail
105 | tps.sync_podcast_to_tonie(podcast, "tonie-123", max_minutes=90)
106 |
107 | captured = capsys.readouterr()
108 |
109 | # Bug: Currently this assertion FAILS because "Successfully uploaded" is printed even on failure
110 | assert "Successfully uploaded" not in captured.out, "Should not print 'Successfully uploaded' when uploads fail"
111 | # Should show error message
112 | assert "Failed to upload" in captured.out, "Should print error message when uploads fail"
113 |
114 |
115 | def test_partial_upload_failure_should_report_correctly(mock_tonie_api_with_tonie, temp_podcast_with_episodes, capsys):
116 | """
117 | Test that when some uploads fail, only successful uploads are reported.
118 |
119 | Bug: Currently the function reports all episodes as successfully uploaded,
120 | even if some failed.
121 | """
122 | cache_dir, episodes = temp_podcast_with_episodes
123 |
124 | tps = ToniePodcastSync("user", "pass")
125 | tps.podcast_cache_directory = cache_dir
126 |
127 | # Mock the upload to fail for the second episode (all retries)
128 | upload_call_count = 0
129 |
130 | def mock_upload(_tonie, _file_path, title):
131 | nonlocal upload_call_count
132 | upload_call_count += 1
133 | # Fail all attempts for Episode 2
134 | if "Episode 2" in title:
135 | msg = "Upload failed"
136 | raise HTTPError(msg)
137 |
138 | mock_tonie_api_with_tonie.upload_file_to_tonie.side_effect = mock_upload
139 |
140 | # Create a mock podcast
141 | podcast = mock.MagicMock()
142 | podcast.epList = episodes
143 | podcast.title = "Test Podcast"
144 | podcast.epSorting = EpisodeSorting.BY_DATE_NEWEST_FIRST
145 |
146 | # Mock download to succeed
147 | mock_response = mock.MagicMock()
148 | mock_response.ok = True
149 | mock_response.iter_content = mock.MagicMock(return_value=[b"fake audio"])
150 | mock_response.raise_for_status = mock.MagicMock()
151 | tps._session.get = mock.MagicMock(return_value=mock_response)
152 |
153 | # Mock time.sleep to speed up test
154 | with mock.patch("tonie_podcast_sync.toniepodcastsync.time.sleep"):
155 | tps.sync_podcast_to_tonie(podcast, "tonie-123", max_minutes=90)
156 |
157 | captured = capsys.readouterr()
158 |
159 | # Should report successful uploads (Episodes 1 and 3)
160 | assert "Successfully uploaded" in captured.out
161 | # Should also report failures
162 | assert "Failed to upload" in captured.out
163 | # Should show 1 failed episode
164 | assert "1 episode(s)" in captured.out
165 | # Episode 2 should be in the failure list
166 | assert "Episode 2" in captured.out
167 |
168 |
169 | def test_all_uploads_succeed(mock_tonie_api_with_tonie, temp_podcast_with_episodes, capsys):
170 | """
171 | Test that when all uploads succeed, success is correctly reported.
172 |
173 | This test ensures our fix doesn't break the success case.
174 | """
175 | cache_dir, episodes = temp_podcast_with_episodes
176 |
177 | tps = ToniePodcastSync("user", "pass")
178 | tps.podcast_cache_directory = cache_dir
179 |
180 | # Mock successful uploads
181 | mock_tonie_api_with_tonie.upload_file_to_tonie.return_value = None
182 |
183 | # Create a mock podcast
184 | podcast = mock.MagicMock()
185 | podcast.epList = episodes
186 | podcast.title = "Test Podcast"
187 | podcast.epSorting = EpisodeSorting.BY_DATE_NEWEST_FIRST
188 |
189 | # Mock download to succeed
190 | mock_response = mock.MagicMock()
191 | mock_response.ok = True
192 | mock_response.iter_content = mock.MagicMock(return_value=[b"fake audio"])
193 | mock_response.raise_for_status = mock.MagicMock()
194 | tps._session.get = mock.MagicMock(return_value=mock_response)
195 |
196 | tps.sync_podcast_to_tonie(podcast, "tonie-123", max_minutes=90)
197 |
198 | captured = capsys.readouterr()
199 |
200 | # Should report success when all uploads succeed
201 | assert "Successfully uploaded" in captured.out, "Should print 'Successfully uploaded' when uploads succeed"
202 | # All 3 episodes should be mentioned
203 | assert "Episode 1" in captured.out
204 | assert "Episode 2" in captured.out
205 | assert "Episode 3" in captured.out
206 | # Should NOT show failure message
207 | assert "Failed to upload" not in captured.out
208 |
209 |
210 | def test_upload_respects_retry_count(mock_tonie_api_with_tonie, temp_podcast_with_episodes):
211 | """
212 | Test that upload retries are limited to UPLOAD_RETRY_COUNT.
213 | """
214 | cache_dir, episodes = temp_podcast_with_episodes
215 |
216 | tps = ToniePodcastSync("user", "pass")
217 | tps.podcast_cache_directory = cache_dir
218 |
219 | # Track upload attempts
220 | attempt_count = 0
221 |
222 | def mock_upload_always_fail(*_args, **_kwargs):
223 | nonlocal attempt_count
224 | attempt_count += 1
225 | msg = "Upload failed"
226 | raise HTTPError(msg)
227 |
228 | mock_tonie_api_with_tonie.upload_file_to_tonie.side_effect = mock_upload_always_fail
229 |
230 | # Create a mock podcast with just one episode
231 | podcast = mock.MagicMock()
232 | podcast.epList = [episodes[0]]
233 | podcast.title = "Test Podcast"
234 | podcast.epSorting = EpisodeSorting.BY_DATE_NEWEST_FIRST
235 |
236 | # Mock download to succeed
237 | mock_response = mock.MagicMock()
238 | mock_response.ok = True
239 | mock_response.iter_content = mock.MagicMock(return_value=[b"fake audio"])
240 | mock_response.raise_for_status = mock.MagicMock()
241 | tps._session.get = mock.MagicMock(return_value=mock_response)
242 |
243 | with mock.patch("tonie_podcast_sync.toniepodcastsync.time.sleep"):
244 | tps.sync_podcast_to_tonie(podcast, "tonie-123", max_minutes=90)
245 |
246 | # Should have attempted UPLOAD_RETRY_COUNT times
247 | assert attempt_count == UPLOAD_RETRY_COUNT, f"Expected {UPLOAD_RETRY_COUNT} upload attempts, got {attempt_count}"
248 |
--------------------------------------------------------------------------------
/tests/test_reshuffle_logic.py:
--------------------------------------------------------------------------------
1 | """Test for reshuffle logic bug in toniepodcastsync.py."""
2 |
3 | from unittest import mock
4 |
5 | import pytest
6 |
7 | from tonie_podcast_sync.constants import MAX_SHUFFLE_ATTEMPTS
8 | from tonie_podcast_sync.podcast import Episode
9 | from tonie_podcast_sync.toniepodcastsync import ToniePodcastSync
10 |
11 |
12 | @pytest.fixture
13 | def mock_tonie_api():
14 | """Mock TonieAPI."""
15 | with mock.patch("tonie_podcast_sync.toniepodcastsync.TonieAPI") as _mock:
16 | api_mock = mock.MagicMock()
17 | api_mock.get_households.return_value = []
18 | api_mock.get_all_creative_tonies.return_value = []
19 | _mock.return_value = api_mock
20 | yield _mock
21 |
22 |
23 | @pytest.mark.usefixtures("mock_tonie_api")
24 | def test_reshuffle_with_single_episode_should_fail():
25 | """
26 | Test that reshuffle correctly handles case with only one episode.
27 |
28 | Bug: If there's only one episode and it matches the current episode on the tonie,
29 | shuffling will never find a different episode. The function should recognize this
30 | and handle it gracefully.
31 | """
32 | tps = ToniePodcastSync("user", "pass")
33 |
34 | # Create a podcast with only one episode
35 | test_feed_data = {
36 | "title": "Single Episode",
37 | "published": "Mon, 01 Jan 2024 10:00:00 +0000",
38 | "published_parsed": (2024, 1, 1, 10, 0, 0, 0, 1, 0),
39 | "id": "test-guid-123",
40 | "itunes_duration": "10:30",
41 | }
42 |
43 | ep = Episode(podcast="Test Podcast", raw=test_feed_data, url="http://example.com/test.mp3")
44 | podcast = mock.MagicMock()
45 | podcast.epList = [ep]
46 | podcast.title = "Test Podcast"
47 |
48 | current_title = "Single Episode (Mon, 01 Jan 2024 10:00:00 +0000)"
49 |
50 | # This should complete without infinite loop and log a warning
51 | # The bug is that it shuffles AFTER checking, not before
52 | with mock.patch("tonie_podcast_sync.toniepodcastsync.log") as mock_log:
53 | tps._ToniePodcastSync__reshuffle_until_different(podcast, current_title)
54 |
55 | # Should log warning that it couldn't find different episode
56 | warning_calls = [
57 | call for call in mock_log.warning.call_args_list if "Could not find different first episode" in str(call)
58 | ]
59 | assert len(warning_calls) == 1, "Should warn when unable to find different episode"
60 |
61 |
62 | @pytest.mark.usefixtures("mock_tonie_api", "monkeypatch")
63 | def test_reshuffle_should_shuffle_before_checking():
64 | """
65 | Test that reshuffle shuffles BEFORE checking, not after.
66 |
67 | Bug: Current implementation checks the first episode BEFORE shuffling on each iteration.
68 | This means the first check is always against the unshuffled list, and subsequent
69 | checks happen before the shuffle. The shuffle should happen BEFORE each check.
70 | """
71 | tps = ToniePodcastSync("user", "pass")
72 |
73 | # Create a podcast with two episodes
74 | test_feed_data_1 = {
75 | "title": "Episode 1",
76 | "published": "Mon, 01 Jan 2024 10:00:00 +0000",
77 | "published_parsed": (2024, 1, 1, 10, 0, 0, 0, 1, 0),
78 | "id": "test-guid-1",
79 | "itunes_duration": "10:30",
80 | }
81 | test_feed_data_2 = {
82 | "title": "Episode 2",
83 | "published": "Tue, 02 Jan 2024 10:00:00 +0000",
84 | "published_parsed": (2024, 1, 2, 10, 0, 0, 0, 1, 0),
85 | "id": "test-guid-2",
86 | "itunes_duration": "10:30",
87 | }
88 |
89 | ep1 = Episode(podcast="Test Podcast", raw=test_feed_data_1, url="http://example.com/test1.mp3")
90 | ep2 = Episode(podcast="Test Podcast", raw=test_feed_data_2, url="http://example.com/test2.mp3")
91 |
92 | podcast = mock.MagicMock()
93 | podcast.epList = [ep1, ep2]
94 | podcast.title = "Test Podcast"
95 |
96 | current_title = "Episode 1 (Mon, 01 Jan 2024 10:00:00 +0000)"
97 |
98 | # Mock random.shuffle to swap the episodes on first call
99 | shuffle_call_count = 0
100 |
101 | def mock_shuffle(lst):
102 | nonlocal shuffle_call_count
103 | shuffle_call_count += 1
104 | # Swap the episodes
105 | lst[0], lst[1] = lst[1], lst[0]
106 |
107 | # The bug: with current implementation, it checks BEFORE shuffling
108 | # So the first check sees ep1, then shuffles to ep2, then checks ep2 (which is now correct)
109 | # But the logic is backwards - should shuffle FIRST then check
110 |
111 | with mock.patch("tonie_podcast_sync.toniepodcastsync.random.shuffle", side_effect=mock_shuffle):
112 | tps._ToniePodcastSync__reshuffle_until_different(podcast, current_title)
113 |
114 | # With the bug, shuffle is called on the first iteration (after failed check)
115 | # With the fix, shuffle should be called BEFORE the first check
116 | # For now, we just verify it eventually succeeds
117 | assert podcast.epList[0] == ep2, "Should have shuffled to different episode"
118 | assert shuffle_call_count >= 1, "Should have called shuffle at least once"
119 |
120 |
121 | @pytest.mark.usefixtures("mock_tonie_api")
122 | def test_reshuffle_succeeds_on_first_shuffle():
123 | """
124 | Test that reshuffle can succeed on the first attempt when episodes are different.
125 |
126 | This test ensures that if a shuffle immediately results in a different first episode,
127 | the function returns success on the first attempt.
128 | """
129 | tps = ToniePodcastSync("user", "pass")
130 |
131 | # Create episodes
132 | test_feed_data_1 = {
133 | "title": "Episode A",
134 | "published": "Mon, 01 Jan 2024 10:00:00 +0000",
135 | "published_parsed": (2024, 1, 1, 10, 0, 0, 0, 1, 0),
136 | "id": "test-guid-a",
137 | "itunes_duration": "10:30",
138 | }
139 | test_feed_data_2 = {
140 | "title": "Episode B",
141 | "published": "Tue, 02 Jan 2024 10:00:00 +0000",
142 | "published_parsed": (2024, 1, 2, 10, 0, 0, 0, 1, 0),
143 | "id": "test-guid-b",
144 | "itunes_duration": "10:30",
145 | }
146 |
147 | ep_a = Episode(podcast="Test Podcast", raw=test_feed_data_1, url="http://example.com/a.mp3")
148 | ep_b = Episode(podcast="Test Podcast", raw=test_feed_data_2, url="http://example.com/b.mp3")
149 |
150 | podcast = mock.MagicMock()
151 | # Start with Episode A first
152 | podcast.epList = [ep_a, ep_b]
153 | podcast.title = "Test Podcast"
154 |
155 | # Current tonie has Episode A
156 | current_title = "Episode A (Mon, 01 Jan 2024 10:00:00 +0000)"
157 |
158 | shuffle_call_count = 0
159 |
160 | def mock_shuffle(lst):
161 | nonlocal shuffle_call_count
162 | shuffle_call_count += 1
163 | # Always put Episode B first
164 | if lst[0] == ep_a:
165 | lst[0], lst[1] = lst[1], lst[0]
166 |
167 | with (
168 | mock.patch("tonie_podcast_sync.toniepodcastsync.random.shuffle", side_effect=mock_shuffle),
169 | mock.patch("tonie_podcast_sync.toniepodcastsync.log") as mock_log,
170 | ):
171 | tps._ToniePodcastSync__reshuffle_until_different(podcast, current_title)
172 |
173 | # Should succeed and log success
174 | info_calls = [call for call in mock_log.info.call_args_list if "Successfully shuffled" in str(call)]
175 | assert len(info_calls) == 1, "Should log success message"
176 |
177 | # With the bug: needs to shuffle once (after first failed check)
178 | # With fix: should shuffle once (before first check succeeds)
179 | # Either way, should only shuffle once for this scenario
180 | assert shuffle_call_count == 1, f"Expected 1 shuffle call, got {shuffle_call_count}"
181 |
182 |
183 | @pytest.mark.usefixtures("mock_tonie_api")
184 | def test_reshuffle_exhausts_all_attempts():
185 | """
186 | Test that reshuffle exhausts all MAX_SHUFFLE_ATTEMPTS when it can't find a different episode.
187 |
188 | This ensures the retry logic is working correctly even when shuffling keeps returning
189 | the same first episode.
190 | """
191 | tps = ToniePodcastSync("user", "pass")
192 |
193 | # Create two episodes but mock shuffle to always keep same order
194 | test_feed_data_1 = {
195 | "title": "Episode X",
196 | "published": "Mon, 01 Jan 2024 10:00:00 +0000",
197 | "published_parsed": (2024, 1, 1, 10, 0, 0, 0, 1, 0),
198 | "id": "test-guid-x",
199 | "itunes_duration": "10:30",
200 | }
201 | test_feed_data_2 = {
202 | "title": "Episode Y",
203 | "published": "Tue, 02 Jan 2024 10:00:00 +0000",
204 | "published_parsed": (2024, 1, 2, 10, 0, 0, 0, 1, 0),
205 | "id": "test-guid-y",
206 | "itunes_duration": "10:30",
207 | }
208 |
209 | ep_x = Episode(podcast="Test Podcast", raw=test_feed_data_1, url="http://example.com/x.mp3")
210 | ep_y = Episode(podcast="Test Podcast", raw=test_feed_data_2, url="http://example.com/y.mp3")
211 |
212 | podcast = mock.MagicMock()
213 | podcast.epList = [ep_x, ep_y]
214 | podcast.title = "Test Podcast"
215 |
216 | current_title = "Episode X (Mon, 01 Jan 2024 10:00:00 +0000)"
217 |
218 | shuffle_call_count = 0
219 |
220 | def mock_shuffle_noop(_lst):
221 | nonlocal shuffle_call_count
222 | shuffle_call_count += 1
223 | # Don't actually shuffle - keep same order
224 |
225 | with (
226 | mock.patch("tonie_podcast_sync.toniepodcastsync.random.shuffle", side_effect=mock_shuffle_noop),
227 | mock.patch("tonie_podcast_sync.toniepodcastsync.log") as mock_log,
228 | ):
229 | tps._ToniePodcastSync__reshuffle_until_different(podcast, current_title)
230 |
231 | # Should have attempted MAX_SHUFFLE_ATTEMPTS times
232 | assert shuffle_call_count == MAX_SHUFFLE_ATTEMPTS, (
233 | f"Expected {MAX_SHUFFLE_ATTEMPTS} shuffle attempts, got {shuffle_call_count}"
234 | )
235 |
236 | # Should log warning about exhausting attempts
237 | # Check for the warning call with the right format
238 | warning_calls = [
239 | call for call in mock_log.warning.call_args_list if "Could not find different first episode" in str(call)
240 | ]
241 | assert len(warning_calls) == 1, "Should warn after exhausting all attempts"
242 |
243 | # Verify the warning includes the correct number of attempts
244 | warning_call = warning_calls[0]
245 | assert MAX_SHUFFLE_ATTEMPTS in warning_call[0], f"Warning should mention {MAX_SHUFFLE_ATTEMPTS} attempts"
246 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # tonie-podcast-sync
2 |
3 | [](#contributors-)
4 |
5 |
6 | tonie-podcast-sync allows synching podcast episodes to [creative tonies](https://tonies.com).
7 |
8 | This is a purely private project and has no association with Boxine GmbH.
9 |
10 | [](https://asciinema.org/a/644812 "asciicast Recording of tonie-podcast-sync")
11 |
12 |
13 | # Prerequisites
14 |
15 | - requires Python >= 3.10.11
16 | - if you use the `volume_adjustment` feature for a Podcast, `ffmpeg` needs to be installed
17 |
18 | # Usage
19 |
20 | tonie-podcast-sync is available as [a pip package on pypi](https://pypi.org/project/tonie-podcast-sync). Install via
21 |
22 | `pip install tonie-podcast-sync`
23 |
24 | You then have two options of using this: via its CLI or as a python library.
25 |
26 | ## via CLI
27 |
28 | The most convienent way is to just use the CLI:
29 |
30 |
31 | A first step is to configure `tonie-podcast-sync`
32 |
33 | ```bash
34 | tonie-podcast-sync create-settings-file
35 | ```
36 |
37 | The command will guide you through the process. The settings and optionally also a secret file will be stored in `~/.toniepodcastsync`.
38 |
39 | Afterwards, you can run
40 |
41 | ```bash
42 | tonie-podcast-sync list-tonies
43 | ```
44 | to get an overview about your tonies, and
45 |
46 | ```bash
47 | tonie-podcast-sync update-tonies
48 | ```
49 |
50 | to fetch new podcast episodes and download them onto the tonies and
51 |
52 | If you want to perform changes (e.g. switch to another podcast), you can edit the settings file `~/.toniepodcastsync/settings.toml` in a text editor.
53 |
54 | ### CLI Settings File Format
55 |
56 | The settings file supports the following options for each creative tonie:
57 |
58 | ```toml
59 | [creative_tonies.
175 |
176 |
186 |
187 |
188 |
189 |
190 |
191 |
192 | > Use the [all-contributors github bot](https://allcontributors.org/docs/en/bot/usage) to add contributors here.
193 |
194 | ## builds upon work of / kudos to
195 | - moritj29's awesome [tonie_api](https://github.com/moritzj29/tonie_api)
196 | - [Tobias Raabe](https://tobiasraabe.github.io/blog/how-to-download-files-with-python.html)
197 | - [Matthew Wimberly](https://codeburst.io/building-an-rss-feed-scraper-with-python-73715ca06e1f)
198 |
--------------------------------------------------------------------------------
/tonie_podcast_sync/cli.py:
--------------------------------------------------------------------------------
1 | """The command line interface module for the tonie-podcast-sync."""
2 |
3 | import warnings
4 |
5 | import tomli_w
6 | from dynaconf.vendor.box.exceptions import BoxError
7 | from rich.console import Console
8 | from rich.prompt import Confirm, IntPrompt, Prompt
9 | from tonie_api.models import CreativeTonie
10 | from typer import Typer
11 |
12 | from tonie_podcast_sync.config import APP_SETTINGS_DIR, settings
13 | from tonie_podcast_sync.constants import MAXIMUM_TONIE_MINUTES
14 | from tonie_podcast_sync.podcast import EpisodeSorting, Podcast
15 | from tonie_podcast_sync.toniepodcastsync import ToniePodcastSync
16 |
17 | warnings.filterwarnings("ignore", category=SyntaxWarning, module="pydub")
18 |
19 | app = Typer(pretty_exceptions_show_locals=False)
20 | _console = Console()
21 |
22 |
23 | @app.command()
24 | def update_tonies() -> None:
25 | """Update the tonies by using the settings file."""
26 | tps = _create_tonie_podcast_sync()
27 | if not tps:
28 | return
29 |
30 | for tonie_id, tonie_config in settings.CREATIVE_TONIES.items():
31 | podcast = _create_podcast_from_config(tonie_config)
32 | tps.sync_podcast_to_tonie(podcast, tonie_id, tonie_config.maximum_length)
33 |
34 |
35 | def _create_tonie_podcast_sync() -> ToniePodcastSync | None:
36 | """Create ToniePodcastSync instance from settings.
37 |
38 | Returns:
39 | ToniePodcastSync instance if successful, None otherwise
40 | """
41 | try:
42 | return ToniePodcastSync(settings.TONIE_CLOUD_ACCESS.USERNAME, settings.TONIE_CLOUD_ACCESS.PASSWORD)
43 | except BoxError:
44 | _console.print(
45 | "There was an error getting the username or password. Please create the settings file or set the "
46 | "environment variables TPS_TONIE_CLOUD_ACCESS_USERNAME and TPS_TONIE_CLOUD_ACCESS_PASSWORD.",
47 | )
48 | return None
49 |
50 |
51 | def _create_podcast_from_config(config: dict) -> Podcast:
52 | """Create a Podcast instance from configuration.
53 |
54 | Args:
55 | config: The configuration dictionary for a Tonie
56 |
57 | Returns:
58 | Configured Podcast instance
59 | """
60 | excluded_title_strings = config.get("excluded_title_strings", [])
61 | episode_max_duration_sec = config.get("episode_max_duration_sec", MAXIMUM_TONIE_MINUTES * 60)
62 |
63 | return Podcast(
64 | config.podcast,
65 | episode_sorting=config.episode_sorting,
66 | volume_adjustment=config.volume_adjustment,
67 | episode_min_duration_sec=config.episode_min_duration_sec,
68 | episode_max_duration_sec=episode_max_duration_sec,
69 | excluded_title_strings=excluded_title_strings,
70 | )
71 |
72 |
73 | @app.command()
74 | def list_tonies() -> None:
75 | """Print an overview of all creative-tonies."""
76 | tps = _create_tonie_podcast_sync()
77 | if tps:
78 | tps.print_tonies_overview()
79 | else:
80 | _console.print("Could not find credentials. Please run 'tonie-podcast-sync create-settings-file' first.")
81 |
82 |
83 | @app.command()
84 | def create_settings_file() -> None:
85 | """Create a settings file in your user home."""
86 | username, password = _get_credentials()
87 |
88 | tps = _validate_and_create_tps(username, password)
89 | if not tps:
90 | return
91 |
92 | tonies = tps.get_tonies()
93 | tonie_configs = _configure_tonies(tonies)
94 |
95 | _save_settings_file(tonie_configs)
96 |
97 |
98 | def _get_credentials() -> tuple[str, str]:
99 | """Get user credentials from existing secrets or prompt user.
100 |
101 | Returns:
102 | Tuple of (username, password)
103 | """
104 | secrets_file = APP_SETTINGS_DIR / ".secrets.toml"
105 |
106 | if secrets_file.exists() and Confirm.ask("You already have secrets set, do you want to keep them?"):
107 | return settings.TONIE_CLOUD_ACCESS.USERNAME, settings.TONIE_CLOUD_ACCESS.PASSWORD
108 |
109 | username = Prompt.ask("Enter your Tonie CloudAPI username")
110 | password = Prompt.ask("Enter your password for Tonie CloudAPI", password=True)
111 |
112 | if Confirm.ask("Do you want to save your login data in a .secrets.toml file"):
113 | _save_credentials(username, password)
114 |
115 | return username, password
116 |
117 |
118 | def _save_credentials(username: str, password: str) -> None:
119 | """Save user credentials to secrets file.
120 |
121 | Args:
122 | username: The Tonie Cloud username
123 | password: The Tonie Cloud password
124 | """
125 | APP_SETTINGS_DIR.mkdir(parents=True, exist_ok=True)
126 | secrets_file = APP_SETTINGS_DIR / ".secrets.toml"
127 |
128 | with secrets_file.open("wb") as file:
129 | tomli_w.dump({"tonie_cloud_access": {"username": username, "password": password}}, file)
130 |
131 |
132 | def _validate_and_create_tps(username: str, password: str) -> ToniePodcastSync | None:
133 | """Validate credentials and create ToniePodcastSync instance.
134 |
135 | Args:
136 | username: The Tonie Cloud username
137 | password: The Tonie Cloud password
138 |
139 | Returns:
140 | ToniePodcastSync instance if successful, None otherwise
141 | """
142 | try:
143 | return ToniePodcastSync(user=username, pwd=password)
144 | except KeyError:
145 | _console.print("It seems like you are not able to login, please provide different login data.")
146 | return None
147 |
148 |
149 | def _configure_tonies(tonies: list[CreativeTonie]) -> dict:
150 | """Interactively configure podcasts for tonies.
151 |
152 | Args:
153 | tonies: List of available creative tonies
154 |
155 | Returns:
156 | Dictionary of tonie configurations
157 | """
158 | configs = {}
159 |
160 | for tonie in tonies:
161 | podcast_url = Prompt.ask(
162 | f"Which podcast do you want to set for Tonie {tonie.name} with ID {tonie.id}?\n"
163 | "Please enter the URL to the podcast, or leave empty if you don't want to set it.",
164 | )
165 |
166 | if not podcast_url:
167 | continue
168 |
169 | configs[tonie.id] = {"podcast": podcast_url, "name": tonie.name}
170 | _configure_tonie_settings(configs, tonie)
171 |
172 | return configs
173 |
174 |
175 | def _configure_tonie_settings(configs: dict, tonie: CreativeTonie) -> None:
176 | """Configure settings for a specific tonie.
177 |
178 | Args:
179 | configs: The configuration dictionary to update
180 | tonie: The tonie to configure
181 | """
182 | _ask_episode_order(configs, tonie)
183 | _ask_maximum_tonie_length(configs, tonie)
184 | _ask_minimum_episode_length(configs, tonie)
185 | _ask_volume_adjustment(configs, tonie)
186 |
187 |
188 | def _save_settings_file(configs: dict) -> None:
189 | """Save tonie configurations to settings file.
190 |
191 | Args:
192 | configs: Dictionary of tonie configurations
193 | """
194 | settings_file = APP_SETTINGS_DIR / "settings.toml"
195 |
196 | with settings_file.open("wb") as file:
197 | tomli_w.dump({"creative_tonies": configs}, file)
198 |
199 |
200 | def _ask_episode_order(configs: dict, tonie: CreativeTonie) -> None:
201 | """Ask user for episode sorting preference.
202 |
203 | Args:
204 | configs: The configuration dictionary to update
205 | tonie: The tonie being configured
206 | """
207 | episode_order = Prompt.ask(
208 | "How would you like your podcast episodes sorted?",
209 | choices=list(EpisodeSorting),
210 | default=EpisodeSorting.BY_DATE_NEWEST_FIRST,
211 | )
212 | configs[tonie.id]["episode_sorting"] = episode_order
213 |
214 |
215 | def _ask_maximum_tonie_length(configs: dict, tonie: CreativeTonie) -> None:
216 | """Ask user for maximum tonie length.
217 |
218 | Args:
219 | configs: The configuration dictionary to update
220 | tonie: The tonie being configured
221 | """
222 | max_length = IntPrompt.ask(
223 | "What should be the maximum total duration of all episodes on this tonie?\n"
224 | f"Defaults to {MAXIMUM_TONIE_MINUTES} minutes (the tonie's maximum).\n"
225 | "Only episodes up to these many minutes in total will be uploaded.\n",
226 | default=90,
227 | )
228 |
229 | if max_length is None or max_length <= 0 or max_length > MAXIMUM_TONIE_MINUTES:
230 | if max_length is not None:
231 | _console.print(
232 | f"The value you have entered is out of range. Will be set to default value of {MAXIMUM_TONIE_MINUTES}.",
233 | )
234 | configs[tonie.id]["maximum_length"] = MAXIMUM_TONIE_MINUTES
235 | else:
236 | configs[tonie.id]["maximum_length"] = max_length
237 |
238 |
239 | def _ask_minimum_episode_length(configs: dict, tonie: CreativeTonie) -> None:
240 | """Ask user for minimum episode length.
241 |
242 | Args:
243 | configs: The configuration dictionary to update
244 | tonie: The tonie being configured
245 | """
246 | min_length = IntPrompt.ask(
247 | "What should be the minimum length (in sec) of each episode?\n"
248 | "Defaults to the minimum of 0 seconds, ie. no minimum length considered.\n"
249 | "Podcast episodes shorter than this value will not be uploaded.",
250 | default=0,
251 | )
252 |
253 | if min_length is None or min_length < 0:
254 | if min_length is not None and min_length < 0:
255 | _console.print("The value you have set is less than 0 and will be set to 0.")
256 | configs[tonie.id]["episode_min_duration_sec"] = 0
257 | elif min_length > 60 * configs[tonie.id]["maximum_length"]:
258 | _console.print(
259 | "The value you have set conflicts with the configured maximum available length for the tonie. "
260 | "It will be set to the maximum, but this might result in no episode being downloaded.",
261 | )
262 | configs[tonie.id]["episode_min_duration_sec"] = 60 * configs[tonie.id]["maximum_length"]
263 | else:
264 | configs[tonie.id]["episode_min_duration_sec"] = min_length
265 |
266 |
267 | def _ask_volume_adjustment(configs: dict, tonie: CreativeTonie) -> None:
268 | """Ask user for volume adjustment setting.
269 |
270 | Args:
271 | configs: The configuration dictionary to update
272 | tonie: The tonie being configured
273 | """
274 | volume_adjustment = IntPrompt.ask(
275 | "Would you like to adjust the volume of the Episodes?\n"
276 | "If set, the downloaded audio will be adjusted by the given amount in dB.\n"
277 | "Defaults to 0, i.e. no adjustment",
278 | default=0,
279 | )
280 |
281 | if volume_adjustment is None or volume_adjustment < 0:
282 | if volume_adjustment is not None and volume_adjustment < 0:
283 | _console.print("The value you have set is less than 0 and will be set to 0.")
284 | configs[tonie.id]["volume_adjustment"] = 0
285 | else:
286 | configs[tonie.id]["volume_adjustment"] = volume_adjustment
287 |
288 |
289 | if __name__ == "__main__":
290 | app()
291 |
--------------------------------------------------------------------------------
/tests/test_episode_selection_with_max_duration.py:
--------------------------------------------------------------------------------
1 | """Test for episode selection when individual episodes exceed max_minutes limit.
2 |
3 | Bug: When using RANDOM sorting with a max_minutes limit smaller than some
4 | individual episodes, if a long episode is randomly selected first, the
5 | _select_episodes_within_time_limit method returns zero episodes instead of
6 | skipping the long episode and selecting shorter ones.
7 | """
8 |
9 | from pathlib import Path
10 | from unittest.mock import Mock
11 |
12 | from tonie_podcast_sync.podcast import Episode, EpisodeSorting, Podcast
13 | from tonie_podcast_sync.toniepodcastsync import ToniePodcastSync
14 |
15 |
16 | def _create_mock_episode(duration_sec: int, title: str = "Test Episode") -> Episode:
17 | """Create a mock episode with specified duration."""
18 | episode = Mock(spec=Episode)
19 | episode.duration_sec = duration_sec
20 | episode.title = title
21 | return episode
22 |
23 |
24 | def test_select_episodes_skips_individual_episodes_exceeding_max_minutes():
25 | """
26 | Test that episodes exceeding max_minutes individually are skipped.
27 |
28 | Scenario: Podcast has episodes of 46min, 40min, 35min (in that order)
29 | max_minutes is 45
30 | Expected: Should skip 46min episode and select 40min episode
31 | Actual (bug): Returns empty list because first episode exceeds limit
32 | """
33 | # Setup
34 | tps = ToniePodcastSync.__new__(ToniePodcastSync)
35 |
36 | # Create mock podcast with episodes in order: 46min, 40min, 35min
37 | mock_podcast = Mock()
38 | mock_podcast.title = "Test Podcast"
39 | mock_podcast.epList = [
40 | _create_mock_episode(2760, "Episode 1 - 46 minutes"), # Exceeds 45 min
41 | _create_mock_episode(2400, "Episode 2 - 40 minutes"), # Fits
42 | _create_mock_episode(2100, "Episode 3 - 35 minutes"), # Fits
43 | ]
44 |
45 | max_minutes = 45
46 |
47 | # Act
48 | selected = tps._select_episodes_within_time_limit(mock_podcast, max_minutes)
49 |
50 | # Assert
51 | assert len(selected) > 0, (
52 | "Should select at least one episode when shorter episodes are available, "
53 | "even if first episode exceeds max_minutes"
54 | )
55 | assert selected[0].title == "Episode 2 - 40 minutes", (
56 | "Should skip first episode (46min) and select second episode (40min)"
57 | )
58 | assert len(selected) == 1, (
59 | "Should select only first episode that fits (40min), as adding 35min would exceed 45min total"
60 | )
61 |
62 |
63 | def test_select_episodes_fits_multiple_when_skipping_long_ones():
64 | """
65 | Test that multiple episodes can be selected when skipping long ones.
66 |
67 | Scenario: Episodes of 50min, 20min, 15min, 50min, 10min
68 | max_minutes is 45
69 | Expected: Skip 50min episodes, select 20min + 15min + 10min = 45min total
70 | """
71 | # Setup
72 | tps = ToniePodcastSync.__new__(ToniePodcastSync)
73 |
74 | mock_podcast = Mock()
75 | mock_podcast.title = "Test Podcast"
76 | mock_podcast.epList = [
77 | _create_mock_episode(3000, "Episode 1 - 50 minutes"), # Too long
78 | _create_mock_episode(1200, "Episode 2 - 20 minutes"), # Fits
79 | _create_mock_episode(900, "Episode 3 - 15 minutes"), # Fits
80 | _create_mock_episode(3000, "Episode 4 - 50 minutes"), # Too long
81 | _create_mock_episode(600, "Episode 5 - 10 minutes"), # Fits
82 | ]
83 |
84 | max_minutes = 45
85 |
86 | # Act
87 | selected = tps._select_episodes_within_time_limit(mock_podcast, max_minutes)
88 |
89 | # Assert
90 | assert len(selected) == 3, "Should select 3 episodes (20min + 15min + 10min)"
91 | assert selected[0].title == "Episode 2 - 20 minutes"
92 | assert selected[1].title == "Episode 3 - 15 minutes"
93 | assert selected[2].title == "Episode 5 - 10 minutes"
94 |
95 | total_duration = sum(ep.duration_sec for ep in selected)
96 | assert total_duration <= max_minutes * 60, "Total duration should not exceed max_minutes"
97 | assert total_duration == 2700, "Total should be 45 minutes (2700 seconds)"
98 |
99 |
100 | def test_select_episodes_returns_empty_when_all_exceed_limit():
101 | """
102 | Test that empty list is returned when all episodes exceed max_minutes.
103 |
104 | Scenario: All episodes are 50+ minutes, max_minutes is 45
105 | Expected: Return empty list (no episodes fit)
106 | """
107 | # Setup
108 | tps = ToniePodcastSync.__new__(ToniePodcastSync)
109 |
110 | mock_podcast = Mock()
111 | mock_podcast.title = "Test Podcast"
112 | mock_podcast.epList = [
113 | _create_mock_episode(3000, "Episode 1 - 50 minutes"),
114 | _create_mock_episode(3300, "Episode 2 - 55 minutes"),
115 | _create_mock_episode(3600, "Episode 3 - 60 minutes"),
116 | ]
117 |
118 | max_minutes = 45
119 |
120 | # Act
121 | selected = tps._select_episodes_within_time_limit(mock_podcast, max_minutes)
122 |
123 | # Assert
124 | assert len(selected) == 0, "Should return empty list when all individual episodes exceed max_minutes"
125 |
126 |
127 | def test_select_episodes_works_normally_when_all_fit():
128 | """
129 | Test that normal behavior is preserved when all episodes fit individually.
130 |
131 | Scenario: Episodes of 20min, 30min, 40min, max_minutes is 45
132 | Expected: Select 20min only (adding 30min would exceed limit)
133 | """
134 | # Setup
135 | tps = ToniePodcastSync.__new__(ToniePodcastSync)
136 |
137 | mock_podcast = Mock()
138 | mock_podcast.title = "Test Podcast"
139 | mock_podcast.epList = [
140 | _create_mock_episode(1200, "Episode 1 - 20 minutes"),
141 | _create_mock_episode(1800, "Episode 2 - 30 minutes"),
142 | _create_mock_episode(2400, "Episode 3 - 40 minutes"),
143 | ]
144 |
145 | max_minutes = 45
146 |
147 | # Act
148 | selected = tps._select_episodes_within_time_limit(mock_podcast, max_minutes)
149 |
150 | # Assert
151 | assert len(selected) == 1, "Should select only first episode (20min)"
152 | assert selected[0].title == "Episode 1 - 20 minutes"
153 |
154 |
155 | def test_select_episodes_with_random_sorting_skips_long_episodes():
156 | """
157 | Test that RANDOM sorting works correctly when some episodes exceed max_minutes.
158 |
159 | This is the actual bug scenario from "Alle gegen Nico" podcast:
160 | - 29 episodes with episode_min_duration_sec=600 (10 min)
161 | - 4 episodes are > 45 minutes individually
162 | - With RANDOM sorting, if a long episode is first, it should be skipped
163 |
164 | We run multiple trials to ensure the fix works consistently across different
165 | random orderings, including when long episodes appear first.
166 | """
167 | # Create a podcast feed that mimics the "Alle gegen Nico" scenario
168 | # Using the actual XML file ensures realistic testing
169 | test_feed = str(Path(__file__).parent / "res" / "alle-gegen-nico.xml")
170 |
171 | podcast = Podcast(
172 | test_feed,
173 | episode_sorting=EpisodeSorting.RANDOM,
174 | episode_min_duration_sec=600,
175 | )
176 |
177 | # Verify the podcast has episodes
178 | assert len(podcast.epList) > 0, "Podcast should have episodes after filtering"
179 |
180 | # Count episodes that are individually > 45 minutes
181 | max_minutes = 45
182 | max_seconds = max_minutes * 60
183 | long_episodes = [ep for ep in podcast.epList if ep.duration_sec > max_seconds]
184 | short_episodes = [ep for ep in podcast.epList if ep.duration_sec <= max_seconds]
185 |
186 | assert len(long_episodes) > 0, "Test requires some episodes > 45 min"
187 | assert len(short_episodes) > 0, "Test requires some episodes <= 45 min"
188 |
189 | # Setup
190 | tps = ToniePodcastSync.__new__(ToniePodcastSync)
191 |
192 | # Run multiple trials to test different random orderings
193 | trials = 20
194 | success_count = 0
195 |
196 | for _ in range(trials):
197 | # Create fresh podcast instance for each trial (new random order)
198 | podcast_trial = Podcast(
199 | test_feed,
200 | episode_sorting=EpisodeSorting.RANDOM,
201 | episode_min_duration_sec=600,
202 | )
203 |
204 | # Act
205 | selected = tps._select_episodes_within_time_limit(podcast_trial, max_minutes)
206 |
207 | # Assert: Should select at least one episode in most cases
208 | # Even if first episode is > 45 min, it should skip and continue
209 | if len(selected) > 0:
210 | success_count += 1
211 |
212 | # Verify selected episodes are within limits
213 | for ep in selected:
214 | assert ep.duration_sec <= max_seconds, (
215 | f"Selected episode '{ep.title}' ({ep.duration_sec}s) exceeds max_minutes ({max_seconds}s)"
216 | )
217 |
218 | # Verify total duration doesn't exceed limit
219 | total_duration = sum(ep.duration_sec for ep in selected)
220 | assert total_duration <= max_seconds, (
221 | f"Total duration ({total_duration}s) exceeds max_minutes ({max_seconds}s)"
222 | )
223 |
224 | # Assert: Should succeed in most trials (allow for rare case where all episodes are too long)
225 | # With 25 out of 29 episodes fitting, we expect high success rate
226 | min_expected_success = trials * 0.8 # At least 80% success rate
227 | assert success_count >= min_expected_success, (
228 | f"Expected at least {min_expected_success} successes out of {trials} trials, "
229 | f"but got {success_count}. The fix should work consistently with RANDOM sorting."
230 | )
231 |
232 |
233 | def test_random_sorting_provides_variety_across_multiple_runs():
234 | """
235 | Test that RANDOM sorting actually produces variety across multiple runs.
236 |
237 | This is a high-level integration test that verifies:
238 | - Random sorting doesn't always return the same episodes
239 | - Different episodes are selected across multiple sync attempts
240 | - The variety is meaningful (not just the same 5 episodes)
241 |
242 | This test covers the full flow from Podcast creation through episode selection,
243 | ensuring the random behavior works correctly end-to-end.
244 | """
245 | test_feed = str(Path(__file__).parent / "res" / "alle-gegen-nico.xml")
246 |
247 | # Setup
248 | tps = ToniePodcastSync.__new__(ToniePodcastSync)
249 | max_minutes = 45
250 |
251 | # Track which unique episodes are selected across multiple runs
252 | selected_episode_titles = set()
253 | runs = 30
254 |
255 | for _ in range(runs):
256 | # Create fresh podcast with RANDOM sorting for each run
257 | podcast = Podcast(
258 | test_feed,
259 | episode_sorting=EpisodeSorting.RANDOM,
260 | episode_min_duration_sec=600,
261 | )
262 |
263 | # Select episodes (simulating what sync_podcast_to_tonie does)
264 | selected = tps._select_episodes_within_time_limit(podcast, max_minutes)
265 |
266 | # Track all selected episode titles
267 | for episode in selected:
268 | selected_episode_titles.add(episode.title)
269 |
270 | # Assert: Should have selected many different episodes across all runs
271 | # With 29 episodes available and 30 runs, we expect to see significant variety
272 | # At minimum, we should see more than just 5 different episodes
273 | min_unique_episodes = 10
274 | assert len(selected_episode_titles) >= min_unique_episodes, (
275 | f"Expected at least {min_unique_episodes} unique episodes across {runs} runs, "
276 | f"but only got {len(selected_episode_titles)}. "
277 | f"RANDOM sorting should provide variety, not the same episodes repeatedly."
278 | )
279 |
280 | # Assert: Should have reasonable variety (at least 30% of available episodes seen)
281 | # This ensures random isn't just picking from a small subset
282 | expected_variety_percentage = 0.3
283 | total_available = len(
284 | Podcast(
285 | test_feed,
286 | episode_sorting=EpisodeSorting.BY_DATE_NEWEST_FIRST,
287 | episode_min_duration_sec=600,
288 | ).epList
289 | )
290 | min_variety = int(total_available * expected_variety_percentage)
291 | assert len(selected_episode_titles) >= min_variety, (
292 | f"Expected to see at least {min_variety} different episodes "
293 | f"({expected_variety_percentage * 100}% of {total_available} available), "
294 | f"but only saw {len(selected_episode_titles)}. "
295 | f"RANDOM sorting should explore the episode catalog more broadly."
296 | )
297 |
298 |
299 | def test_random_sorting_integration_with_episode_caching():
300 | """
301 | Integration test: Verify RANDOM sorting works with the full caching workflow.
302 |
303 | This test verifies that when using RANDOM sorting:
304 | 1. Different episodes are considered across multiple podcast instances
305 | 2. The episode selection respects max_minutes even with random ordering
306 | 3. First episodes can vary (proving randomness at the selection level)
307 |
308 | This is closer to real usage where podcast instances are created fresh
309 | and episodes are selected for caching.
310 | """
311 | test_feed = str(Path(__file__).parent / "res" / "alle-gegen-nico.xml")
312 | tps = ToniePodcastSync.__new__(ToniePodcastSync)
313 | max_minutes = 45
314 |
315 | # Run multiple times and track first episodes selected
316 | first_episodes = []
317 | total_runs = 20
318 |
319 | for _ in range(total_runs):
320 | podcast = Podcast(
321 | test_feed,
322 | episode_sorting=EpisodeSorting.RANDOM,
323 | episode_min_duration_sec=600,
324 | )
325 |
326 | selected = tps._select_episodes_within_time_limit(podcast, max_minutes)
327 |
328 | if selected:
329 | first_episodes.append(selected[0].title)
330 |
331 | # Assert: First episode should vary across runs (not always the same)
332 | unique_first_episodes = set(first_episodes)
333 | assert len(unique_first_episodes) > 1, (
334 | f"RANDOM sorting should vary the first episode selected, "
335 | f"but got the same episode {len(first_episodes)} times: {first_episodes[0] if first_episodes else 'none'}"
336 | )
337 |
338 | # Assert: Should see reasonable variety in first episodes (at least 5 different ones)
339 | min_unique_first = 5
340 | assert len(unique_first_episodes) >= min_unique_first, (
341 | f"Expected at least {min_unique_first} different first episodes across {total_runs} runs, "
342 | f"but only saw {len(unique_first_episodes)}. "
343 | f"This suggests RANDOM sorting is not sufficiently random."
344 | )
345 |
346 | # Assert: All selected episode groups should respect time limit
347 | for i in range(len(first_episodes)):
348 | podcast_check = Podcast(
349 | test_feed,
350 | episode_sorting=EpisodeSorting.RANDOM,
351 | episode_min_duration_sec=600,
352 | )
353 | # Just verify the logic would work - we already tested time limits above
354 | assert len(podcast_check.epList) > 0, f"Run {i}: Podcast should have episodes"
355 |
--------------------------------------------------------------------------------
/ressources/tps_asciinema.cast:
--------------------------------------------------------------------------------
1 | {"version": 2, "width": 80, "height": 25, "timestamp": 1709319880, "env": {"SHELL": "/bin/zsh", "TERM": "xterm-256color"}}
2 | [0.051899, "o", "\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m \r \r"]
3 | [0.065647, "o", "\r\u001b[0m\u001b[27m\u001b[24m\u001b[J\r\n\u001b[1;36m~\u001b[0m via \u001b[1;33m🐍 \u001b[0m\u001b[1;33m\u001b[0m\u001b[1;33mv3.11.7\u001b[0m\u001b[1;33m \u001b[0m\r\n\u001b[1;32m❯\u001b[0m \u001b[K\u001b[?2004h"]
4 | [0.650265, "o", "p"]
5 | [0.859252, "o", "\bpi"]
6 | [0.993876, "o", "p"]
7 | [1.222038, "o", " "]
8 | [1.383968, "o", "i"]
9 | [1.474104, "o", "n"]
10 | [1.581444, "o", "s"]
11 | [1.684556, "o", "t"]
12 | [1.833869, "o", "a"]
13 | [1.953462, "o", "l"]
14 | [2.089069, "o", "l"]
15 | [3.544966, "o", " "]
16 | [4.055148, "o", "t"]
17 | [4.114886, "o", "o"]
18 | [4.232481, "o", "n"]
19 | [4.368954, "o", "i"]
20 | [4.474351, "o", "e"]
21 | [4.6563, "o", "-"]
22 | [4.893834, "o", "p"]
23 | [5.118435, "o", "o"]
24 | [5.194078, "o", "d"]
25 | [5.419266, "o", "c"]
26 | [5.554805, "o", "a"]
27 | [5.764842, "o", "s"]
28 | [5.901207, "o", "t"]
29 | [6.0205, "o", "-"]
30 | [6.156234, "o", "s"]
31 | [6.407599, "o", "y"]
32 | [6.514685, "o", "n"]
33 | [6.648754, "o", "c"]
34 | [6.904082, "o", "\u001b[?2004l\r\r\n"]
35 | [7.865387, "o", "Collecting tonie-podcast-sync\r\n"]
36 | [7.910023, "o", " Using cached tonie_podcast_sync-3.0.0-py3-none-any.whl.metadata (8.0 kB)\r\n"]
37 | [7.915032, "o", "Requirement already satisfied: dynaconf<4.0.0,>=3.2.3 in /opt/homebrew/lib/python3.11/site-packages (from tonie-podcast-sync) (3.2.4)\r\n"]
38 | [7.915866, "o", "Requirement already satisfied: feedparser<7.0.0,>=6.0.10 in /opt/homebrew/lib/python3.11/site-packages (from tonie-podcast-sync) (6.0.10)\r\n"]
39 | [7.916505, "o", "Requirement already satisfied: pathvalidate<4.0.0,>=3.2.0 in /opt/homebrew/lib/python3.11/site-packages (from tonie-podcast-sync) (3.2.0)\r\n"]
40 | [7.916922, "o", "Requirement already satisfied: pydub<0.26.0,>=0.25.1 in /opt/homebrew/lib/python3.11/site-packages (from tonie-podcast-sync) (0.25.1)\r\n"]
41 | [7.917605, "o", "Requirement already satisfied: python-slugify<9.0.0,>=8.0.1 in /opt/homebrew/lib/python3.11/site-packages (from tonie-podcast-sync) (8.0.1)\r\n"]
42 | [7.91847, "o", "Requirement already satisfied: rich<14.0.0,>=13.5.2 in /opt/homebrew/lib/python3.11/site-packages (from tonie-podcast-sync) (13.5.2)\r\n"]
43 | [7.918915, "o", "Requirement already satisfied: tomli-w<2.0.0,>=1.0.0 in /opt/homebrew/lib/python3.11/site-packages (from tonie-podcast-sync) (1.0.0)\r\n"]
44 | [7.919365, "o", "Requirement already satisfied: tonie-api<0.2.0,>=0.1.1 in /opt/homebrew/lib/python3.11/site-packages (from tonie-podcast-sync) (0.1.1)\r\n"]
45 | [7.920326, "o", "Requirement already satisfied: typer<0.10.0,>=0.9.0 in /opt/homebrew/lib/python3.11/site-packages (from typer[all]<0.10.0,>=0.9.0->tonie-podcast-sync) (0.9.0)\r\n"]
46 | [7.9282, "o", "Requirement already satisfied: sgmllib3k in /opt/homebrew/lib/python3.11/site-packages (from feedparser<7.0.0,>=6.0.10->tonie-podcast-sync) (1.0.0)\r\n"]
47 | [7.933168, "o", "Requirement already satisfied: text-unidecode>=1.3 in /opt/homebrew/lib/python3.11/site-packages (from python-slugify<9.0.0,>=8.0.1->tonie-podcast-sync) (1.3)\r\n"]
48 | [7.935438, "o", "Requirement already satisfied: markdown-it-py>=2.2.0 in /opt/homebrew/lib/python3.11/site-packages (from rich<14.0.0,>=13.5.2->tonie-podcast-sync) (3.0.0)\r\n"]
49 | [7.935868, "o", "Requirement already satisfied: pygments<3.0.0,>=2.13.0 in /opt/homebrew/lib/python3.11/site-packages (from rich<14.0.0,>=13.5.2->tonie-podcast-sync) (2.16.1)\r\n"]
50 | [7.938813, "o", "Requirement already satisfied: pydantic<3.0.0,>=2.3.0 in /opt/homebrew/lib/python3.11/site-packages (from tonie-api<0.2.0,>=0.1.1->tonie-podcast-sync) (2.5.3)\r\n"]
51 | [7.939332, "o", "Requirement already satisfied: requests-oauthlib<2.0.0,>=1.3.1 in /opt/homebrew/lib/python3.11/site-packages (from tonie-api<0.2.0,>=0.1.1->tonie-podcast-sync) (1.3.1)\r\n"]
52 | [7.946695, "o", "Requirement already satisfied: click<9.0.0,>=7.1.1 in /opt/homebrew/lib/python3.11/site-packages (from typer<0.10.0,>=0.9.0->typer[all]<0.10.0,>=0.9.0->tonie-podcast-sync) (8.1.3)\r\n"]
53 | [7.947162, "o", "Requirement already satisfied: typing-extensions>=3.7.4.3 in /opt/homebrew/lib/python3.11/site-packages (from typer<0.10.0,>=0.9.0->typer[all]<0.10.0,>=0.9.0->tonie-podcast-sync) (4.9.0)\r\n"]
54 | [7.956015, "o", "Requirement already satisfied: colorama<0.5.0,>=0.4.3 in /opt/homebrew/lib/python3.11/site-packages (from typer[all]<0.10.0,>=0.9.0->tonie-podcast-sync) (0.4.6)\r\n"]
55 | [7.95669, "o", "Requirement already satisfied: shellingham<2.0.0,>=1.3.0 in /opt/homebrew/lib/python3.11/site-packages (from typer[all]<0.10.0,>=0.9.0->tonie-podcast-sync) (1.5.4)\r\n"]
56 | [7.96606, "o", "Requirement already satisfied: mdurl~=0.1 in /opt/homebrew/lib/python3.11/site-packages (from markdown-it-py>=2.2.0->rich<14.0.0,>=13.5.2->tonie-podcast-sync) (0.1.2)\r\n"]
57 | [7.968942, "o", "Requirement already satisfied: annotated-types>=0.4.0 in /opt/homebrew/lib/python3.11/site-packages (from pydantic<3.0.0,>=2.3.0->tonie-api<0.2.0,>=0.1.1->tonie-podcast-sync) (0.5.0)\r\n"]
58 | [7.969505, "o", "Requirement already satisfied: pydantic-core==2.14.6 in /opt/homebrew/lib/python3.11/site-packages (from pydantic<3.0.0,>=2.3.0->tonie-api<0.2.0,>=0.1.1->tonie-podcast-sync) (2.14.6)\r\n"]
59 | [7.974456, "o", "Requirement already satisfied: oauthlib>=3.0.0 in /opt/homebrew/lib/python3.11/site-packages (from requests-oauthlib<2.0.0,>=1.3.1->tonie-api<0.2.0,>=0.1.1->tonie-podcast-sync) (3.2.2)\r\n"]
60 | [7.974956, "o", "Requirement already satisfied: requests>=2.0.0 in /opt/homebrew/lib/python3.11/site-packages (from requests-oauthlib<2.0.0,>=1.3.1->tonie-api<0.2.0,>=0.1.1->tonie-podcast-sync) (2.31.0)\r\n"]
61 | [7.9861, "o", "Requirement already satisfied: charset-normalizer<4,>=2 in /opt/homebrew/lib/python3.11/site-packages (from requests>=2.0.0->requests-oauthlib<2.0.0,>=1.3.1->tonie-api<0.2.0,>=0.1.1->tonie-podcast-sync) (3.3.2)\r\n"]
62 | [7.986782, "o", "Requirement already satisfied: idna<4,>=2.5 in /opt/homebrew/lib/python3.11/site-packages (from requests>=2.0.0->requests-oauthlib<2.0.0,>=1.3.1->tonie-api<0.2.0,>=0.1.1->tonie-podcast-sync) (3.6)\r\n"]
63 | [7.987294, "o", "Requirement already satisfied: urllib3<3,>=1.21.1 in /opt/homebrew/lib/python3.11/site-packages (from requests>=2.0.0->requests-oauthlib<2.0.0,>=1.3.1->tonie-api<0.2.0,>=0.1.1->tonie-podcast-sync) (2.1.0)\r\n"]
64 | [7.987679, "o", "Requirement already satisfied: certifi>=2017.4.17 in /opt/homebrew/lib/python3.11/site-packages (from requests>=2.0.0->requests-oauthlib<2.0.0,>=1.3.1->tonie-api<0.2.0,>=0.1.1->tonie-podcast-sync) (2023.11.17)\r\n"]
65 | [7.996861, "o", "Using cached tonie_podcast_sync-3.0.0-py3-none-any.whl (12 kB)\r\n"]
66 | [8.242347, "o", "Installing collected packages: tonie-podcast-sync\r\n"]
67 | [8.26451, "o", "Successfully installed tonie-podcast-sync-3.0.0\r\n"]
68 | [8.83285, "o", "\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m \r \r"]
69 | [8.847782, "o", "\r\u001b[0m\u001b[27m\u001b[24m\u001b[J\r\n\u001b[1;36m~\u001b[0m via \u001b[1;33m🐍 \u001b[0m\u001b[1;33m\u001b[0m\u001b[1;33mv3.11.7\u001b[0m\u001b[1;33m \u001b[0m\r\n\u001b[1;32m❯\u001b[0m \u001b[K\u001b[?2004h"]
70 | [10.056551, "o", "t"]
71 | [10.16139, "o", "\bto"]
72 | [10.309736, "o", "n"]
73 | [10.504834, "o", "i"]
74 | [10.640425, "o", "e"]
75 | [10.850597, "o", "-"]
76 | [11.091033, "o", "p"]
77 | [11.299373, "o", "o"]
78 | [11.389352, "o", "d"]
79 | [11.601901, "o", "c"]
80 | [11.780968, "o", "a"]
81 | [12.004368, "o", "s"]
82 | [12.139523, "o", "t"]
83 | [12.30348, "o", "-"]
84 | [12.442217, "o", "s"]
85 | [12.665289, "o", "y"]
86 | [12.81459, "o", "n"]
87 | [12.921992, "o", "c"]
88 | [13.04155, "o", " "]
89 | [13.310579, "o", "-"]
90 | [13.445542, "o", "-"]
91 | [13.745574, "o", "h"]
92 | [13.910452, "o", "e"]
93 | [14.089566, "o", "l"]
94 | [14.225295, "o", "p"]
95 | [14.887772, "o", "\u001b[?2004l\r\r\n"]
96 | [15.410796, "o", "\u001b[1m \u001b[0m\r\n\u001b[1m \u001b[0m\u001b[1;33mUsage: \u001b[0m\u001b[1mtonie-podcast-sync [OPTIONS] COMMAND [ARGS]...\u001b[0m\u001b[1m \u001b[0m\u001b[1m \u001b[0m\r\n\u001b[1m \u001b[0m\r\n"]
97 | [15.412367, "o", "\u001b[2m╭─\u001b[0m\u001b[2m Options \u001b[0m\u001b[2m───────────────────────────────────────────────────────────────────\u001b[0m\u001b[2m─╮\u001b[0m\r\n\u001b[2m│\u001b[0m \u001b[1;36m-\u001b[0m\u001b[1;36m-install\u001b[0m\u001b[1;36m-completion\u001b[0m Install completion for the current shell. \u001b[2m│\u001b[0m\r\n\u001b[2m│\u001b[0m \u001b[1;36m-\u001b[0m\u001b[1;36m-show\u001b[0m\u001b[1;36m-completion\u001b[0m Show completion for the current shell, to copy \u001b[2m│\u001b[0m\r\n\u001b[2m│\u001b[0m it or customize the installation. \u001b[2m│\u001b[0m\r\n\u001b[2m│\u001b[0m \u001b[1;36m-\u001b[0m\u001b[1;36m-help\u001b[0m Show this message and exit. \u001b[2m│\u001b[0m\r\n\u001b[2m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\r\n"]
98 | [15.412761, "o", "\u001b[2m╭─\u001b[0m\u001b[2m Commands \u001b[0m\u001b[2m──────────────────────────────────────────────────────────────────\u001b[0m\u001b[2m─╮\u001b[0m\r\n\u001b[2m│\u001b[0m \u001b[1;36mcreate-settings-file \u001b[0m\u001b[1;36m \u001b[0m Create a settings file in your user home. \u001b[2m│\u001b[0m\r\n\u001b[2m│\u001b[0m \u001b[1;36mupdate-tonies \u001b[0m\u001b[1;36m \u001b[0m Update the tonies by using the settings file. \u001b[2m│\u001b[0m\r\n\u001b[2m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\r\n"]
99 | [15.412788, "o", "\r\n"]
100 | [15.434376, "o", "\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m \r \r"]
101 | [15.448773, "o", "\r\u001b[0m\u001b[27m\u001b[24m\u001b[J\r\n\u001b[1;36m~\u001b[0m via \u001b[1;33m🐍 \u001b[0m\u001b[1;33m\u001b[0m\u001b[1;33mv3.11.7\u001b[0m\u001b[1;33m \u001b[0m\r\n\u001b[1;32m❯\u001b[0m \u001b[K\u001b[?2004h"]
102 | [18.636472, "o", "t"]
103 | [18.860263, "o", "\bto"]
104 | [19.071258, "o", "n"]
105 | [19.280871, "o", "i"]
106 | [19.445047, "o", "e"]
107 | [19.805211, "o", "-"]
108 | [20.074197, "o", "p"]
109 | [20.270453, "o", "o"]
110 | [20.361336, "o", "d"]
111 | [20.629834, "o", "c"]
112 | [20.779999, "o", "a"]
113 | [21.022155, "o", "s"]
114 | [21.140695, "o", "t"]
115 | [21.319961, "o", "-"]
116 | [21.425901, "o", "s"]
117 | [21.663908, "o", "y"]
118 | [21.844894, "o", "n"]
119 | [21.949798, "o", "c"]
120 | [22.099988, "o", " "]
121 | [22.461476, "o", "c"]
122 | [22.699379, "o", "r"]
123 | [22.80559, "o", "e"]
124 | [22.940797, "o", "a"]
125 | [23.04573, "o", "t"]
126 | [23.106706, "o", "e"]
127 | [23.317392, "o", "-"]
128 | [23.448957, "o", "s"]
129 | [23.586386, "o", "e"]
130 | [23.674092, "o", "t"]
131 | [23.959758, "o", "t"]
132 | [24.153504, "o", "i"]
133 | [24.202316, "o", "n"]
134 | [24.303611, "o", "g"]
135 | [24.409776, "o", "s"]
136 | [24.558322, "o", "-"]
137 | [24.710337, "o", "f"]
138 | [24.90507, "o", "i"]
139 | [25.114949, "o", "l"]
140 | [25.221172, "o", "e"]
141 | [27.816206, "o", "\u001b[?2004l\r\r\n"]
142 | [28.028082, "o", "Enter your Tonie CloudAPI username: "]
143 | [28.941672, "o", "e"]
144 | [29.032179, "o", "x"]
145 | [29.165245, "o", "a"]
146 | [29.331146, "o", "m"]
147 | [29.451197, "o", "p"]
148 | [29.555634, "o", "l"]
149 | [29.646871, "o", "e"]
150 | [29.75134, "o", "_"]
151 | [29.857341, "o", "a"]
152 | [29.947579, "o", "d"]
153 | [30.079251, "o", "d"]
154 | [30.156255, "o", "r"]
155 | [30.334095, "o", "e"]
156 | [30.441753, "o", "s"]
157 | [30.589402, "o", "s"]
158 | [30.725212, "o", "@"]
159 | [30.859015, "o", "e"]
160 | [31.76112, "o", "m"]
161 | [32.09108, "o", "a"]
162 | [32.210085, "o", "i"]
163 | [32.377085, "o", "l"]
164 | [32.480884, "o", "."]
165 | [32.734949, "o", "c"]
166 | [32.795122, "o", "o"]
167 | [32.915019, "o", "m"]
168 | [33.501444, "o", "\r\n"]
169 | [33.502949, "o", "Enter your password for Tonie CloudAPI: "]
170 | [35.482451, "o", "\r\n"]
171 | [35.483873, "o", "Do you want to save your login data in a .secrets.toml file \u001b[1;35m[y/n]\u001b[0m: "]
172 | [36.289555, "o", "y"]
173 | [36.757272, "o", "\r\n"]
174 | [37.578733, "o", "Which podcast do you want to set for Tonie Feuerwehr Kreativ-Tonie with ID \r\n700053045142E0B5?\r\nPlease enter the URL to the podcast, or leave empty if you don't want to set \r\nit.: "]
175 | [41.859535, "o", "https://feeds.br.de/checkpod-der-podcast-mit-checker-tobi/feed.xml"]
176 | [44.319311, "o", "\r\n"]
177 | [44.320611, "o", "How would you like your podcast episodes sorted? \r\n\u001b[1;35m[by_date_newest_first/by_date_oldest_first/random]\u001b[0m \r\n\u001b[1;36m(EpisodeSorting.BY_DATE_NEWEST_FIRST)\u001b[0m: "]
178 | [45.594179, "o", "r"]
179 | [45.845718, "o", "a"]
180 | [45.997629, "o", "n"]
181 | [46.176443, "o", "d"]
182 | [46.357764, "o", "o"]
183 | [46.536541, "o", "m"]
184 | [47.062208, "o", "\r\n"]
185 | [47.062979, "o", "What should be the maximum length of the podcast?\r\nDefaults to the maximum of 90 minutes. \u001b[1;36m(90)\u001b[0m: "]
186 | [48.832646, "o", "6"]
187 | [49.146396, "o", "5"]
188 | [49.836716, "o", "\r\n"]
189 | [49.838238, "o", "What should be the minimum length (in sec) of the podcast?\r\nDefaults to the minimum of 0 seconds.\r\nPodcasts shorter than the input, will not be uploaded. \u001b[1;36m(0)\u001b[0m: "]
190 | [51.983192, "o", "5"]
191 | [52.191693, "o", "0"]
192 | [52.868134, "o", "\r\n"]
193 | [52.869816, "o", "Would you like to adjust the volume of the Episodes?\r\nIf set, the downloaded audio will be adjusted by the given amount in dB.\r\nDefaults to 0, i.e. no adjustment \u001b[1;36m(0)\u001b[0m: "]
194 | [54.381524, "o", "\r\n"]
195 | [54.382515, "o", "Which podcast do you want to set for Tonie grauer Kreativ-Tonie with ID \r\n3D7E0500F204C70F?\r\nPlease enter the URL to the podcast, or leave empty if you don't want to set \r\nit.: "]
196 | [59.00488, "o", "\r\n"]
197 | [59.005938, "o", "Which podcast do you want to set for Tonie Pirat Kreativ-Tonie with ID \r\nC0B1F0335008480E?\r\nPlease enter the URL to the podcast, or leave empty if you don't want to set \r\nit.: "]
198 | [62.066811, "o", "https://kinder.wdr.de/radio/diemaus/audio/diemaus-60/diemaus-60-106.podcast"]
199 | [63.100215, "o", "\r\n"]
200 | [63.101416, "o", "How would you like your podcast episodes sorted? \r\n\u001b[1;35m[by_date_newest_first/by_date_oldest_first/random]\u001b[0m \r\n\u001b[1;36m(EpisodeSorting.BY_DATE_NEWEST_FIRST)\u001b[0m: "]
201 | [65.017942, "o", "\r\n"]
202 | [65.018905, "o", "What should be the maximum length of the podcast?\r\nDefaults to the maximum of 90 minutes. \u001b[1;36m(90)\u001b[0m: "]
203 | [65.679791, "o", "\r\n"]
204 | [65.680882, "o", "What should be the minimum length (in sec) of the podcast?\r\nDefaults to the minimum of 0 seconds.\r\nPodcasts shorter than the input, will not be uploaded. \u001b[1;36m(0)\u001b[0m: "]
205 | [66.217855, "o", "\r\n"]
206 | [66.219036, "o", "Would you like to adjust the volume of the Episodes?\r\nIf set, the downloaded audio will be adjusted by the given amount in dB.\r\nDefaults to 0, i.e. no adjustment \u001b[1;36m(0)\u001b[0m: "]
207 | [66.849039, "o", "\r\n"]
208 | [66.90667, "o", "\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m \r \r"]
209 | [66.92365, "o", "\r\u001b[0m\u001b[27m\u001b[24m\u001b[J\r\n\u001b[1;36m~\u001b[0m via \u001b[1;33m🐍 \u001b[0m\u001b[1;33m\u001b[0m\u001b[1;33mv3.11.7\u001b[0m\u001b[1;33m \u001b[0mtook \u001b[1;33m39s\u001b[0m \r\n\u001b[1;32m❯\u001b[0m \u001b[K\u001b[?2004h"]
210 | [69.773762, "o", "t"]
211 | [69.865348, "o", "\bto"]
212 | [70.072223, "o", "n"]
213 | [70.221604, "o", "i"]
214 | [70.3746, "o", "e"]
215 | [70.58306, "o", "-"]
216 | [70.898427, "o", "p"]
217 | [71.137369, "o", "o"]
218 | [71.257575, "o", "d"]
219 | [71.560796, "o", "c"]
220 | [71.73989, "o", "a"]
221 | [71.992763, "o", "s"]
222 | [72.11262, "o", "t"]
223 | [72.291913, "o", "-"]
224 | [72.728779, "o", "s"]
225 | [72.952761, "o", "y"]
226 | [73.19276, "o", "n"]
227 | [73.373843, "o", "c"]
228 | [73.507109, "o", " "]
229 | [74.30538, "o", "u"]
230 | [74.753263, "o", "p"]
231 | [75.773207, "o", "d"]
232 | [76.057476, "o", "a"]
233 | [76.326709, "o", "t"]
234 | [76.44786, "o", "e"]
235 | [76.689346, "o", "-"]
236 | [77.123745, "o", "t"]
237 | [77.25903, "o", "o"]
238 | [77.497765, "o", "n"]
239 | [77.781966, "o", "i"]
240 | [77.90304, "o", "e"]
241 | [78.113047, "o", "s"]
242 | [80.153603, "o", "\u001b[?2004l\r\r\n"]
243 | [81.218549, "o", "Wipe all chapters of Tonie \u001b[32m'Feuerwehr Kreativ-Tonie'\u001b[0m\r\n"]
244 | [81.544316, "o", "\u001b[?25l"]
245 | [81.547867, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: Cache episodes ... \u001b[38;5;237m━━━━━━━\u001b[0m \u001b[35m 0%\u001b[0m \u001b[36m-:--:--\u001b[0m"]
246 | [82.049459, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: Cache episodes ... \u001b[38;5;237m━━━━━━━\u001b[0m \u001b[35m 0%\u001b[0m \u001b[36m-:--:--\u001b[0m"]
247 | [82.5552, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: Cache episodes ... \u001b[38;5;237m━━━━━━━\u001b[0m \u001b[35m 0%\u001b[0m \u001b[36m-:--:--\u001b[0m"]
248 | [83.063129, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: Cache episodes ... \u001b[38;2;249;38;114m━━━\u001b[0m\u001b[38;2;249;38;114m╸\u001b[0m\u001b[38;5;237m━━━\u001b[0m \u001b[35m 50%\u001b[0m \u001b[36m-:--:--\u001b[0m"]
249 | [83.364081, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: Cache episodes ... \u001b[38;2;114;156;31m━━━━━━━\u001b[0m \u001b[35m100%\u001b[0m \u001b[33m0:00:01\u001b[0m"]
250 | [83.364627, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: Cache episodes ... \u001b[38;2;114;156;31m━━━━━━━\u001b[0m \u001b[35m100%\u001b[0m \u001b[33m0:00:01\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"]
251 | [83.364691, "o", "\u001b[?25l"]
252 | [83.365369, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
253 | [83.873226, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
254 | [84.378906, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
255 | [84.886678, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
256 | [85.389432, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
257 | [85.895372, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
258 | [86.403555, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
259 | [86.908332, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
260 | [87.416413, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
261 | [87.922186, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
262 | [88.427167, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
263 | [88.931698, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
264 | [89.439969, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
265 | [89.942682, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
266 | [90.450973, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
267 | [90.959082, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
268 | [91.467104, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
269 | [91.972571, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
270 | [92.480794, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
271 | [92.985963, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
272 | [93.489188, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
273 | [93.995177, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
274 | [94.503209, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
275 | [95.008884, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
276 | [95.517113, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
277 | [96.024233, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
278 | [96.529502, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
279 | [97.037702, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
280 | [97.546021, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
281 | [98.049916, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
282 | [98.556264, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
283 | [99.061663, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
284 | [99.566274, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
285 | [100.073744, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
286 | [100.579052, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
287 | [101.083782, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
288 | [101.585955, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
289 | [102.090568, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
290 | [102.596185, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
291 | [102.791361, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m "]
292 | [102.79288, "o", "\r\u001b[2KCheckPod - Der Podcast mit Checker Tobi: transferring 2 episodes to Feuerweh… \u001b[35m…\u001b[0m \r\n\u001b[?25h\r\u001b[1A\u001b[2K"]
293 | [102.793931, "o", "CheckPod - Der Podcast mit Checker Tobi: Successfully uploaded \u001b[1m[\u001b[0m\u001b[32m'Spielen | Vom \u001b[0m\r\n\u001b[32mGewinnen und Verlieren \u001b[0m\u001b[32m(\u001b[0m\u001b[32mMon, 29 Aug 2022 06:00:00 +0200\u001b[0m\u001b[32m)\u001b[0m\u001b[32m'\u001b[0m, \u001b[32m'Stimme | Von ganz \u001b[0m\r\n\u001b[32mleise bis laut \u001b[0m\u001b[32m(\u001b[0m\u001b[32mFri, 04 Mar 2022 06:00:00 +0100\u001b[0m\u001b[32m)\u001b[0m\u001b[32m'\u001b[0m\u001b[1m]\u001b[0m to Feuerwehr Kreativ-Tonie \r\n\u001b[1m(\u001b[0m700053045142E0B5\u001b[1m)\u001b[0m\r\n"]
294 | [102.990677, "o", "Podcast \u001b[32m'Die Maus zum Hören'\u001b[0m has no new episodes, latest episode is \u001b[32m'Drachen \u001b[0m\r\n\u001b[32m(\u001b[0m\u001b[32mFri, 01 Mar 2024 05:00:10 GMT\u001b[0m\u001b[32m)\u001b[0m\u001b[32m'\u001b[0m\r\n"]
295 | [103.020397, "o", "\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m \r \r"]
296 | [103.035609, "o", "\r\u001b[0m\u001b[27m\u001b[24m\u001b[J\r\n\u001b[1;36m~\u001b[0m via \u001b[1;33m🐍 \u001b[0m\u001b[1;33m\u001b[0m\u001b[1;33mv3.11.7\u001b[0m\u001b[1;33m \u001b[0mtook \u001b[1;33m22s\u001b[0m \r\n\u001b[1;32m❯\u001b[0m \u001b[K\u001b[?2004h"]
297 | [105.377632, "o", "\u001b[?2004l\r\r\n"]
298 |
--------------------------------------------------------------------------------
/tonie_podcast_sync/toniepodcastsync.py:
--------------------------------------------------------------------------------
1 | """The Tonie Podcast Sync API."""
2 |
3 | import logging
4 | import platform
5 | import random
6 | import subprocess
7 | import tempfile
8 | import time
9 | from collections import deque
10 | from io import BytesIO
11 | from pathlib import Path
12 |
13 | import requests
14 | from pathvalidate import sanitize_filename, sanitize_filepath
15 | from pydub import AudioSegment
16 | from requests.exceptions import HTTPError, RequestException
17 | from rich.console import Console
18 | from rich.progress import track
19 | from rich.table import Table
20 | from tonie_api.api import TonieAPI
21 | from tonie_api.models import CreativeTonie
22 |
23 | from tonie_podcast_sync.constants import (
24 | DOWNLOAD_RETRY_COUNT,
25 | MAX_SHUFFLE_ATTEMPTS,
26 | MAXIMUM_TONIE_MINUTES,
27 | RETRY_DELAY_SECONDS,
28 | UPLOAD_RETRY_COUNT,
29 | )
30 | from tonie_podcast_sync.podcast import (
31 | Episode,
32 | EpisodeSorting,
33 | Podcast,
34 | )
35 |
36 | console = Console()
37 | log = logging.getLogger(__name__)
38 | log.addHandler(logging.NullHandler())
39 |
40 |
41 | class ToniePodcastSync:
42 | """The class of syncing podcasts to given tonies."""
43 |
44 | def __init__(self, user: str, pwd: str) -> None:
45 | """Initialize ToniePodcastSync and connect to the TonieAPI.
46 |
47 | Args:
48 | user: The username for the Tonie Cloud API
49 | pwd: The password for the Tonie Cloud API
50 | """
51 | self._api = TonieAPI(user, pwd)
52 | self._households = {household.id: household for household in self._api.get_households()}
53 | self._update_tonies()
54 | self._session = requests.Session()
55 | log.debug("Performance optimization: HTTP session initialized for connection reuse")
56 |
57 | def _update_tonies(self) -> None:
58 | """Refresh the internal cache of creative tonies."""
59 | self._tonies = {tonie.id: tonie for tonie in self._api.get_all_creative_tonies()}
60 |
61 | def get_tonies(self) -> list[CreativeTonie]:
62 | """Return a list of all creative tonies.
63 |
64 | Returns:
65 | A list of CreativeTonie objects.
66 | """
67 | return list(self._tonies.values())
68 |
69 | def print_tonies_overview(self) -> None:
70 | """Print a table of all available creative tonies to the console."""
71 | table = Table(title="List of all creative tonies.")
72 | table.add_column("ID", no_wrap=True)
73 | table.add_column("Name of Tonie")
74 | table.add_column("Time of last update")
75 | table.add_column("Household")
76 | table.add_column("Latest Episode name")
77 |
78 | for tonie in self._tonies.values():
79 | last_update = tonie.lastUpdate.strftime("%c") if tonie.lastUpdate else None
80 | latest_chapter = tonie.chapters[0].title if tonie.chaptersPresent > 0 else "No latest chapter available."
81 | table.add_row(
82 | tonie.id,
83 | tonie.name,
84 | last_update,
85 | self._households[tonie.householdId].name,
86 | latest_chapter,
87 | )
88 | console.print(table)
89 |
90 | def sync_podcast_to_tonie(
91 | self,
92 | podcast: Podcast,
93 | tonie_id: str,
94 | max_minutes: int = 90,
95 | wipe: bool = True, # noqa: FBT001, FBT002
96 | ) -> None:
97 | """Sync new episodes from a podcast feed to a creative Tonie.
98 |
99 | Downloads and uploads new podcast episodes to the specified Tonie.
100 | Limits total episode duration to max_minutes.
101 |
102 | Args:
103 | podcast: The podcast to sync episodes from
104 | tonie_id: The ID of the target Tonie
105 | max_minutes: Maximum total duration of episodes in minutes. Defaults to 90.
106 | wipe: Whether to clear existing content before syncing. Defaults to True.
107 | """
108 | with tempfile.TemporaryDirectory() as podcast_cache_directory:
109 | self.podcast_cache_directory = Path(podcast_cache_directory)
110 | log.debug("Cache path is %s", self.podcast_cache_directory)
111 |
112 | if not self._validate_tonie_exists(tonie_id):
113 | return
114 |
115 | if not self._validate_podcast_has_episodes(podcast, tonie_id):
116 | return
117 |
118 | if not self._should_update_tonie(podcast, tonie_id):
119 | return
120 |
121 | if wipe:
122 | self._wipe_tonie(tonie_id)
123 |
124 | # For RANDOM mode, reshuffle before caching to ensure fresh episodes
125 | if podcast.epSorting == EpisodeSorting.RANDOM and not self._is_tonie_empty(tonie_id):
126 | latest_episode_tonie = self._tonies[tonie_id].chapters[0].title
127 | self.__reshuffle_until_different(podcast, latest_episode_tonie)
128 |
129 | cached_episodes = self.__cache_podcast_episodes(podcast, max_minutes)
130 | self._upload_episodes_to_tonie(podcast, cached_episodes, tonie_id)
131 |
132 | def _validate_tonie_exists(self, tonie_id: str) -> bool:
133 | """Check if a Tonie with the given ID exists.
134 |
135 | Args:
136 | tonie_id: The ID of the Tonie to check
137 |
138 | Returns:
139 | True if the Tonie exists, False otherwise
140 | """
141 | if tonie_id not in self._tonies:
142 | msg = f"Cannot find tonie with ID {tonie_id}"
143 | log.error(msg)
144 | console.print(f"ERROR: {msg}", style="red")
145 | return False
146 | return True
147 |
148 | def _validate_podcast_has_episodes(self, podcast: Podcast, tonie_id: str) -> bool:
149 | """Check if the podcast has any episodes available.
150 |
151 | Args:
152 | podcast: The podcast to check
153 | tonie_id: The ID of the target Tonie (for error message)
154 |
155 | Returns:
156 | True if episodes are available, False otherwise
157 | """
158 | if len(podcast.epList) == 0:
159 | msg = f"Cannot find any episodes for podcast '{podcast.title}' to put on tonie with ID {tonie_id}"
160 | log.warning(msg)
161 | console.print(f"ERROR: {msg}", style="orange")
162 | return False
163 | return True
164 |
165 | def _should_update_tonie(self, podcast: Podcast, tonie_id: str) -> bool:
166 | """Determine if the Tonie should be updated with new episodes.
167 |
168 | Args:
169 | podcast: The podcast to check for new episodes
170 | tonie_id: The ID of the Tonie to check
171 |
172 | Returns:
173 | True if the Tonie should be updated, False otherwise
174 | """
175 | if self._is_tonie_empty(tonie_id):
176 | log.info("Tonie is empty, proceeding with sync")
177 | return True
178 |
179 | # Skip check in random mode - always update
180 | if podcast.epSorting == EpisodeSorting.RANDOM:
181 | return True
182 |
183 | # Check if new feed has newer episodes than tonie
184 | latest_episode_feed = self._generate_chapter_title(podcast.epList[0])
185 | latest_episode_tonie = self._tonies[tonie_id].chapters[0].title
186 |
187 | if latest_episode_tonie == latest_episode_feed:
188 | msg = f"Podcast '{podcast.title}' has no new episodes, latest episode is '{latest_episode_tonie}'"
189 | log.info(msg)
190 | console.print(msg)
191 | return False
192 |
193 | return True
194 |
195 | def _upload_episodes_to_tonie(
196 | self,
197 | podcast: Podcast,
198 | episodes: list[Episode],
199 | tonie_id: str,
200 | ) -> None:
201 | """Upload a list of episodes to a Tonie.
202 |
203 | Args:
204 | podcast: The podcast object (for title information)
205 | episodes: List of episodes to upload
206 | tonie_id: The ID of the target Tonie
207 | """
208 | successfully_uploaded = []
209 | failed_episodes = []
210 |
211 | for episode in track(
212 | episodes,
213 | description=(f"{podcast.title}: transferring {len(episodes)} episodes to {self._tonies[tonie_id].name}"),
214 | total=len(episodes),
215 | transient=True,
216 | refresh_per_second=2,
217 | ):
218 | if self._upload_episode(episode, tonie_id):
219 | successfully_uploaded.append(episode)
220 | else:
221 | failed_episodes.append(episode)
222 |
223 | self._report_upload_results(podcast.title, tonie_id, successfully_uploaded, failed_episodes)
224 |
225 | def _report_upload_results(
226 | self,
227 | podcast_title: str,
228 | tonie_id: str,
229 | successfully_uploaded: list[Episode],
230 | failed_episodes: list[Episode],
231 | ) -> None:
232 | """Report the results of episode uploads to the user.
233 |
234 | Args:
235 | podcast_title: The title of the podcast
236 | tonie_id: The ID of the Tonie
237 | successfully_uploaded: List of successfully uploaded episodes
238 | failed_episodes: List of episodes that failed to upload
239 | """
240 | if successfully_uploaded:
241 | episode_info = [f"{episode.title} ({episode.published})" for episode in successfully_uploaded]
242 | console.print(
243 | f"{podcast_title}: Successfully uploaded {episode_info} to "
244 | f"{self._tonies[tonie_id].name} ({self._tonies[tonie_id].id})",
245 | )
246 |
247 | if failed_episodes:
248 | failed_info = [episode.title for episode in failed_episodes]
249 | console.print(
250 | f"{podcast_title}: Failed to upload {len(failed_episodes)} episode(s): {failed_info}",
251 | style="red",
252 | )
253 |
254 | def _upload_episode(self, episode: Episode, tonie_id: str) -> bool:
255 | """Upload a single episode to a creative Tonie.
256 |
257 | Args:
258 | episode: The episode to upload
259 | tonie_id: The ID of the target Tonie
260 |
261 | Returns:
262 | True if upload was successful, False otherwise
263 | """
264 | tonie = self._tonies[tonie_id]
265 | for _attempt in range(UPLOAD_RETRY_COUNT):
266 | try:
267 | self._api.upload_file_to_tonie(tonie, episode.fpath, self._generate_chapter_title(episode))
268 | return True # noqa: TRY300
269 | except HTTPError as e: # noqa: PERF203
270 | log.warning("Upload failed for %s, retrying in %d seconds: %s", episode.title, RETRY_DELAY_SECONDS, e)
271 | time.sleep(RETRY_DELAY_SECONDS)
272 |
273 | log.error("Unable to upload file %s after %d attempts", episode.title, UPLOAD_RETRY_COUNT)
274 | return False
275 |
276 | def _wipe_tonie(self, tonie_id: str) -> None:
277 | """Remove all chapters from a Tonie.
278 |
279 | Args:
280 | tonie_id: The ID of the Tonie to wipe
281 | """
282 | tonie = self._tonies[tonie_id]
283 | console.print(f"Wipe all chapters of Tonie '{tonie.name}'")
284 | self._api.clear_all_chapter_of_tonie(tonie)
285 | self._update_tonies()
286 |
287 | def __cache_podcast_episodes(self, podcast: Podcast, max_minutes: int = MAXIMUM_TONIE_MINUTES) -> list[Episode]:
288 | """Download podcast episodes locally, limited to max_minutes total duration.
289 |
290 | Args:
291 | podcast: The podcast to cache episodes from
292 | max_minutes: Maximum total duration in minutes
293 |
294 | Returns:
295 | List of successfully cached episodes
296 | """
297 | if max_minutes <= 0 or max_minutes > MAXIMUM_TONIE_MINUTES:
298 | max_minutes = MAXIMUM_TONIE_MINUTES
299 |
300 | episodes_to_cache = self._select_episodes_within_time_limit(podcast, max_minutes)
301 |
302 | if not episodes_to_cache:
303 | msg = f"No episodes found for podcast '{podcast.title}' that fit within {max_minutes} minutes"
304 | log.warning(msg)
305 | console.print(f"WARNING: {msg}", style="yellow")
306 | return []
307 |
308 | # Track available fallback episodes
309 | available_episodes = [ep for ep in podcast.epList if ep not in episodes_to_cache]
310 |
311 | cached_episodes, failed_episodes = self._download_episodes_with_fallback(
312 | podcast, episodes_to_cache, available_episodes, max_minutes
313 | )
314 |
315 | self._log_caching_summary(podcast, cached_episodes, failed_episodes)
316 | return cached_episodes
317 |
318 | def _select_episodes_within_time_limit(self, podcast: Podcast, max_minutes: int) -> list[Episode]:
319 | """Select episodes from podcast that fit within the time limit.
320 |
321 | Args:
322 | podcast: The podcast to select episodes from
323 | max_minutes: Maximum total duration in minutes
324 |
325 | Returns:
326 | List of episodes that fit within the time limit
327 | """
328 | episodes = []
329 | total_seconds = 0
330 | max_seconds = max_minutes * 60
331 |
332 | for episode in podcast.epList:
333 | if episode.duration_sec > max_seconds:
334 | continue
335 | if (total_seconds + episode.duration_sec) > max_seconds:
336 | break
337 | total_seconds += episode.duration_sec
338 | episodes.append(episode)
339 |
340 | return episodes
341 |
342 | def _download_episodes_with_fallback(
343 | self,
344 | podcast: Podcast,
345 | episodes_to_cache: list[Episode],
346 | available_episodes: list[Episode],
347 | max_minutes: int,
348 | ) -> tuple[list[Episode], list[Episode]]:
349 | """Download episodes with fallback to alternative episodes on failure.
350 |
351 | Args:
352 | podcast: The podcast object
353 | episodes_to_cache: Primary list of episodes to download
354 | available_episodes: List of fallback episodes
355 | max_minutes: Maximum total duration in minutes
356 |
357 | Returns:
358 | Tuple of (successfully cached episodes, failed episodes without replacement)
359 | """
360 | cached_episodes: list[Episode] = []
361 | failed_episodes = []
362 | current_duration = 0
363 | max_seconds = max_minutes * 60
364 |
365 | available_queue = deque(available_episodes)
366 | log.debug(
367 | "Using deque for efficient fallback episode selection (%d available)",
368 | len(available_queue),
369 | )
370 |
371 | for episode in track(
372 | episodes_to_cache,
373 | description=f"{podcast.title}: Cache episodes ...",
374 | total=len(episodes_to_cache),
375 | transient=True,
376 | refresh_per_second=2,
377 | ):
378 | if self.__cache_episode(episode):
379 | cached_episodes.append(episode)
380 | current_duration += episode.duration_sec
381 | else:
382 | failed_episodes.append(episode)
383 | replacement = self._find_replacement_episode(available_queue, max_seconds, current_duration)
384 |
385 | if replacement and self._try_cache_replacement(podcast, replacement, episode):
386 | cached_episodes.append(replacement)
387 | current_duration += replacement.duration_sec
388 | failed_episodes.remove(episode)
389 |
390 | return cached_episodes, failed_episodes
391 |
392 | def _try_cache_replacement(self, podcast: Podcast, replacement: Episode, failed_episode: Episode) -> bool:
393 | """Attempt to cache a replacement episode.
394 |
395 | Args:
396 | podcast: The podcast object
397 | replacement: The replacement episode to try
398 | failed_episode: The episode that failed to download
399 |
400 | Returns:
401 | True if replacement was successfully cached, False otherwise
402 | """
403 | log.info(
404 | "%s: Attempting replacement episode '%s' for failed download of '%s'",
405 | podcast.title,
406 | replacement.title,
407 | failed_episode.title,
408 | )
409 |
410 | if self.__cache_episode(replacement):
411 | return True
412 |
413 | log.warning(
414 | "%s: Replacement episode '%s' also failed to download",
415 | podcast.title,
416 | replacement.title,
417 | )
418 | return False
419 |
420 | def _log_caching_summary(
421 | self, podcast: Podcast, cached_episodes: list[Episode], failed_episodes: list[Episode]
422 | ) -> None:
423 | """Log a summary of the caching operation.
424 |
425 | Args:
426 | podcast: The podcast object
427 | cached_episodes: List of successfully cached episodes
428 | failed_episodes: List of episodes that failed to cache
429 | """
430 | if failed_episodes:
431 | log.warning(
432 | "%s: %d episode(s) failed to download and could not be replaced",
433 | podcast.title,
434 | len(failed_episodes),
435 | )
436 |
437 | total_duration_minutes = sum(ep.duration_sec for ep in cached_episodes) / 60
438 | log.info(
439 | "%s: providing %d episodes with %.1f min total",
440 | podcast.title,
441 | len(cached_episodes),
442 | total_duration_minutes,
443 | )
444 |
445 | def _find_replacement_episode(
446 | self,
447 | available_episodes: deque[Episode],
448 | max_seconds: int,
449 | current_seconds: int,
450 | ) -> Episode | None:
451 | """Find a replacement episode when download fails.
452 |
453 | Args:
454 | available_episodes: Deque of episodes not yet selected
455 | max_seconds: Maximum total seconds allowed
456 | current_seconds: Current total seconds already downloaded
457 |
458 | Returns:
459 | A replacement episode if found (removed from deque), None otherwise
460 | """
461 | for i, episode in enumerate(available_episodes):
462 | if (current_seconds + episode.duration_sec) <= max_seconds:
463 | del available_episodes[i]
464 | log.debug(
465 | "Found fallback episode '%s' using O(1) deque removal at index %d",
466 | episode.title,
467 | i,
468 | )
469 | return episode
470 | return None
471 |
472 | def __cache_episode(self, episode: Episode) -> bool:
473 | """Download a single episode to local cache.
474 |
475 | Args:
476 | episode: The episode to download
477 |
478 | Returns:
479 | True if download was successful, False otherwise
480 | """
481 | podcast_path = self.podcast_cache_directory / sanitize_filepath(episode.podcast)
482 | podcast_path.mkdir(parents=True, exist_ok=True)
483 |
484 | filepath = podcast_path / self._generate_filename(episode)
485 | if filepath.exists():
486 | log.info("File %s exists, will be overwritten", episode.guid)
487 | filepath.unlink()
488 |
489 | for _attempt in range(DOWNLOAD_RETRY_COUNT):
490 | try:
491 | response = self._session.get(episode.url, timeout=180, stream=True)
492 | response.raise_for_status()
493 |
494 | if episode.volume_adjustment != 0 and self._is_ffmpeg_available():
495 | log.debug(
496 | "Loading episode '%s' to memory for volume adjustment",
497 | episode.title,
498 | )
499 | content = self._download_to_memory(response)
500 | content = self._adjust_volume(content, episode.volume_adjustment)
501 | with filepath.open("wb") as file:
502 | file.write(content)
503 | else:
504 | log.debug(
505 | "Streaming episode '%s' directly to disk (memory-efficient)",
506 | episode.title,
507 | )
508 | self._download_to_file(response, filepath)
509 |
510 | episode.fpath = filepath
511 | return True # noqa: TRY300
512 | except RequestException as e: # noqa: PERF203
513 | log.warning(
514 | "Download failed for %s, retrying in %d seconds: %s",
515 | episode.url,
516 | RETRY_DELAY_SECONDS,
517 | e,
518 | )
519 | if filepath.exists():
520 | filepath.unlink()
521 | time.sleep(RETRY_DELAY_SECONDS)
522 |
523 | log.error("Unable to download file from %s after %d attempts", episode.url, DOWNLOAD_RETRY_COUNT)
524 | return False
525 |
526 | def _download_to_memory(self, response: requests.Response) -> bytes:
527 | """Download streaming response to memory.
528 |
529 | Args:
530 | response: Streaming HTTP response
531 |
532 | Returns:
533 | Downloaded content as bytes
534 | """
535 | return b"".join(chunk for chunk in response.iter_content(chunk_size=8192) if chunk)
536 |
537 | def _download_to_file(self, response: requests.Response, filepath: Path) -> None:
538 | """Stream download directly to file.
539 |
540 | Args:
541 | response: Streaming HTTP response
542 | filepath: Path to write the file to
543 | """
544 | with filepath.open("wb") as file:
545 | for chunk in response.iter_content(chunk_size=8192):
546 | if chunk:
547 | file.write(chunk)
548 |
549 | def _process_audio_content(self, content: bytes, volume_adjustment: int) -> bytes:
550 | """Process audio content, applying volume adjustment if needed.
551 |
552 | Args:
553 | content: The raw audio content
554 | volume_adjustment: Volume adjustment in dB (0 means no adjustment)
555 |
556 | Returns:
557 | Processed audio content
558 | """
559 | if volume_adjustment != 0 and self._is_ffmpeg_available():
560 | return self._adjust_volume(content, volume_adjustment)
561 | return content
562 |
563 | def _generate_filename(self, episode: Episode) -> str:
564 | """Generate a canonical filename for local episode cache.
565 |
566 | Args:
567 | episode: The episode to generate a filename for
568 |
569 | Returns:
570 | Sanitized filename string
571 | """
572 | return sanitize_filename(f"{episode.published} {episode.title}.mp3")
573 |
574 | def _generate_chapter_title(self, episode: Episode) -> str:
575 | """Generate chapter title for display on Tonie.
576 |
577 | Args:
578 | episode: The episode to generate a title for
579 |
580 | Returns:
581 | Formatted chapter title string
582 | """
583 | return f"{episode.title} ({episode.published})"
584 |
585 | def _is_tonie_empty(self, tonie_id: str) -> bool:
586 | """Check if a Tonie has no chapters.
587 |
588 | Args:
589 | tonie_id: The ID of the Tonie to check
590 |
591 | Returns:
592 | True if the Tonie is empty, False otherwise
593 | """
594 | return self._tonies[tonie_id].chaptersPresent == 0
595 |
596 | def __reshuffle_until_different(self, podcast: Podcast, current_first_episode_title: str) -> None:
597 | """Re-shuffle podcast episodes until first episode differs from current one on Tonie.
598 |
599 | Args:
600 | podcast: The podcast with episodes to shuffle
601 | current_first_episode_title: The title of the current first episode on the Tonie
602 | """
603 | for attempt in range(MAX_SHUFFLE_ATTEMPTS):
604 | random.shuffle(podcast.epList)
605 | first_episode_title = self._generate_chapter_title(podcast.epList[0])
606 |
607 | if first_episode_title != current_first_episode_title:
608 | log.info(
609 | "%s: Successfully shuffled to new first episode after %d attempt(s)",
610 | podcast.title,
611 | attempt + 1,
612 | )
613 | return
614 |
615 | log.info(
616 | "%s: Shuffle attempt %d - first episode still matches, re-shuffling",
617 | podcast.title,
618 | attempt + 1,
619 | )
620 |
621 | log.warning(
622 | "%s: Could not find different first episode after %d shuffle attempts",
623 | podcast.title,
624 | MAX_SHUFFLE_ATTEMPTS,
625 | )
626 |
627 | def _adjust_volume(self, audio_bytes: bytes, volume_adjustment: int) -> bytes:
628 | """Adjust the volume of audio content.
629 |
630 | Args:
631 | audio_bytes: The raw audio data
632 | volume_adjustment: Volume adjustment in dB
633 |
634 | Returns:
635 | Adjusted audio data
636 | """
637 | audio = AudioSegment.from_file(BytesIO(audio_bytes), format="mp3")
638 | adjusted_audio = audio + volume_adjustment
639 |
640 | byte_io = BytesIO()
641 | adjusted_audio.export(byte_io, format="mp3")
642 |
643 | return byte_io.getvalue()
644 |
645 | def _is_ffmpeg_available(self) -> bool:
646 | """Check if ffmpeg is available on the system.
647 |
648 | Returns:
649 | True if ffmpeg is available, False otherwise
650 | """
651 | try:
652 | executable = "ffmpeg" if platform.system().lower() != "windows" else "ffmpeg.exe"
653 | subprocess.run([executable, "-version"], check=True, capture_output=True) # noqa: S603
654 | return True # noqa: TRY300
655 | except (FileNotFoundError, subprocess.CalledProcessError):
656 | console.print(
657 | "Warning: you tried to adjust the volume without having 'ffmpeg' available. "
658 | "Please install 'ffmpeg' or set no volume adjustment.",
659 | style="red",
660 | )
661 | return False
662 |
--------------------------------------------------------------------------------
/tests/res/pumuckl.xml:
--------------------------------------------------------------------------------
1 |
2 |
177 |
184 |
185 |
178 |
Alexander Hartmann
💻 🤔 🚧
179 |
Wilhelmsson177
💻 🤔 🚧 ⚠️
180 |
Malte Bär
🐛
181 |
Valentin v. Seggern
💻
182 |
stefan14808
💻 🤔
183 |
GoldBrickLemon
🐛 💻
Hast du Lust auf eine spannende Geschichte von uns?
43 |Dann hör doch mal rein bei: "Der Affenkönig"
44 |Darum geht es: Es ist ein ungleiches Paar, das gemeinsam von China nach Indien unterwegs ist: Tripitaka, ein heiliger Mönch, der sein Leben der Meditation und dem Studium heiliger Schriften gewidmet hat. Und Sun Wukung, der Affenkönig, ein mächtiger und zauberkräftiger Kämpfer, der den Auftrag hat, den tollpatschigen Mönch zu beschützen.
45 |Hier geht's zur ersten Folge: https://1.ard.de/der-affenkoenig
46 | ]]> 47 | 48 |Übrigens: Wir haben noch mehr tolle Kinder-Podcasts - hör doch mal rein:
60 |Du willst die Welt checken? Dann hör doch mal rein bei Checkpod, der Podcast mit Checker Tobi
61 |Interessierst du dich für Tiere? Dann hör doch mal rein bei Anna und die wilden Tiere
62 |Gute-Nacht-Geschichten für kleinere Hörerinnen und Hörer: Das Betthupferl
63 | 64 |Noch mehr vom Bayerischen Rundfunk für Kinder: BR Kinder, die Internetseite mit Wissenswertem aus und über alle unsere Sendungen
65 | ]]>Übrigens: Wir haben noch mehr tolle Kinder-Podcasts - hör doch mal rein:
79 |Du willst die Welt checken? Dann hör doch mal rein bei "Checkpod, der Podcast mit Checker Tobi"! https://www.ardaudiothek.de/checkpod-der-podcast-mit-checker-tobi/89338476
80 |Interessierst du dich für Tiere? Dann hör doch mal rein bei "Anna und die wilden Tiere"! https://www.ardaudiothek.de/anna-und-die-wilden-tiere-der-podcast/89079206
81 | 82 |Noch mehr vom Bayerischen Rundfunk für Kinder: BR Kinder, die Internetseite mit Wissenswertem aus und über alle unsere Sendungen: https://www.br.de/kinder/index.html
83 | ]]>Übrigens: Wir haben noch mehr tolle Kinder-Podcasts - hör doch mal rein:
121 |Do Re Mikro - Klassik für Kinder: Da geht's um Musiker und Komponisten, um Konzerte und Instrumente! https://www.ardaudiothek.de/sendung/do-re-mikro-klassik-fuer-kinder/5959908/
122 |Du willst die Welt checken? Dann hör doch mal rein bei "Checkpod, der Podcast mit Checker Tobi"! https://www.ardaudiothek.de/checkpod-der-podcast-mit-checker-tobi/89338476
123 |Das Betthupferl: Gute-Nacht-Geschichten für kleinere Hörerinnen und Hörer! https://www.ardaudiothek.de/sendung/betthupferl-gute-nacht-geschichten-fuer-kinder/7244594/
124 |Geschichten für Kinder: Tolle Hörspiele und Lesungen! Da ist für jeden was dabei! https://www.ardaudiothek.de/sendung/geschichten-fuer-kinder/58715068/
125 |Lachlabor – lustiges Wissen für Kinder zum Miträseln: Tina und Mischa beantworten im Lachlabor die verrücktesten Kinderfragen: https://www.ardaudiothek.de/sendung/lachlabor-lustiges-wissen-fuer-kinder-zum-mitraetseln/12050715/
126 | ]]>Übrigens: Wir haben noch mehr tolle Kinder-Podcasts - hör doch mal rein:
200 |Du willst die Welt checken? Dann hör doch mal rein bei "Checkpod, der Podcast mit Checker Tobi"! https://www.ardaudiothek.de/checkpod-der-podcast-mit-checker-tobi/89338476
201 |Interessierst du dich für Tiere? Dann hör doch mal rein bei "Anna und die wilden Tiere"! https://www.ardaudiothek.de/anna-und-die-wilden-tiere-der-podcast/89079206
202 |Das Betthupferl: Gute-Nacht-Geschichten für kleinere Hörerinnen und Hörer! https://www.ardaudiothek.de/sendung/betthupferl-gute-nacht-geschichten-fuer-kinder/7244594/
203 |Geschichten für Kinder: Tolle Hörspiele und Lesungen! Da ist für jeden was dabei! https://www.ardaudiothek.de/sendung/geschichten-fuer-kinder/58715068/
204 |Do Re Mikro - Klassik für Kinder: Da geht's um Musiker und Komponisten, um Konzerte und Instrumente! https://www.ardaudiothek.de/sendung/do-re-mikro-klassik-fuer-kinder/5959908/
205 |Lachlabor – lustiges Wissen für Kinder zum Miträseln: Tina und Mischa beantworten im Lachlabor die verrücktesten Kinderfragen: https://www.ardaudiothek.de/sendung/lachlabor-lustiges-wissen-fuer-kinder-zum-mitraetseln/12050715/
206 |207 |
Noch mehr vom Bayerischen Rundfunk für Kinder: BR Kinder, die Internetseite mit Wissenswertem aus und über alle unsere Sendungen: https://www.br.de/kinder/index.html
208 | ]]>