├── 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 | [![All Contributors](https://img.shields.io/badge/all_contributors-6-orange.svg?style=flat-square)](#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 | [![gif Recording of tonie-podcast-sync](ressources/tps.gif)](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.] 60 | podcast = "https://example.com/podcast.xml" 61 | name = "My Tonie Name" 62 | episode_sorting = "by_date_newest_first" # or "by_date_oldest_first", "random" 63 | maximum_length = 90 # Maximum duration in minutes 64 | episode_min_duration_sec = 0 # Minimum episode duration in seconds (optional, defaults to 0) 65 | episode_max_duration_sec = 5400 # Maximum total duration of epsiodes on this tonie in seconds (optional, defaults to what the tonie can store at maximum) 66 | volume_adjustment = 0 # volume adjustment in dB (+/-) 67 | excluded_title_strings = ["vampir", "brokkoli"] # filter out scary episodes 68 | ``` 69 | 70 | The `excluded_title_strings` field is optional and allows you to filter out episodes whose titles contain any of the specified strings (case-insensitive matching). 71 | 72 | The `episode_max_duration_sec` field is optional. It filters out individual episodes that exceed this duration. Note that this is different from `maximum_length`, which controls the total duration of episodes placed on the tonie. 73 | 74 | To periodically fetch for new episodes, you can schedule `tonie-podcast-sync` e.g. via systemd (on a Linux OS). 75 | 76 | In addition, 77 | 78 | ```bash 79 | tonie-podcast-sync --help 80 | ``` 81 | 82 | provides an overview about these and other available commands. 83 | 84 | 85 | ## in your own Python scripts 86 | 87 | You can use `tonie-podcast-sync` by importing it into your own Python scripts, as in this example code: 88 | 89 | ```python 90 | from tonie_podcast_sync.toniepodcastsync import ToniePodcastSync, Podcast, EpisodeSorting 91 | 92 | # Create some Podcast objects, providing the feed URL to each 93 | pumuckl = Podcast("https://feeds.br.de/pumuckl/feed.xml") 94 | 95 | # By default, podcasts are placed onto Tonies by newest episode first 96 | # If you want to change the episode sorting, following options are available 97 | # - EpisodeSorting.BY_DATE_NEWEST_FIRST (default) 98 | # - EpisodeSorting.BY_DATE_OLDEST_FIRST 99 | # - EpisodeSorting.RADNOM 100 | maus_60min = Podcast( 101 | "https://kinder.wdr.de/radio/diemaus/audio/diemaus-60/diemaus-60-106.podcast", 102 | episode_sorting = EpisodeSorting.BY_DATE_NEWEST_FIRST 103 | ) 104 | maus_gute_nacht = Podcast( 105 | "https://kinder.wdr.de/radio/diemaus/audio/gute-nacht-mit-der-maus/diemaus-gute-nacht-104.podcast", 106 | episode_sorting = EpisodeSorting.RANDOM 107 | ) 108 | 109 | # If you want to adjust the volume of a podcast, set volume_adjustment to an integer other than 0 110 | # The audio will be adjusted (+/-) by that amount in dB 111 | anne_und_die_wilden_tiere = Podcast( 112 | "https://feeds.br.de/anna-und-die-wilden-tiere/feed.xml", 113 | episode_sorting = EpisodeSorting.RANDOM, 114 | volume_adjustment = -2 115 | ) 116 | 117 | # Some Podcasts inject episodes that are very short (e.g. announcing a holiday break). 118 | # `episode_min_duration_sec` can be used to filter out all episodes shorter then this value. 119 | # the example below will skip all episodes shorter then 30 seconds. 120 | checker_tobi = Podcast( 121 | "https://feeds.br.de/checkpod-der-podcast-mit-checker-tobi/feed.xml", 122 | episode_sorting = EpisodeSorting.RANDOM, 123 | episode_min_duration_sec = 30 124 | ) 125 | 126 | # You can also filter out episodes by title strings. Episodes with titles containing 127 | # any of the specified strings (case-insensitive) will be excluded. 128 | # This is useful for filtering out episodes that are too scary (for example). 129 | maus_filtered = Podcast( 130 | "https://kinder.wdr.de/radio/diemaus/audio/maus-gute-nacht/maus-gute-nacht-148.podcast", 131 | excluded_title_strings = ["vampir", "brokkoli"] 132 | ) 133 | 134 | # Create instance of ToniePodcastSync 135 | tps = ToniePodcastSync("", "") 136 | 137 | # For an overview of your creative Tonies and their IDs 138 | # The IDs are needed to address specific Tonies in the next step 139 | tps.print_tonies_overview() 140 | 141 | # Define creative Tonies based on their ID 142 | greenTonie = "" 143 | orangeTonie = "" 144 | greyTonie = "" 145 | 146 | # Fetch new podcast episodes and copy them to greenTonie. 147 | # The tonie will be filled with as much episodes as fit (90 min max). 148 | tps.sync_podcast_to_tonie(pumuckl, greenTonie) 149 | 150 | # Kid's should fall asleep, so let's limit the podcast 151 | # Episodes on this tonie to 60 minutes in total. 152 | # Use the optional parameter for this: 153 | tps.sync_podcast_to_tonie(maus_gute_nacht, orangeTonie, 60) 154 | 155 | # By default, syncing a podcast to a tonie will remove all 156 | # existing content on that tonie: 157 | tps.sync_podcast_to_tonie(checker_tobi, greyTonie, 30) 158 | # If you want to add episodes without deleting existing content 159 | # on the tonie, use the wipe=False parameter: 160 | tps.sync_podcast_to_tonie(checker_tobi, greyTonie, 30, wipe=False) 161 | ``` 162 | 163 | For the tonie to fetch new content from tonie-cloud, you have to press one ear for 3s (until the "ping" sound) with no tonie on the box (refer also to TonieBox manual). 164 | 165 | ## as a docker container 166 | 167 | @goldbricklemon has created a docker container around this: [docker-tonie-podcast-sync](https://github.com/goldbricklemon/docker-tonie-podcast-sync). 168 | 169 | # Contributors 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 |
Alexander Hartmann
Alexander Hartmann

💻 🤔 🚧
Wilhelmsson177
Wilhelmsson177

💻 🤔 🚧 ⚠️
Malte Bär
Malte Bär

🐛
Valentin v. Seggern
Valentin v. Seggern

💻
stefan14808
stefan14808

💻 🤔
GoldBrickLemon
GoldBrickLemon

🐛 💻
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 | 3 | 4 | Pumuckl - Der Hörspiel-Klassiker 5 | https://www.br.de/mediathek/podcast/pumuckl/830 6 | Hurra! Der freche kleine Kobold mit dem roten Haar spielt hier seine Streiche! Die alten Folgen der Original-Hörspiel-Serie "Meister Eder und sein Pumuckl" von Ellis Kaut stammen aus dem Radioarchiv des Bayerischen Rundfunks. Hier hört ihr Hans Clarin als Pumuckl und Alfred Pongratz als Meister Eder. Ein Hörgenuss für alle großen und kleinen Fans des liebenswerten Klabautermanns. (Ab 5 Jahren) 7 | Sat, 19 Aug 2023 10:00:17 +0200 8 | 9 | https://img.br.de/e9ae87d5-1153-4fb6-a20e-91076325cf26.jpeg?fm=jpg&w=1800 10 | Pumuckl - Der Hörspiel-Klassiker 11 | https://www.br.de/mediathek/podcast/pumuckl/830 12 | 13 | Kids & Family 14 | de-DE 15 | BR Podcasts 16 | © 2023 Bayerischer Rundfunk 17 | Bayerischer Rundfunk 18 | Hurra! Der freche kleine Kobold mit dem roten Haar spielt hier seine Streiche! Die alten Folgen der Original-Hörspiel-Serie "Meister Eder und sein Pumuckl" von Ellis Kaut stammen aus dem Radioarchiv des Bayerischen Rundfunks. Hier hört ihr Hans Clarin als Pumuckl und Alfred Pongratz als Meister Eder. Ein Hörgenuss für alle großen und kleinen Fans des liebenswerten Klabautermanns. (Ab 5 Jahren) 19 | 20 | clean 21 | 593784 22 | episodic 23 | 24 | 25 | 26 | 27 | Bayerischer Rundfunk 28 | podcast@br.de 29 | 30 | 31 | 32 | Pumuckl - der Föhn 33 | https://www.br.de/mediathek/podcast/pumuckl/pumuckl-der-foehn/2033628 34 | Sat, 19 Aug 2023 12:00:00 +0200 35 | eff05dfd-366d-4efb-a633-3d749df62c05 36 | Was für ein komischer Tag! Alles geht schief, Meister Eder ist schlecht gelaunt und findet nix, Pumuckl will helfen und darf nicht. Irgendwie sind heute alle mit den Nerven am Ende, bis sich schließlich herausstellt: es ist Föhn. Das erklärt einiges! 37 | 38 | 00:27:24 39 | Ellis Kaut 40 | Was für ein komischer Tag! Alles geht schief, Meister Eder ist schlecht gelaunt und findet nix, Pumuckl will helfen und darf nicht. Irgendwie sind heute alle mit den Nerven am Ende, bis sich schließlich herausstellt: es ist Föhn. Das erklärt einiges! 41 | full 42 | Was für ein komischer Tag! Alles geht schief, Meister Eder ist schlecht gelaunt und findet nix, Pumuckl will helfen und darf nicht. Irgendwie sind heute alle mit den Nerven am Ende, bis sich schließlich herausstellt: es ist Föhn. Das erklärt einiges!

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 | 49 | Pumuckl und die Katze 50 | https://www.br.de/mediathek/podcast/pumuckl/pumuckl-und-die-katze/2019744 51 | Sat, 22 Jul 2023 12:00:00 +0200 52 | d01434f1-56b3-4961-841d-5893d570dbe0 53 | Meister Eder läuft eine junge Katze zu. Pumuckl ist eifersüchtig und neidisch und überlegt, wie er sie wieder loswerden kann. Da meldet sich die Besitzerin der Katze und will sie in der Werkstatt abholen, doch plötzlich ist das Tier nicht mehr da ... 54 | 55 | 00:28:55 56 | Ellis Kaut 57 | Meister Eder läuft eine junge Katze zu. Pumuckl ist eifersüchtig und neidisch und überlegt, wie er sie wieder loswerden kann. Da meldet sich die Besitzerin der Katze und will sie in der Werkstatt abholen, doch plötzlich ist das Tier nicht mehr da ... 58 | full 59 | Meister Eder läuft eine junge Katze zu. Pumuckl ist eifersüchtig und neidisch und überlegt, wie er sie wieder loswerden kann. Da meldet sich die Besitzerin der Katze und will sie in der Werkstatt abholen, doch plötzlich ist das Tier nicht mehr da ...

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

]]>
66 |
67 | 68 | Pumuckl und das goldene Herz 69 | https://www.br.de/mediathek/podcast/pumuckl/pumuckl-und-das-goldene-herz/2002122 70 | Sat, 17 Jun 2023 12:00:00 +0200 71 | cf2a132b-d817-4674-8535-050d0a6a59ba 72 | Pumuckl hat seine schöne gläserne Murmel verloren, findet dafür auf dem Hof ein goldenes Herz. Aber das gehört der Bärbel und Pumuckl müsste es ihr eigentlich zurückgeben. Doch der Kobold versteckt es lieber... 73 | 74 | 00:27:25 75 | Ellis Kaut 76 | Pumuckl hat seine schöne gläserne Murmel verloren, findet dafür auf dem Hof ein goldenes Herz. Aber das gehört der Bärbel und Pumuckl müsste es ihr eigentlich zurückgeben. Doch der Kobold versteckt es lieber... 77 | full 78 | Pumuckl hat seine schöne gläserne Murmel verloren, findet dafür auf dem Hof ein goldenes Herz. Aber das gehört der Bärbel und Pumuckl müsste es ihr eigentlich zurückgeben. Doch der Kobold versteckt es lieber...

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

]]>
84 |
85 | 86 | Meister Eder bekommt Besuch 87 | https://www.br.de/mediathek/podcast/pumuckl/meister-eder-bekommt-besuch-1/1988051 88 | Sat, 20 May 2023 12:00:00 +0200 89 | aab19f21-eab6-4e74-905b-5b84a1d7392d 90 | Auch wenn es dem kleinen Kobold überhaupt nicht passt: Meister Eders Schwester kommt mit ihrer Tochter Bärbel zu Besuch. Pumuckl ist eifersüchtig auf das kleine Mädchen ...doch am Ende kommt alles ganz anders als befürchtet! 91 | 92 | 00:23:27 93 | Ellis Kaut 94 | Auch wenn es dem kleinen Kobold überhaupt nicht passt: Meister Eders Schwester kommt mit ihrer Tochter Bärbel zu Besuch. Pumuckl ist eifersüchtig auf das kleine Mädchen ...doch am Ende kommt alles ganz anders als befürchtet! 95 | full 96 | 97 | 98 | Pumuckl und die abgerissenen Tulpen 99 | https://www.br.de/mediathek/podcast/pumuckl/pumuckl-und-die-abgerissenen-tulpen/1974004 100 | Sat, 22 Apr 2023 12:00:00 +0200 101 | c548e629-faf1-4d26-882b-29686c4add38 102 | Pumuckl freut sich über jede Blume. Das kleine Beet im Hof ist sein ganzes Glück, und die drei feuerroten Tulpen haben dem kleinen Kobold den Kopf verdreht. Doch das Tulpenglück ist schon bald zerstört und Pumuckl empört. 103 | 104 | 00:21:17 105 | Ellis Kaut 106 | Pumuckl freut sich über jede Blume. Das kleine Beet im Hof ist sein ganzes Glück, und die drei feuerroten Tulpen haben dem kleinen Kobold den Kopf verdreht. Doch das Tulpenglück ist schon bald zerstört und Pumuckl empört. 107 | full 108 | 109 | 110 | Pumuckl und der Finderlohn 111 | https://www.br.de/mediathek/podcast/pumuckl/pumuckl-und-der-finderlohn-2/1955890 112 | Fri, 17 Mar 2023 12:00:00 +0100 113 | ec07b413-4c97-409b-8b99-c02d8f7bb99c 114 | Kobolde mögen keine Katzen. Aber wenn für so ein miauendes Monster ein Finderlohn versprochen wird, lohnt es sich ja vielleicht, seinen inneren Schweinehund zu überwinden... 115 | 116 | 00:24:15 117 | Ellis Kaut 118 | Kobolde mögen keine Katzen. Aber wenn für so ein miauendes Monster ein Finderlohn versprochen wird, lohnt es sich ja vielleicht, seinen inneren Schweinehund zu überwinden... 119 | full 120 | Kobolde mögen keine Katzen. Aber wenn für so ein miauendes Monster ein Finderlohn versprochen wird, lohnt es sich ja vielleicht, seinen inneren Schweinehund zu überwinden...

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

]]>
127 |
128 | 129 | Pumuckl und das Geld 130 | https://www.br.de/mediathek/podcast/pumuckl/pumuckl-und-das-geld-1/1942304 131 | Sat, 18 Feb 2023 12:00:00 +0100 132 | 818d83d2-c575-4f91-9667-c9f543578d07 133 | Ohne Geld kann man nichts kaufen. Als Pumuckl das versteht, will er auch Geld haben. Zu seinem großen "Glückerl", bekommt er vom Eder "ein Stückerl". Doch was kauft sich ein Kobold mit seinem "Fuffzgerl"? 134 | 135 | 00:22:17 136 | Ellis Kaut 137 | Ohne Geld kann man nichts kaufen. Als Pumuckl das versteht, will er auch Geld haben. Zu seinem großen "Glückerl", bekommt er vom Eder "ein Stückerl". Doch was kauft sich ein Kobold mit seinem "Fuffzgerl"? 138 | full 139 | 140 | 141 | Pumuckl in der Schule 142 | https://www.br.de/mediathek/podcast/pumuckl/pumuckl-in-der-schule-1/1928151 143 | Sat, 21 Jan 2023 12:00:00 +0100 144 | a51e06c5-d685-4e09-b2c6-aedf54be8d66 145 | Pumuckl will unbedingt in die Schule gehen. Doch was die Kinder im Unterricht lernen, überzeugt ihn nicht. Als er dort an einem Kaugummi hängen bleibt, ist die Angst groß, sichtbar zu werden und für immer da bleiben zu müssen! 146 | 147 | 00:22:29 148 | Ellis Kaut 149 | Pumuckl will unbedingt in die Schule gehen. Doch was die Kinder im Unterricht lernen, überzeugt ihn nicht. Als er dort an einem Kaugummi hängen bleibt, ist die Angst groß, sichtbar zu werden und für immer da bleiben zu müssen! 150 | full 151 | 152 | 153 | Pumuckl und die Blechbüchsen 154 | https://www.br.de/mediathek/podcast/pumuckl/pumuckl-und-die-blechbuechsen/1917676 155 | Sat, 31 Dec 2022 12:05:00 +0100 156 | cd3bbb9d-54cd-4f4b-bd65-6252ede5bf51 157 | Meister Eder soll mal schnell ein Auge auf die kleine Gabi aus der Nachbarschaft haben. Eigentlich hat der Schreinermeister für so etwas überhaupt keine Zeit. Warum also den Aufpasser-Job nicht an den Pumuckl weitergeben? 158 | 159 | 00:29:44 160 | Ellis Kaut 161 | Meister Eder soll mal schnell ein Auge auf die kleine Gabi aus der Nachbarschaft haben. Eigentlich hat der Schreinermeister für so etwas überhaupt keine Zeit. Warum also den Aufpasser-Job nicht an den Pumuckl weitergeben? 162 | full 163 | 164 | 165 | Pumuckl passt auf 166 | https://www.br.de/mediathek/podcast/pumuckl/pumuckl-passt-auf-1/1917670 167 | Sat, 31 Dec 2022 12:00:00 +0100 168 | 032f8d1f-1bfe-4e7b-9f23-0b575bfd723a 169 | Meister Eder muss sich ja oft über den Pumuckl ärgern. Heute allerdings trifft der Schreinermeister auf jemanden, der noch viel mehr nerven kann als der kleine Kobold. 170 | 171 | 00:22:20 172 | Ellis Kaut 173 | Meister Eder muss sich ja oft über den Pumuckl ärgern. Heute allerdings trifft der Schreinermeister auf jemanden, der noch viel mehr nerven kann als der kleine Kobold. 174 | full 175 | 176 | 177 | Pumuckl wartet auf die Bescherung 178 | https://www.br.de/mediathek/podcast/pumuckl/pumuckl-wartet-auf-die-bescherung-1/1914179 179 | Sat, 24 Dec 2022 12:00:00 +0100 180 | 0885fbef-d331-4573-afb3-99477fe63799 181 | Der Heilige Abend ist endlich da! Meister Eder und der Pumuckl freuen sich sehr. Allerdings wundern sich die beiden, wie oft man an so einem Tag gestört werden kann. Und wie schnell die Menge der Plätzchen auf dem Teller immer größer wird ... 182 | 183 | 00:35:29 184 | Ellis Kaut 185 | Der Heilige Abend ist endlich da! Meister Eder und der Pumuckl freuen sich sehr. Allerdings wundern sich die beiden, wie oft man an so einem Tag gestört werden kann. Und wie schnell die Menge der Plätzchen auf dem Teller immer größer wird ... 186 | full 187 | 188 | 189 | Pumuckl und der Nikolaus 190 | https://www.br.de/mediathek/podcast/pumuckl/pumuckl-und-der-nikolaus-1/1904345 191 | Mon, 05 Dec 2022 06:00:00 +0100 192 | c62dffb8-fdd2-44ec-8e9a-84efeb0643dc 193 | Pumuckl erfährt, dass der Nikolaus nur zu Kindern kommt, die brav sind. Da kann er ja ruhig weiter Unsinn machen, denkt er sich, denn schließlich ist er kein Kind, sondern ein Kobold. Der Meister Eder muss sich also was einfallen lassen ... 194 | 195 | 00:30:07 196 | Ellis Kaut 197 | Pumuckl erfährt, dass der Nikolaus nur zu Kindern kommt, die brav sind. Da kann er ja ruhig weiter Unsinn machen, denkt er sich, denn schließlich ist er kein Kind, sondern ein Kobold. Der Meister Eder muss sich also was einfallen lassen ... 198 | full 199 | Pumuckl erfährt, dass der Nikolaus nur zu Kindern kommt, die brav sind. Da kann er ja ruhig weiter Unsinn machen, denkt er sich, denn schließlich ist er kein Kind, sondern ein Kobold. Der Meister Eder muss sich also was einfallen lassen ...

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

]]>
209 |
210 | 211 | Pumuckl und die Erkältung 212 | https://www.br.de/mediathek/podcast/pumuckl/pumuckl-und-die-erkaeltung/1896343 213 | Sat, 19 Nov 2022 12:00:00 +0100 214 | 3e82032e-89b2-4941-87c6-4dab61344635 215 | Saumäßiges Wetter bringt wunderbare Pfützen, in die der Pumuckl vortrefflich rein hüpfen kann. Und Unsichtbare können auch nicht krank werden! Der kleine Kobold ignoriert alle Warnungen von Meister Eder. Damit er unsichtbar bleibt, muss er doch nur dem Meister Eder aus dem Weg gehen. Doch der Plan geht in die Hose. 216 | 217 | 00:29:38 218 | Ellis Kaut 219 | Saumäßiges Wetter bringt wunderbare Pfützen, in die der Pumuckl vortrefflich rein hüpfen kann. Und Unsichtbare können auch nicht krank werden! Der kleine Kobold ignoriert alle Warnungen von Meister Eder. Damit er unsichtbar bleibt, muss er doch nur dem Meister Eder aus dem Weg gehen. Doch der Plan geht in die Hose. 220 | full 221 | 222 | 223 | Pumuckl will Lesen lernen 224 | https://www.br.de/mediathek/podcast/pumuckl/pumuckl-will-lesen-lernen/1878551 225 | Sat, 15 Oct 2022 12:00:00 +0200 226 | 83672492-0906-4547-a231-95b35647f338 227 | Pumuckl beobachtet den Meister Eder beim Zeitungslesen und findet, er selbst müsse jetzt auch Lesen und am besten auch gleich Schreiben lernen. Die ersten Buchstaben zeigt ihm der Schreiner, dann malt der Kobold Worte aus der Zeitung ab - und wirft die Nachrichten in den Hausbriefkasten von Frau Steiner. Die ist erst verwirrt und dann beunruhigt. Für den Pumuckl ein großer Spaß. 228 | 229 | 00:30:08 230 | Ellis Kaut 231 | Pumuckl beobachtet den Meister Eder beim Zeitungslesen und findet, er selbst müsse jetzt auch Lesen und am besten auch gleich Schreiben lernen. Die ersten Buchstaben zeigt ihm der Schreiner, dann malt der Kobold Worte aus der Zeitung ab - und wirft die Nachrichten in den Hausbriefkasten von Frau Steiner. Die ist erst verwirrt und dann beunruhigt. Für den Pumuckl ein großer Spaß. 232 | full 233 | 234 | 235 | Die Gartenzwerge 236 | https://www.br.de/mediathek/podcast/pumuckl/die-gartenzwerge/1861854 237 | Sat, 10 Sep 2022 12:00:00 +0200 238 | 305e0ca7-0720-4292-bf3b-1aee38bff5df 239 | Meister Eder bekommt von einer Kundin für den Blumenkasten vor dem Werkstattfenster einen Gartenzwerg geschenkt. Pumuckl ist sauer: Für diese "Konkurrenz" hat er gar nichts übrig. Der Schreinermeister ahnt, das wird wieder mal ein komplizierter Tag ... und er soll recht behalten. 240 | 241 | 00:29:44 242 | Ellis Kaut 243 | Meister Eder bekommt von einer Kundin für den Blumenkasten vor dem Werkstattfenster einen Gartenzwerg geschenkt. Pumuckl ist sauer: Für diese "Konkurrenz" hat er gar nichts übrig. Der Schreinermeister ahnt, das wird wieder mal ein komplizierter Tag ... und er soll recht behalten. 244 | full 245 | 246 |
247 |
248 | --------------------------------------------------------------------------------