diff --git a/podme_api/client.py b/podme_api/client.py index cfdd7f2..1ac0793 100644 --- a/podme_api/client.py +++ b/podme_api/client.py @@ -14,14 +14,17 @@ from typing import TYPE_CHECKING, Callable, Self, Sequence, TypeVar import aiofiles +import aiofiles.os from aiohttp.client import ClientError, ClientPayloadError, ClientResponseError, ClientSession from aiohttp.hdrs import METH_DELETE, METH_GET, METH_POST +from ffmpeg.asyncio import FFmpeg +from ffmpeg.errors import FFmpegError import platformdirs from yarl import URL from podme_api.const import ( - PODME_API_URL, DEFAULT_REQUEST_TIMEOUT, + PODME_API_URL, ) from podme_api.exceptions import ( PodMeApiConnectionError, @@ -312,12 +315,64 @@ async def _get_pages( return data + async def transcode_file( + self, + input_file: PathLike | str, + output_file: PathLike | str | None = None, + transcode_options: dict[str, str] | None = None, + ) -> Path: + """Remux audio file using ffmpeg. + + This will basically remux the audio file into another container format (version 1 of the MP4 + Base Media format). Most likely this can be solved in better ways, but this will do for now. + If the audio is served to clients in the original container (version 5 as of now), they will + be very confused about the total duration of the file, for some reason... + + Args: + input_file (PathLike | str): The path to the audio file. + output_file (PathLike | str | None): The path to the output file. + By default, the output file will be the same as the input file with "_out" appended + to the name. + transcode_options (dict[str, str] | None): Additional transcode options. + + """ + input_file = Path(input_file) + if not input_file.is_file(): + raise PodMeApiError("File not found") + + if output_file is None: + output_file = input_file.with_stem(f"{input_file.stem}_out") + + transcode_options = transcode_options or {} + + ffmpeg = ( + FFmpeg() + .option("y") + .input(input_file.as_posix()) + .output( + output_file.as_posix(), + { + "c": "copy", + "map": "0", + "brand": "isomiso2mp41", + **transcode_options, + }, + ) + ) + try: + await ffmpeg.execute() + except FFmpegError as err: + _LOGGER.warning("Error occurred while transcoding file: %s", err) + return input_file + return output_file + async def download_file( self, download_url: URL | str, path: PathLike | str, on_progress: Callable[[str, int, int], None] | None = None, on_finished: Callable[[str, str], None] | None = None, + transcode: bool = True, ) -> None: """Download a file from a given URL and save it to the specified path. @@ -330,6 +385,7 @@ async def download_file( on_finished (Callable[[str, str], None], optional): A callback function to be called when the download is complete. It should accept the download URL and save path as arguments. + transcode (bool, optional): Whether to transcode the file. Defaults to True. Raises: PodMeApiDownloadError: If there's an error during the download process. @@ -356,6 +412,13 @@ async def download_file( raise PodMeApiDownloadError(msg) from err _LOGGER.debug("Finished download of <%s> to <%s>", download_url, save_path) + + if transcode: + new_save_path = await self.transcode_file(save_path) + if new_save_path != save_path: + _LOGGER.debug("Moving transcoded file %s to %s", new_save_path, save_path) + await aiofiles.os.replace(new_save_path, save_path) + if on_finished: on_finished(str(download_url), str(save_path)) @@ -706,6 +769,16 @@ async def get_podcast_info(self, podcast_slug: str) -> PodMePodcast: ) return PodMePodcast.from_dict(data) + async def get_podcasts_info(self, podcast_slugs: list[str]) -> list[PodMePodcast]: + """Get information about multiple podcasts. + + Args: + podcast_slugs (list[str]): The slugs of the podcasts. + + """ + podcasts = await asyncio.gather(*[self.get_podcast_info(slug) for slug in podcast_slugs]) + return list(podcasts) + async def get_episode_info(self, episode_id: int) -> PodMeEpisode: """Get information about an episode. diff --git a/podme_api/models.py b/podme_api/models.py index faa8a72..2cf6cd7 100644 --- a/podme_api/models.py +++ b/podme_api/models.py @@ -277,7 +277,7 @@ class PodMeHomeSection(BaseDataClassORJSONMixin): class PodMeSearchResult(BaseDataClassORJSONMixin): """Represents a search result in PodMe.""" - podcast_id: int = field(metadata=field_options(alias="podcastId")) + podcast_id: int | str = field(metadata=field_options(alias="podcastId")) podcast_title: str = field(metadata=field_options(alias="podcastTitle")) image_url: str = field(metadata=field_options(alias="imageUrl")) author_full_name: str = field(metadata=field_options(alias="authorFullName")) diff --git a/poetry.lock b/poetry.lock index d763383..7d88826 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1566,6 +1566,23 @@ doc = ["ablog (>=0.11.8)", "colorama", "graphviz", "ipykernel", "ipyleaflet", "i i18n = ["Babel", "jinja2"] test = ["pytest", "pytest-cov", "pytest-regressions", "sphinx[test]"] +[[package]] +name = "pyee" +version = "12.0.0" +description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyee-12.0.0-py3-none-any.whl", hash = "sha256:7b14b74320600049ccc7d0e0b1becd3b4bd0a03c745758225e31a59f4095c990"}, + {file = "pyee-12.0.0.tar.gz", hash = "sha256:c480603f4aa2927d4766eb41fa82793fe60a82cbfdb8d688e0d08c55a534e145"}, +] + +[package.dependencies] +typing-extensions = "*" + +[package.extras] +dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "pytest", "pytest-asyncio", "pytest-trio", "sphinx", "toml", "tox", "trio", "trio", "trio-typing", "twine", "twisted", "validate-pyproject[all]"] + [[package]] name = "pygments" version = "2.18.0" @@ -1663,6 +1680,21 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] +[[package]] +name = "python-ffmpeg" +version = "2.0.12" +description = "A python binding for FFmpeg which provides sync and async APIs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "python_ffmpeg-2.0.12-py3-none-any.whl", hash = "sha256:d86697da8dfb39335183e336d31baf42fb217468adf5ac97fd743898240faae3"}, + {file = "python_ffmpeg-2.0.12.tar.gz", hash = "sha256:19ac80af5a064a2f53c245af1a909b2d7648ea045500d96d3bcd507b88d43dc7"}, +] + +[package.dependencies] +pyee = "*" +typing-extensions = "*" + [[package]] name = "pyyaml" version = "6.0.2" @@ -2365,4 +2397,4 @@ test = [] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "9083426e70abef7157295b78d19b07b48e352c31bf0736aec34d3d74f7c10c74" +content-hash = "2b1e0ae0343074bedadc74b4e907755c4db9527f79886779553bbcadc860fcd1" diff --git a/pyproject.toml b/pyproject.toml index d2ecf4c..63e69a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ aiofiles = "^24.1.0" mashumaro = "^3.13.1" orjson = "^3.10.7" isodate = "^0.7.2" +python-ffmpeg = "^2.0.12" [tool.poetry.group.dev.dependencies] aresponses = "^3.0.0"