diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b6084ae --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## Unreleased + +### Changed + +- Formatted the Python source with Black. +- Normalized import ordering with isort. +- Corrected PEP 8 whitespace, indentation, quoting, and `None` comparison style. +- Preserved the existing public camelCase APIs to avoid breaking current callers. +- Git commits for updates are now signed with GPG. +- Added a cached JPEG artwork proxy for formats such as WebP, with download, + image-size, redirect, and private-address protections. +- Added channel-level iTunes artwork, News category, non-explicit status, and + episodic show type metadata. + +### Validation + +- Python bytecode compilation, formatter, import-order, pycodestyle, whitespace, + and focused utility smoke checks pass. +- **Not fully tested:** the application has not undergone end-to-end or live Podimo + API testing. The artwork and feed test suite passes, and the application starts + successfully with the configured Hypercorn server. diff --git a/Dockerfile b/Dockerfile index a64d34d..6d930e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.10-alpine WORKDIR /src COPY requirements.txt /src/ -RUN apk add libxml2-dev libxslt-dev gcc libc-dev && pip3 install --no-cache-dir -r requirements.txt +RUN apk add libxml2-dev libxslt-dev jpeg-dev zlib-dev libwebp-dev gcc libc-dev && pip3 install --no-cache-dir -r requirements.txt COPY . /src diff --git a/README.md b/README.md index 6e8d9f8..ae658ae 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,14 @@ docker run --rm \ 3. Visit http://localhost:12104. You should see the site now! +## HLS fallback audio + +Podcast clients without HLS support receive an episode-specific URL such as +`/audio/dummy-.mp3` as their standard RSS enclosure. Every generated URL +serves the same physical fallback file at `audio/dummy.mp3`. The legacy +`/audio/dummy.mp3` URL remains available. A reverse proxy may expose these URLs +under a prefix such as `/pc/audio/`. + ## Configuration A complete list of all configuration options can be found in the [.env.example file](.env.example) diff --git a/audio/.gitkeep b/audio/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/audio/.gitkeep @@ -0,0 +1 @@ + diff --git a/audio/dummy.mp3 b/audio/dummy.mp3 new file mode 100644 index 0000000..e4b6df5 Binary files /dev/null and b/audio/dummy.mp3 differ diff --git a/main.py b/main.py index d540132..8a7d078 100644 --- a/main.py +++ b/main.py @@ -18,36 +18,164 @@ # permissions and limitations under the Licence. import asyncio +import logging import re import sys -import logging +import traceback +from hashlib import sha256 +from io import BytesIO +from ipaddress import ip_address +from mimetypes import guess_type from os import getenv -from podimo.client import PodimoClient +from pathlib import Path +from urllib.parse import quote, urlsplit +from weakref import WeakValueDictionary + +import cloudscraper +from aiohttp import ClientSession, ClientTimeout, CookieJar, TCPConnector +from aiohttp.abc import AbstractResolver +from aiohttp.resolver import DefaultResolver +from feedgen.ext.base import BaseEntryExtension, BaseExtension from feedgen.feed import FeedGenerator -from mimetypes import guess_type -from aiohttp import ClientSession, CookieJar, ClientTimeout -from quart import Quart, Response, render_template, request -from hashlib import sha256 -from hypercorn.config import Config from hypercorn.asyncio import serve -from urllib.parse import quote +from hypercorn.config import Config +from lxml import etree +from PIL import Image +from quart import Quart, Response, render_template, request, send_from_directory + +import podimo.cache as cache +from podimo.client import PodimoClient from podimo.config import * from podimo.utils import generateHeaders, randomHexId -import podimo.cache as cache -import cloudscraper -import traceback + +PODCAST_NAMESPACE = "https://podcastindex.org/namespace/1.0" +ITUNES_NAMESPACE = "http://www.itunes.com/dtds/podcast-1.0.dtd" +HLS_FALLBACK_MP3 = "audio/dummy.mp3" +AUDIO_DIR = Path(__file__).resolve().parent / "audio" +HLS_FALLBACK_MP3_PATH = Path(__file__).resolve().parent / HLS_FALLBACK_MP3 +MAX_ARTWORK_SIZE = 10 * 1024 * 1024 +MAX_ARTWORK_DIMENSION = 3000 +MAX_ARTWORK_PIXELS = 20_000_000 +artwork_downloads = asyncio.Semaphore(2) +artwork_locks = WeakValueDictionary() + + +async def runInThread(function, *args): + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, function, *args) + + +def ensurePublicAddresses(addresses): + if any(not ip_address(address["host"]).is_global for address in addresses): + raise OSError("Artwork host resolves to a non-public address") + return addresses + + +class PublicResolver(AbstractResolver): + def __init__(self): + self.resolver = DefaultResolver() + + async def resolve(self, host, port=0, family=0): + addresses = await self.resolver.resolve(host, port, family) + return ensurePublicAddresses(addresses) + + async def close(self): + await self.resolver.close() + + +class PublicConnector(TCPConnector): + async def _resolve_host(self, host, port, traces=None): + addresses = await super()._resolve_host(host, port, traces) + return ensurePublicAddresses(addresses) + + +class PodcastHlsExtension(BaseExtension): + def __init__(self): + self._itunes_explicit = None + self._itunes_type = None + self._podcast_block = None + + def itunes_explicit(self, value=None): + if value is not None: + self._itunes_explicit = value + return self._itunes_explicit + + def itunes_type(self, value=None): + if value is not None: + self._itunes_type = value + return self._itunes_type + + def podcast_block(self, value=None): + if value is not None: + self._podcast_block = value + return self._podcast_block + + def extend_ns(self): + return {"podcast": PODCAST_NAMESPACE} + + def extend_rss(self, feed): + if self._itunes_explicit is not None: + explicit = etree.SubElement( + feed[0], etree.QName(ITUNES_NAMESPACE, "explicit") + ) + explicit.text = self._itunes_explicit + if self._itunes_type is not None: + podcast_type = etree.SubElement( + feed[0], etree.QName(ITUNES_NAMESPACE, "type") + ) + podcast_type.text = self._itunes_type + if self._podcast_block is not None: + podcast_block = etree.SubElement( + feed[0], etree.QName(PODCAST_NAMESPACE, "block") + ) + podcast_block.text = self._podcast_block + return feed + + +class PodcastHlsEntryExtension(BaseEntryExtension): + def __init__(self): + self._alternate_enclosures = [] + + def alternate_enclosure(self, uri, type, length=0, title=None): + self._alternate_enclosures.append( + { + "uri": uri, + "type": type, + "length": length, + "title": title, + } + ) + + def extend_rss(self, entry): + for enclosure in self._alternate_enclosures: + alternate = etree.SubElement( + entry, + etree.QName(PODCAST_NAMESPACE, "alternateEnclosure"), + type=enclosure["type"], + length=str(enclosure["length"]), + ) + if enclosure["title"] is not None: + alternate.set("title", enclosure["title"]) + etree.SubElement( + alternate, + etree.QName(PODCAST_NAMESPACE, "source"), + uri=enclosure["uri"], + ) + return entry + # Setup Quart, used for serving the web pages app = Quart(__name__) proxies = dict() -#Setup logging +# Setup logging logging.basicConfig( format="%(levelname)s | %(asctime)s | %(message)s", datefmt="%Y-%m-%dT%H:%M:%SZ", level=logging.INFO, ) + def example(): return f"""Example ------------ @@ -62,14 +190,18 @@ def example(): a tool like https://gchq.github.io/CyberChef/#recipe=URL_Encode(true) """ + @app.after_request def allow_cors(response): - response.headers.set('Access-Control-Allow-Origin', '*') - response.headers.set('Access-Control-Allow-Methods', 'GET, POST') - response.headers.set('Cache-Control', 'max-age=900') - logging.debug(f"Incoming {request.method} request for '{request.url}' from User-Agent {request.user_agent} at {request.remote_addr}.") + response.headers.set("Access-Control-Allow-Origin", "*") + response.headers.set("Access-Control-Allow-Methods", "GET, POST") + response.headers.set("Cache-Control", "max-age=900") + logging.debug( + f"Incoming {request.method} request for '{request.url}' from User-Agent {request.user_agent} at {request.remote_addr}." + ) return response + def authenticate(): return Response( f"""401 Unauthorized. @@ -79,11 +211,14 @@ def authenticate(): 401, { "Content-Type": "text/plain", - "WWW-Authenticate": "Basic realm='Podimo credentials'" + "WWW-Authenticate": "Basic realm='Podimo credentials'", }, ) -def initialize_client(username: str, password: str, region: str, locale: str) -> PodimoClient: + +def initialize_client( + username: str, password: str, region: str, locale: str +) -> PodimoClient: client = PodimoClient(username, password, region, locale) # Check if there is an authentication token already in memory. If so, use that one. @@ -97,6 +232,7 @@ def initialize_client(username: str, password: str, region: str, locale: str) -> client.cookie_jar = cache.cookie_jars[key] return client + async def check_auth(username, password, region, locale, scraper): try: client = initialize_client(username, password, region, locale) @@ -113,8 +249,10 @@ async def check_auth(username, password, region, locale, scraper): traceback.print_exc() return None + podcast_id_pattern = re.compile(r"[0-9a-fA-F\-]+") + @app.route("/", methods=["POST", "GET"]) async def index(): error = "" @@ -148,20 +286,26 @@ async def index(): podcast_id = quote(str(podcast_id), safe="") region = quote(str(region), safe="") locale = quote(str(locale), safe="") - + if LOCAL_CREDENTIALS: url = f"{PODIMO_PROTOCOL}://{PODIMO_HOSTNAME}/feed/{podcast_id}.xml?{randomHexId(10)}®ion={region}&locale={locale}" else: email = quote(str(email), safe="") - comma = quote(',', safe="") + comma = quote(",", safe="") username = f"{email}{comma}{region}{comma}{locale}" - password = quote(str(password), safe="") + password = quote(str(password), safe="") url = f"{PODIMO_PROTOCOL}://{username}:{password}@{PODIMO_HOSTNAME}/feed/{podcast_id}.xml?{randomHexId(10)}®ion={region}&locale={locale}" - + logging.debug(f"Created an URL: {url}.") return await render_template("feed_location.html", url=url) - return await render_template("index.html", error=error, locales=LOCALES, regions=REGIONS, need_credentials=not(LOCAL_CREDENTIALS)) + return await render_template( + "index.html", + error=error, + locales=LOCALES, + regions=REGIONS, + need_credentials=not (LOCAL_CREDENTIALS), + ) @app.errorhandler(404) @@ -171,13 +315,24 @@ async def not_found(error): ) +@app.route("/audio/") +async def serve_audio(filename): + if filename != "dummy.mp3" and not re.fullmatch( + r"dummy-[0-9a-f]{64}\.mp3", filename + ): + return Response("Audio not found", 404, {}) + return await send_from_directory(AUDIO_DIR, "dummy.mp3") + + @app.route("/feed/.xml") async def serve_basic_auth_feed(podcast_id): if LOCAL_CREDENTIALS: args = request.args region = args.get("region") locale = args.get("locale") - return await serve_feed(PODIMO_EMAIL, PODIMO_PASSWORD, podcast_id, region, locale) + return await serve_feed( + PODIMO_EMAIL, PODIMO_PASSWORD, podcast_id, region, locale + ) else: auth = request.authorization if not auth: @@ -187,12 +342,105 @@ async def serve_basic_auth_feed(podcast_id): return await serve_feed(username, auth.password, podcast_id, region, locale) +def artworkUrl(image_url): + if not image_url: + return None + + parsed_url = urlsplit(image_url) + if parsed_url.scheme not in ("http", "https") or not parsed_url.netloc: + logging.warning("Skipping invalid artwork URL") + return None + + if parsed_url.path.lower().endswith((".jpg", ".jpeg", ".png")): + return image_url + + key = cache.registerArtworkSource(image_url) + return f"{PODIMO_PROTOCOL}://{PODIMO_HOSTNAME}/artwork/{key}.jpg" + + +def artworkToJpeg(data): + with Image.open(BytesIO(data)) as source: + if source.width * source.height > MAX_ARTWORK_PIXELS: + raise ValueError("Artwork exceeds maximum pixel count") + source.load() + source.thumbnail( + (MAX_ARTWORK_DIMENSION, MAX_ARTWORK_DIMENSION), Image.Resampling.LANCZOS + ) + + if source.mode in ("RGBA", "LA") or "transparency" in source.info: + rgba = source.convert("RGBA") + image = Image.new("RGB", rgba.size, "white") + image.paste(rgba, mask=rgba.getchannel("A")) + else: + image = source.convert("RGB") + + output = BytesIO() + image.save(output, format="JPEG", quality=90, optimize=True) + return output.getvalue() + + +async def fetchArtwork(image_url): + timeout = ClientTimeout(total=20) + connector = PublicConnector(resolver=PublicResolver()) + async with ClientSession(timeout=timeout, connector=connector) as session: + async with session.get( + image_url, allow_redirects=True, max_redirects=5 + ) as response: + response.raise_for_status() + if response.content_length and response.content_length > MAX_ARTWORK_SIZE: + raise ValueError("Artwork exceeds maximum download size") + + data = bytearray() + async for chunk in response.content.iter_chunked(64 * 1024): + data.extend(chunk) + if len(data) > MAX_ARTWORK_SIZE: + raise ValueError("Artwork exceeds maximum download size") + return bytes(data) + + +@app.route("/artwork/.jpg") +async def serve_artwork(key): + if not re.fullmatch(r"[0-9a-f]{64}", key): + return Response("Artwork not found", 404, {}) + + artwork = await runInThread(cache.getArtwork, key) + if artwork is None and await runInThread(cache.getArtworkFailure, key): + return Response("Something went wrong while fetching artwork", 502, {}) + + if artwork is None: + lock = artwork_locks.setdefault(key, asyncio.Lock()) + async with lock: + artwork = await runInThread(cache.getArtwork, key) + if artwork is None: + if await runInThread(cache.getArtworkFailure, key): + return Response( + "Something went wrong while fetching artwork", 502, {} + ) + source_url = await runInThread(cache.getArtworkSource, key) + if source_url is None: + return Response("Artwork not found", 404, {}) + + try: + async with artwork_downloads: + source = await fetchArtwork(source_url) + artwork = await runInThread(artworkToJpeg, source) + await runInThread(cache.insertArtwork, key, artwork) + except Exception as error: + await runInThread(cache.insertArtworkFailure, key) + logging.error(f"Error while fetching artwork {key}: {error}") + return Response( + "Something went wrong while fetching artwork", 502, {} + ) + + return Response(artwork, mimetype="image/jpeg") + + def split_username_region_locale(string): - s = string.split(',') + s = string.split(",") if len(s) == 3: return tuple(s) else: - return (s[0], 'nl', 'nl-NL') + return (s[0], "nl", "nl-NL") def token_key(username, password): @@ -204,23 +452,25 @@ def token_key(username, password): @app.route("/feed///.xml") async def serve_feed(username, password, podcast_id, region, locale): - - logging.debug(f"Feed request for podcast {podcast_id} from IP {request.remote_addr} with User-Agent:{request.user_agent}.") - + + logging.debug( + f"Feed request for podcast {podcast_id} from IP {request.remote_addr} with User-Agent:{request.user_agent}." + ) + # Check if it is a valid podcast id string if podcast_id_pattern.fullmatch(podcast_id) is None: return Response("Invalid podcast id format", 400, {}) - + if region not in [region_code for (region_code, _) in REGIONS]: return Response("Invalid region", 400, {}) if locale not in LOCALES: return Response("Invalid locale", 400, {}) - # Check if url contains unique ID or podcastID in blocked list. If so, return HTTP code 410 GONE + # Return HTTP 410 GONE if the URL contains a blocked ID or podcast ID. if any(item in request.url for item in BLOCKED): logging.debug(f"Blocked! Podcast {podcast_id} is on local block list") - return Response("Podcast is gone", 410, {}) - + return Response("Podcast is gone", 410, {}) + with cloudscraper.create_scraper() as scraper: scraper.proxies = proxies client = await check_auth(username, password, region, locale, scraper) @@ -248,80 +498,156 @@ async def urlHeadInfo(session, id, url, locale): if entry: return entry - retries = 3 # Number of retries - timeout = ClientTimeout(total=10) # 10 seconds timeout for each try - - for attempt in range(retries): - try: - logging.debug(f"HEAD request to {url} (Attempt {attempt + 1})") - async with session.head(url, allow_redirects=True, - headers=generateHeaders(None, locale), - timeout=timeout) as response: - content_length = 0 - content_type, _ = guess_type(url) - if 'content-length' in response.headers: - content_length = response.headers['content-length'] - if content_type is None and 'content-type' in response.headers: - content_type = response.headers['content-type'] - else: - content_type = 'audio/mpeg' - cache.insertIntoHeadCache(id, content_length, content_type) - return (content_length, content_type) - - except asyncio.TimeoutError: - if attempt < retries - 1: - logging.info(f"Retrying HEAD request to {url} (Attempt {attempt + 2})") - await asyncio.sleep(1) # Wait for 1 second before retrying - else: - logging.error(f"All retries failed for HEAD request to {url}") - raise # Re-raise the last exception if all retries fail - + logging.debug(f"HEAD request to {url}") + async with session.head( + url, allow_redirects=True, headers=generateHeaders(None, locale), timeout=3.05 + ) as response: + content_length = 0 + content_type, _ = guess_type(url) + if "content-length" in response.headers: + content_length = response.headers["content-length"] + if content_type is None and "content-type" in response.headers: + content_type = response.headers["content-type"] + else: + content_type = "audio/mpeg" + cache.insertIntoHeadCache(id, content_length, content_type) + return (content_length, content_type) def extract_audio_url(episode): duration = 0 url = None - if episode['audio']: - url = episode['audio']['url'] - duration = episode['audio']['duration'] + if episode["audio"]: + url = episode["audio"]["url"] + duration = episode["audio"]["duration"] if url is None or url == "": if episode["streamMedia"]: url = episode["streamMedia"]["url"] duration = episode["streamMedia"]["duration"] - if "hls-media" in url and "/main.m3u8" in url: - url = url.replace("hls-media", "audios") - url = url.replace("/main.m3u8", ".mp3") + # SJC + # url = url.replace('&', '&') + # logging.info(f"Media URL: {url}") return url, duration async def addFeedEntry(fg, episode, session, locale): + episode_id = episode.get("id", "") + logging.debug(f"Starting feed entry for episode {episode_id}") + fe = fg.add_entry() fe.guid(episode["id"]) fe.title(episode["title"]) fe.description(episode["description"]) fe.pubDate(episode.get("publishDatetime", episode.get("datetime"))) - fe.podcast.itunes_image(episode["imageUrl"]) + image = artworkUrl(episode.get("imageUrl")) + if image: + fe.podcast.itunes_image(image) url, duration = extract_audio_url(episode) if url is None: - return - logging.debug(f"Found podcast '{episode['title']}'") + logging.warning(f"No audio URL found for episode {episode_id}") + return + + logging.debug( + f"Audio URL found for episode {episode_id}, duration={duration}, " + f"is_hls={url.split('?', 1)[0].lower().endswith('.m3u8')}" + ) + fe.podcast.itunes_duration(duration) - content_length, content_type = await urlHeadInfo(session, episode['id'], url, locale) - fe.enclosure(url, content_length, content_type) + content_length, content_type = await urlHeadInfo( + session, episode["id"], url, locale + ) + + # Podimo returns HLS manifests as .m3u8 URLs. + is_hls = url.split("?", 1)[0].lower().endswith(".m3u8") + + if is_hls: + fallback_url = hlsFallbackMp3Url(episode_id) + logging.debug( + f"Adding fallback MP3 enclosure {fallback_url} and " + f"HLS alternate enclosure " + f"for episode {episode_id}" + ) + fe.enclosure( + fallback_url, + hlsFallbackMp3Size(), + "audio/mpeg", + ) + fe.podcast_hls.alternate_enclosure( + uri=url, + type="application/x-mpegURL", + length=content_length, + title="HLS", + ) + else: + fe.enclosure(url, content_length, content_type) + + logging.debug(f"Finished feed entry for episode {episode_id}") + def chunks(x, n): for i in range(0, len(x), n): - yield x[i:i + n] + yield x[i : i + n] + + +def hlsFallbackMp3Size(): + if not HLS_FALLBACK_MP3_PATH.is_file(): + raise FileNotFoundError( + f"HLS fallback MP3 not found: {HLS_FALLBACK_MP3_PATH}" + ) + return str(HLS_FALLBACK_MP3_PATH.stat().st_size) + + +def hlsFallbackMp3Url(episode_id): + episode_hash = sha256(str(episode_id).encode("utf-8")).hexdigest() + return ( + f"{PODIMO_PROTOCOL}://{PODIMO_HOSTNAME}/audio/" + f"dummy-{episode_hash}.mp3" + ) + + +def normalizeLanguageTag(language): + if language and language.lower() == "nl-nl": + return "nl-NL" + return language + async def podcastsToRss(podcast_id, data, locale): - fg = FeedGenerator() - fg.load_extension("podcast") + logging.debug(f"podcastsToRss: START podcast_id={podcast_id}") - podcast = data["podcast"] - episodes = data["episodes"] + try: + logging.debug("podcastsToRss: creating FeedGenerator") + fg = FeedGenerator() + logging.debug("podcastsToRss: FeedGenerator created") + + logging.debug("podcastsToRss: loading podcast extension") + fg.load_extension("podcast") + logging.debug("podcastsToRss: podcast extension loaded") + + logging.debug("podcastsToRss: registering HLS extension") + fg.register_extension( + "podcast_hls", + extension_class_feed=PodcastHlsExtension, + extension_class_entry=PodcastHlsEntryExtension, + atom=False, + rss=True, + ) + logging.debug("podcastsToRss: HLS extension registered") + + logging.debug("podcastsToRss: reading podcast data") + podcast = data["podcast"] + episodes = data["episodes"] + + logging.debug( + f"podcastsToRss: podcast={podcast.get('title')!r}, " + f"episodes={len(episodes)}" + ) + + except Exception: + logging.exception(f"podcastsToRss: FAILED for podcast_id={podcast_id}") + raise if len(episodes) > 0: last_episode = episodes[0] @@ -339,13 +665,20 @@ async def podcastsToRss(podcast_id, data, locale): image = podcast["images"]["coverImageUrl"] if image is None: - image = last_episode['imageUrl'] - fg.image(image) + image = last_episode["imageUrl"] + image = artworkUrl(image) + if image: + fg.image(image) + fg.podcast.itunes_image(image) + fg.podcast.itunes_category("News") + fg.podcast_hls.itunes_explicit("false") + fg.podcast.itunes_owner(name="Example", email="you@example.com") + fg.podcast_hls.itunes_type("episodic") language = podcast["language"] if language is None: language = locale - fg.language(language) + fg.language(normalizeLanguageTag(language)) artist = podcast["authorName"] if artist is None: @@ -354,6 +687,7 @@ async def podcastsToRss(podcast_id, data, locale): if not PUBLIC_FEEDS: fg.podcast.itunes_block(True) + fg.podcast_hls.podcast_block("yes") async with ClientSession() as session: for chunk in chunks(episodes, 5): @@ -371,21 +705,26 @@ async def spawn_web_server(): config.read_timeout = 60 config.graceful_timeout = 5 config.backlog = 1000 - app.config['TEMPLATES_AUTO_RELOAD'] = True + app.config["TEMPLATES_AUTO_RELOAD"] = True await serve(app, config) + async def main(): if HTTP_PROXY: global proxies - logging.info(f"Running with https proxy defined in environmental variable HTTP_PROXY: {HTTP_PROXY}") - proxies['https'] = HTTP_PROXY + logging.info( + f"Running with https proxy defined in environmental variable HTTP_PROXY: {HTTP_PROXY}" + ) + proxies["https"] = HTTP_PROXY tasks = [spawn_web_server()] await asyncio.gather(*tasks) + if __name__ == "__main__": if DEBUG: - logging.info(f"""Spawning server on {PODIMO_BIND_HOST} -Configuration: + logging.info( + f"""Spawning server on {PODIMO_BIND_HOST} +Configuration: - DEBUG: {DEBUG} - LOCAL CREDENTIALS: {LOCAL_CREDENTIALS} ({PODIMO_EMAIL}) - PODIMO_HOSTNAME: {PODIMO_HOSTNAME} @@ -401,5 +740,6 @@ async def main(): - PODCAST_CACHE_TIME: {PODCAST_CACHE_TIME} sec - HEAD_CACHE_TIME: {HEAD_CACHE_TIME} sec - BLOCKING: {BLOCKED} -""") +""" + ) asyncio.run(main()) diff --git a/podimo/cache.py b/podimo/cache.py index a289aaa..9472468 100644 --- a/podimo/cache.py +++ b/podimo/cache.py @@ -17,32 +17,39 @@ # See the Licence for the specific language governing # permissions and limitations under the Licence. -from podimo.config import * -from typing import Dict, Tuple +from hashlib import sha256 +from os.path import join from time import time +from typing import Dict, Tuple + from diskcache import Cache -from os.path import join + +from podimo.config import * # Store the authentication token in a dictionary # so it is not necessary to request a new token for every request. The key is # derived from the provided username and password (see the `token_key` function). TOKENS = dict() if STORE_TOKENS_ON_DISK: - TOKENS = Cache(join(CACHE_DIR, 'tokens_cache')) + TOKENS = Cache(join(CACHE_DIR, "tokens_cache")) # Give each user its own cookie jar to keep track of cookies that are # being set and used between different requests. cookie_jars = dict() -url_cache = Cache(join(CACHE_DIR, 'url_cache')) -podcast_cache = Cache(join(CACHE_DIR, 'podcast_cache')) +url_cache = Cache(join(CACHE_DIR, "url_cache")) +podcast_cache = Cache(join(CACHE_DIR, "podcast_cache")) # Podcast players support the display of the file size of each episode. # Podimo does not provide this information directly, so we do a HEAD request # to the episode file locations. This gives us the Content-Length which is # the file size of the episode. The file size of an episode doesn't change often, # which makes it perfect for caching. -head_cache = Cache(join(CACHE_DIR, 'head_cache')) +head_cache = Cache(join(CACHE_DIR, "head_cache")) +artwork_cache = Cache(join(CACHE_DIR, "artwork_cache")) +ARTWORK_CACHE_TIME = 7 * 24 * 60 * 60 +ARTWORK_FAILURE_CACHE_TIME = 5 * 60 + def getCacheEntry(key: str, cache, delete=True): if key in cache: @@ -54,18 +61,50 @@ def getCacheEntry(key: str, cache, delete=True): else: return value + def getHeadEntry(id: str): return getCacheEntry(id, head_cache, False) + def insertCacheEntry(key, value, timeout, cache): cache[key] = (time() + timeout, value) + def insertIntoTokenCache(key, value): insertCacheEntry(key, value, TOKEN_CACHE_TIME, TOKENS) + def insertIntoHeadCache(key, content_length, content_type): insertCacheEntry(key, (content_length, content_type), HEAD_CACHE_TIME, head_cache) + def insertIntoPodcastCache(key, podcast): insertCacheEntry(key, podcast, PODCAST_CACHE_TIME, podcast_cache) + +def registerArtworkSource(url): + key = sha256(url.encode("utf-8")).hexdigest() + source_key = f"source:{key}" + if artwork_cache.get(source_key) != url: + artwork_cache[source_key] = url + return key + + +def getArtworkSource(key): + return artwork_cache.get(f"source:{key}") + + +def getArtwork(key): + return artwork_cache.get(f"image:{key}") + + +def insertArtwork(key, artwork): + artwork_cache.set(f"image:{key}", artwork, expire=ARTWORK_CACHE_TIME) + + +def getArtworkFailure(key): + return artwork_cache.get(f"failure:{key}") + + +def insertArtworkFailure(key): + artwork_cache.set(f"failure:{key}", True, expire=ARTWORK_FAILURE_CACHE_TIME) diff --git a/podimo/client.py b/podimo/client.py index 265d941..33b7d54 100644 --- a/podimo/client.py +++ b/podimo/client.py @@ -17,16 +17,19 @@ # See the Licence for the specific language governing # permissions and limitations under the Licence. -from podimo.config import GRAPHQL_URL, SCRAPER_API, ZENROWS_API -from podimo.utils import (is_correct_email_address, token_key, - randomFlyerId, generateHeaders as gHdrs, - async_wrap) -from podimo.cache import insertIntoPodcastCache, getCacheEntry, podcast_cache -from time import time import logging +from time import time + +from podimo.cache import getCacheEntry, insertIntoPodcastCache, podcast_cache +from podimo.config import GRAPHQL_URL, SCRAPER_API, ZENROWS_API +from podimo.utils import async_wrap +from podimo.utils import generateHeaders as gHdrs +from podimo.utils import is_correct_email_address, randomFlyerId, token_key + if ZENROWS_API is not None: from zenrows import ZenRowsClient + class PodimoClient: def __init__(self, username: str, password: str, region: str, locale: str): self.username = username @@ -56,19 +59,26 @@ async def post(self, headers, query, variables, scraper): POST_URL = GRAPHQL_URL else: POST_URL = GRAPHQL_URL - response = await async_wrap(scraper.post)(POST_URL, - headers=headers, - cookies=self.cookie_jar, - json={"query": query, "variables": variables}, - timeout=(6.05, 30) - ) + response = await async_wrap(scraper.post)( + POST_URL, + headers=headers, + cookies=self.cookie_jar, + json={"query": query, "variables": variables}, + timeout=(6.05, 30), + ) if response is None: - raise RuntimeError(f"Could not receive response for query: {query.strip()[:30]}...") + raise RuntimeError( + f"Could not receive response for query: {query.strip()[:30]}..." + ) if response.status_code != 200: - raise RuntimeError(f"Podimo returned an error code. Response code was: {response.status_code} for query \"{query.strip()[:30]}...\"") + raise RuntimeError( + f'Podimo returned an error code. Response code was: {response.status_code} for query "{query.strip()[:30]}..."' + ) result = response.json()["data"] if result is None: - raise RuntimeError(f"Podimo returned no valid data for query {query.strip()[:30]}") + raise RuntimeError( + f"Podimo returned no valid data for query {query.strip()[:30]}" + ) return result # This gets the authentication token that is required for subsequent requests @@ -90,17 +100,22 @@ async def getPreregisterToken(self, scraper): } } """ - variables = {"locale": self.locale, "countryCode": self.region, "appsFlyerId": randomFlyerId()} + variables = { + "locale": self.locale, + "countryCode": self.region, + "appsFlyerId": randomFlyerId(), + } result = await self.post(headers, query, variables, scraper) tokenWithPreregisterUser = result["tokenWithPreregisterUser"] if not tokenWithPreregisterUser: raise RuntimeError("Podimo did not provide a tokenWithPreregisterUser") self.preauth_token = result["tokenWithPreregisterUser"]["token"] if not self.preauth_token: - raise RuntimeError("Podimo did not provide a tokenWithPreregisterUser token") + raise RuntimeError( + "Podimo did not provide a tokenWithPreregisterUser token" + ) return self.preauth_token - # Gets an "onboarding ID" that is used during login async def getOnboardingId(self, scraper): headers = self.generateHeaders(self.preauth_token) @@ -112,19 +127,22 @@ async def getOnboardingId(self, scraper): } } """ - variables = {"locale": self.locale, "countryCode": self.region, "appsFlyerId": randomFlyerId()} + variables = { + "locale": self.locale, + "countryCode": self.region, + "appsFlyerId": randomFlyerId(), + } result = await self.post(headers, query, variables, scraper) self.prereg_id = result["userOnboardingFlow"]["id"] return self.prereg_id - async def podimoLogin(self, scraper): - await self.getPreregisterToken(scraper) - await self.getOnboardingId(scraper) + await self.getPreregisterToken(scraper) + await self.getOnboardingId(scraper) - headers = self.generateHeaders(self.preauth_token) - logging.debug(f"AuthorizationAuthorize user: {self.username}") - query = """ + headers = self.generateHeaders(self.preauth_token) + logging.debug(f"AuthorizationAuthorize user: {self.username}") + query = """ query AuthorizationAuthorize($email: String!, $password: String!, $locale: String!, $preregisterId: String) { tokenWithCredentials( email: $email @@ -136,29 +154,33 @@ async def podimoLogin(self, scraper): } } """ - variables = { - "email": self.username, - "password": self.password, - "locale": self.locale, - "preregisterId": self.prereg_id, - } - result = await self.post(headers, query, variables, scraper) - tokenWithCredentials = result["tokenWithCredentials"] - if not tokenWithCredentials: - raise ValueError("Invalid Podimo credentials, did not receive tokenWithCredentials") + variables = { + "email": self.username, + "password": self.password, + "locale": self.locale, + "preregisterId": self.prereg_id, + } + result = await self.post(headers, query, variables, scraper) + tokenWithCredentials = result["tokenWithCredentials"] + if not tokenWithCredentials: + raise ValueError( + "Invalid Podimo credentials, did not receive tokenWithCredentials" + ) - self.token = result["tokenWithCredentials"]["token"] - if self.token: - return self.token - else: - raise ValueError("Invalid Podimo credentials, did not receive token") + self.token = result["tokenWithCredentials"]["token"] + if self.token: + return self.token + else: + raise ValueError("Invalid Podimo credentials, did not receive token") async def getPodcasts(self, podcast_id, scraper): podcast = getCacheEntry(podcast_id, podcast_cache) if podcast: timestamp, _ = podcast_cache[podcast_id] podcastName = self.getPodcastName(podcast) - logging.debug(f"Got podcast '{podcastName}' ({podcast_id}) from cache ({int(timestamp-time())} seconds left)") + logging.debug( + f"Got podcast '{podcastName}' ({podcast_id}) from cache ({int(timestamp-time())} seconds left)" + ) return podcast headers = self.generateHeaders(self.token) @@ -220,7 +242,9 @@ async def getPodcasts(self, podcast_id, scraper): if offset == 0: # podcastName = result[0]['podcastName'] podcastName = self.getPodcastName(result) - logging.debug(f"Fetched podcast '{podcastName}' ({podcast_id}) directly") + logging.debug( + f"Fetched podcast '{podcastName}' ({podcast_id}) directly" + ) fullResult = result else: fullResult["episodes"] += result["episodes"] @@ -231,10 +255,9 @@ async def getPodcasts(self, podcast_id, scraper): else: logging.debug(f"Fetched {numEpisodes} episodes; no more to fetch") break - + insertIntoPodcastCache(podcast_id, fullResult) return fullResult - def getPodcastName (self, podcast): + def getPodcastName(self, podcast): return list(podcast.values())[1]["title"] - diff --git a/podimo/config.py b/podimo/config.py index b9bb6cb..e5210ab 100644 --- a/podimo/config.py +++ b/podimo/config.py @@ -17,16 +17,14 @@ # See the Licence for the specific language governing # permissions and limitations under the Licence. -import os import logging +import os + from dotenv import dotenv_values # Load variables from the `.env` file first, # and overwrite them with environment variables -config = { - **dotenv_values(".env"), - **os.environ -} +config = {**dotenv_values(".env"), **os.environ} # You can overwrite the following four values with environmental variables # - `PODIMO_HOSTNAME`: the hostname that is displayed to the user. @@ -45,10 +43,12 @@ BLOCK_LIST_FILE = str(config.get("BLOCK_LIST_FILE", "./.block-list")) # Enable extra logging in debugging mode -DEBUG = bool(str(config.get("DEBUG", None)).lower() in ['true', '1', 't', 'y', 'yes']) +DEBUG = bool(str(config.get("DEBUG", None)).lower() in ["true", "1", "t", "y", "yes"]) # Enable local credentials -LOCAL_CREDENTIALS = bool(str(config.get("LOCAL_CREDENTIALS", None)).lower() in ['true', '1', 't', 'y', 'yes']) +LOCAL_CREDENTIALS = bool( + str(config.get("LOCAL_CREDENTIALS", None)).lower() in ["true", "1", "t", "y", "yes"] +) PODIMO_EMAIL = config.get("PODIMO_EMAIL", None) PODIMO_PASSWORD = config.get("PODIMO_PASSWORD", None) @@ -57,42 +57,53 @@ GRAPHQL_URL = "https://podimo.com/graphql" # Whether login tokens should be cached on disk, or only in memory -STORE_TOKENS_ON_DISK = bool(str(config.get("STORE_TOKENS_ON_DISK", True)).lower() in ['true', '1', 't', 'y', 'yes']) +STORE_TOKENS_ON_DISK = bool( + str(config.get("STORE_TOKENS_ON_DISK", True)).lower() + in ["true", "1", "t", "y", "yes"] +) # The time that a token is stored in cache -TOKEN_CACHE_TIME = int(config.get("TOKEN_CACHE_TIME", 3600 * 24 * 5)) # seconds = 5 days by default +TOKEN_CACHE_TIME = int( + config.get("TOKEN_CACHE_TIME", 3600 * 24 * 5) +) # seconds = 5 days by default # The time that a podcast feed is stored in cache -PODCAST_CACHE_TIME = int(config.get("PODCAST_CACHE_TIME", "21600")) # Default = 3600 * 6 = 6 hours +PODCAST_CACHE_TIME = int( + config.get("PODCAST_CACHE_TIME", "21600") +) # Default = 3600 * 6 = 6 hours # The time that the content information is cached -HEAD_CACHE_TIME = int(config.get("HEAD_CACHE_TIME", 7 * 60 * 60 * 24)) # seconds = 7 days by default +HEAD_CACHE_TIME = int( + config.get("HEAD_CACHE_TIME", 7 * 60 * 60 * 24) +) # seconds = 7 days by default # Whether the feeds generated with this tool should show up in public podcast catalogues -PUBLIC_FEEDS = bool(str(config.get("PUBLIC_FEEDS", None)).lower() in ['true', '1', 't', 'y', 'yes']) +PUBLIC_FEEDS = bool( + str(config.get("PUBLIC_FEEDS", None)).lower() in ["true", "1", "t", "y", "yes"] +) LOCALES = [ - 'nl-NL', - 'de-DE', - 'da-DK', - 'es-ES', - 'en-US', - 'es-MX', - 'no-NO', - 'fi-FI', - 'en-GB' + "nl-NL", + "de-DE", + "da-DK", + "es-ES", + "en-US", + "es-MX", + "no-NO", + "fi-FI", + "en-GB", ] REGIONS = [ - ('nl', 'Nederland'), - ('de', 'Deutschland'), - ('dk', 'Danmark'), - ('es', 'España'), - ('latam', 'America latina'), - ('en', 'International'), - ('mx', 'Mexico'), - ('no', 'Norge'), - ('fi', 'Suomi'), - ('uk', 'United Kingdom') + ("nl", "Nederland"), + ("de", "Deutschland"), + ("dk", "Danmark"), + ("es", "España"), + ("latam", "America latina"), + ("en", "International"), + ("mx", "Mexico"), + ("no", "Norge"), + ("fi", "Suomi"), + ("uk", "United Kingdom"), ] # If DEBUG mode is enabled, modify the logging output @@ -103,15 +114,15 @@ logging.basicConfig( format="%(levelname)s | %(asctime)s | %(message)s", datefmt="%Y-%m-%dT%H:%M:%SZ", - level=log_level + level=log_level, ) # Load block list from file '.block-list' if it exists BLOCKED = set() if os.path.exists(BLOCK_LIST_FILE): - with open (BLOCK_LIST_FILE, 'r') as file: + with open(BLOCK_LIST_FILE, "r") as file: for line in file: line = line.strip() - if line and not line.startswith('#'): - line = line.split(' ', 1)[0] + if line and not line.startswith("#"): + line = line.split(" ", 1)[0] BLOCKED.add(line) diff --git a/podimo/utils.py b/podimo/utils.py index 9d6803b..df8ceb8 100644 --- a/podimo/utils.py +++ b/podimo/utils.py @@ -17,11 +17,12 @@ # See the Licence for the specific language governing # permissions and limitations under the Licence. +import asyncio from email.utils import parseaddr -from random import choice, randint +from functools import partial, wraps from hashlib import sha256 -import asyncio -from functools import wraps, partial +from random import choice, randint + def randomHexId(length: int): string = [] @@ -43,6 +44,7 @@ def token_key(username, password): ).hexdigest() return key + # Verify if it is actually an email address def is_correct_email_address(username): return "@" in parseaddr(username)[1] @@ -50,16 +52,17 @@ def is_correct_email_address(username): def generateHeaders(authorization, locale): headers = { - 'user-os': 'android', - 'user-agent': 'Podimo/2.45.1 build 566/Android 33', - 'user-version': '2.45.1', - 'user-locale': locale, - "user-unique-id": randomHexId(16) + "user-os": "android", + "user-agent": "Podimo/2.45.1 build 566/Android 33", + "user-version": "2.45.1", + "user-locale": locale, + "user-unique-id": randomHexId(16), } if authorization: headers["authorization"] = authorization return headers + def async_wrap(func): @wraps(func) async def run(*args, loop=None, executor=None, **kwargs): @@ -67,4 +70,5 @@ async def run(*args, loop=None, executor=None, **kwargs): loop = asyncio.get_event_loop() pfunc = partial(func, *args, **kwargs) return await loop.run_in_executor(executor, pfunc) + return run diff --git a/requirements.txt b/requirements.txt index 4b31488..ec5b6db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ werkzeug~=2.3.7 zenrows~=1.3.2 diskcache~=5.6.3 python-dotenv~=1.0.0 +Pillow~=10.4.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_artwork.py b/tests/test_artwork.py new file mode 100644 index 0000000..e4e492a --- /dev/null +++ b/tests/test_artwork.py @@ -0,0 +1,339 @@ +import asyncio +import unittest +from datetime import datetime, timezone +from io import BytesIO +from unittest.mock import AsyncMock, patch + +from lxml import etree +from PIL import Image, features + +import main + + +class ArtworkUrlTests(unittest.TestCase): + def test_supported_image_url_is_used_directly(self): + url = "https://cdn.example.com/episode.jpg" + + self.assertEqual(main.artworkUrl(url), url) + + @patch("main.cache.registerArtworkSource") + def test_supported_image_url_with_query_is_used_directly(self, register_artwork): + url = "https://cdn.example.com/episode.JPEG?token=abc" + + self.assertEqual(main.artworkUrl(url), url) + register_artwork.assert_not_called() + + @patch("main.cache.registerArtworkSource", return_value="a" * 64) + def test_webp_image_uses_local_jpeg_url(self, register_artwork): + url = "https://cdn.example.com/episode.webp" + + result = main.artworkUrl(url) + + register_artwork.assert_called_once_with(url) + self.assertEqual( + result, + f"{main.PODIMO_PROTOCOL}://{main.PODIMO_HOSTNAME}/artwork/{'a' * 64}.jpg", + ) + + @patch("main.cache.registerArtworkSource", return_value="b" * 64) + def test_extensionless_image_uses_local_jpeg_url(self, register_artwork): + result = main.artworkUrl("https://cdn.example.com/image/123") + + self.assertTrue(result.endswith(f"/{'b' * 64}.jpg")) + register_artwork.assert_called_once() + + def test_invalid_image_url_is_omitted(self): + self.assertIsNone(main.artworkUrl("file:///etc/passwd")) + self.assertIsNone(main.artworkUrl(None)) + + +class ArtworkConversionTests(unittest.TestCase): + @unittest.skipUnless(features.check("webp"), "Pillow has no WebP support") + def test_webp_is_converted_to_rgb_jpeg(self): + source = BytesIO() + Image.new("RGBA", (32, 24), (255, 0, 0, 128)).save(source, format="WEBP") + + result = main.artworkToJpeg(source.getvalue()) + + with Image.open(BytesIO(result)) as image: + self.assertEqual(image.format, "JPEG") + self.assertEqual(image.mode, "RGB") + self.assertEqual(image.size, (32, 24)) + + +class ArtworkRouteTests(unittest.IsolatedAsyncioTestCase): + async def test_unknown_artwork_returns_404(self): + client = main.app.test_client() + + response = await client.get(f"/artwork/{'c' * 64}.jpg") + + self.assertEqual(response.status_code, 404) + + @patch("main.cache.getArtwork", return_value=b"jpeg-data") + async def test_cached_artwork_is_served_as_jpeg(self, get_artwork): + client = main.app.test_client() + + response = await client.get(f"/artwork/{'d' * 64}.jpg") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_type, "image/jpeg") + self.assertEqual(await response.get_data(), b"jpeg-data") + + @patch("main.cache.insertArtwork") + @patch("main.cache.getArtworkFailure", return_value=None) + @patch( + "main.cache.getArtworkSource", return_value="https://cdn.example.com/image.webp" + ) + @patch("main.cache.getArtwork", return_value=None) + @patch("main.fetchArtwork", new_callable=AsyncMock) + async def test_uncached_artwork_is_converted_and_cached( + self, fetch_artwork, get_artwork, get_source, get_failure, insert_artwork + ): + source = BytesIO() + Image.new("RGB", (16, 16), "blue").save(source, format="PNG") + fetch_artwork.return_value = source.getvalue() + client = main.app.test_client() + + response = await client.get(f"/artwork/{'e' * 64}.jpg") + + self.assertEqual(response.status_code, 200) + result = await response.get_data() + self.assertTrue(result.startswith(b"\xff\xd8")) + insert_artwork.assert_called_once_with("e" * 64, result) + + +class AudioRouteTests(unittest.IsolatedAsyncioTestCase): + @patch("main.send_from_directory", new_callable=AsyncMock) + async def test_audio_file_is_served_from_audio_directory(self, send_file): + send_file.return_value = main.Response(b"mp3-data", mimetype="audio/mpeg") + client = main.app.test_client() + + response = await client.get("/audio/dummy.mp3") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_type, "audio/mpeg") + self.assertEqual(await response.get_data(), b"mp3-data") + send_file.assert_awaited_once_with(main.AUDIO_DIR, "dummy.mp3") + + @patch("main.send_from_directory", new_callable=AsyncMock) + async def test_unique_audio_url_serves_shared_dummy_file(self, send_file): + send_file.return_value = main.Response(b"mp3-data", mimetype="audio/mpeg") + client = main.app.test_client() + filename = main.hlsFallbackMp3Url("episode-1").rsplit("/", 1)[-1] + + response = await client.get(f"/audio/{filename}") + + self.assertEqual(response.status_code, 200) + self.assertEqual(await response.get_data(), b"mp3-data") + send_file.assert_awaited_once_with(main.AUDIO_DIR, "dummy.mp3") + + @patch("main.send_from_directory", new_callable=AsyncMock) + async def test_unknown_audio_filename_returns_404(self, send_file): + client = main.app.test_client() + + response = await client.get("/audio/not-an-enclosure.mp3") + + self.assertEqual(response.status_code, 404) + send_file.assert_not_awaited() + + +class HlsFallbackTests(unittest.TestCase): + def test_url_is_stable_and_unique_per_episode(self): + first = main.hlsFallbackMp3Url("episode-1") + + self.assertEqual(first, main.hlsFallbackMp3Url("episode-1")) + self.assertNotEqual(first, main.hlsFallbackMp3Url("episode-2")) + self.assertRegex(first, r"/audio/dummy-[0-9a-f]{64}\.mp3$") + + @patch("main.HLS_FALLBACK_MP3_PATH") + def test_size_is_read_from_file(self, fallback_path): + fallback_path.is_file.return_value = True + fallback_path.stat.return_value.st_size = 187288 + + self.assertEqual(main.hlsFallbackMp3Size(), "187288") + + @patch("main.HLS_FALLBACK_MP3_PATH") + def test_missing_file_raises_clear_error(self, fallback_path): + fallback_path.is_file.return_value = False + + with self.assertRaisesRegex(FileNotFoundError, "fallback MP3 not found"): + main.hlsFallbackMp3Size() + + +class PublicResolverTests(unittest.IsolatedAsyncioTestCase): + async def test_connector_rejects_literal_private_address(self): + connector = main.PublicConnector(resolver=main.PublicResolver()) + self.addAsyncCleanup(connector.close) + + with self.assertRaisesRegex(OSError, "non-public"): + await connector._resolve_host("127.0.0.1", 80) + + async def test_private_address_is_rejected(self): + resolver = main.PublicResolver() + resolver.resolver.resolve = AsyncMock(return_value=[{"host": "127.0.0.1"}]) + + with self.assertRaisesRegex(OSError, "non-public"): + await resolver.resolve("example.com", 443) + + async def test_public_address_is_returned(self): + resolver = main.PublicResolver() + addresses = [{"host": "93.184.216.34"}] + resolver.resolver.resolve = AsyncMock(return_value=addresses) + + self.assertEqual(await resolver.resolve("example.com", 443), addresses) + + +class FeedArtworkTests(unittest.IsolatedAsyncioTestCase): + @patch("main.PUBLIC_FEEDS", False) + @patch( + "main.urlHeadInfo", new_callable=AsyncMock, return_value=("123", "audio/mpeg") + ) + @patch("main.cache.registerArtworkSource", return_value="f" * 64) + async def test_webp_artwork_does_not_prevent_feed_generation( + self, register_artwork, url_head_info + ): + data = { + "podcast": { + "title": "Test podcast", + "description": "Description", + "images": {"coverImageUrl": "https://cdn.example.com/show.webp"}, + "language": "nl-NL", + "authorName": "Author", + }, + "episodes": [ + { + "id": "episode-1", + "title": "Episode one", + "description": "Episode description", + "publishDatetime": datetime(2026, 7, 22, tzinfo=timezone.utc), + "imageUrl": "https://cdn.example.com/episode.webp", + "audio": { + "url": "https://cdn.example.com/episode.mp3", + "duration": 60, + }, + "streamMedia": None, + "podcastName": "Test podcast", + "artist": "Author", + } + ], + } + + feed = await main.podcastsToRss("podcast-id", data, "nl-NL") + feed_text = feed.decode("utf-8") + + self.assertIn("Episode one", feed_text) + self.assertIn(f"/artwork/{'f' * 64}.jpg", feed_text) + self.assertNotIn(".webp", feed_text) + self.assertIn('false", feed_text) + self.assertIn("Example", feed_text) + self.assertIn("you@example.com", feed_text) + self.assertIn("episodic", feed_text) + self.assertIn("yes", feed_text) + self.assertIn("yes", feed_text) + url_head_info.assert_awaited_once() + + @patch("main.PUBLIC_FEEDS", True) + @patch( + "main.urlHeadInfo", new_callable=AsyncMock, return_value=("123", "audio/mpeg") + ) + async def test_public_feed_omits_block_tags(self, url_head_info): + data = { + "podcast": { + "title": "Test podcast", + "description": "Description", + "images": {"coverImageUrl": "https://cdn.example.com/show.jpg"}, + "language": "nl-nl", + "authorName": "Author", + }, + "episodes": [ + { + "id": "episode-1", + "title": "Episode one", + "description": "Episode description", + "publishDatetime": datetime(2026, 7, 22, tzinfo=timezone.utc), + "imageUrl": "https://cdn.example.com/episode.jpg", + "audio": { + "url": "https://cdn.example.com/episode.mp3", + "duration": 60, + }, + "streamMedia": None, + "podcastName": "Test podcast", + "artist": "Author", + } + ], + } + + feed = await main.podcastsToRss("podcast-id", data, "nl-NL") + feed_text = feed.decode("utf-8") + + self.assertIn("nl-NL", feed_text) + self.assertNotIn("", feed_text) + self.assertNotIn("", feed_text) + url_head_info.assert_awaited_once() + + @patch( + "main.urlHeadInfo", + new_callable=AsyncMock, + return_value=("123", "application/octet-stream"), + ) + @patch("main.hlsFallbackMp3Size", return_value="187288") + async def test_hls_uses_standard_and_alternate_enclosures( + self, fallback_size, url_head_info + ): + data = { + "podcast": { + "title": "Test podcast", + "description": "Description", + "images": {"coverImageUrl": "https://cdn.example.com/show.jpg"}, + "language": "nl-NL", + "authorName": "Author", + }, + "episodes": [ + { + "id": "episode-hls", + "title": "HLS episode", + "description": "Episode description", + "publishDatetime": datetime(2026, 7, 22, tzinfo=timezone.utc), + "imageUrl": "https://cdn.example.com/episode.jpg", + "audio": None, + "streamMedia": { + "url": "https://cdn.example.com/main.m3u8?token=abc", + "duration": 60, + }, + "podcastName": "Test podcast", + "artist": "Author", + } + ], + } + + feed = await main.podcastsToRss("podcast-id", data, "nl-NL") + feed_xml = etree.fromstring(feed) + item = feed_xml.find("./channel/item") + + enclosure = item.find("enclosure") + self.assertIsNotNone(enclosure) + self.assertEqual( + enclosure.get("url"), + main.hlsFallbackMp3Url("episode-hls"), + ) + self.assertEqual(enclosure.get("length"), "187288") + self.assertEqual(enclosure.get("type"), "audio/mpeg") + alternate = item.find(f"{{{main.PODCAST_NAMESPACE}}}alternateEnclosure") + self.assertIsNotNone(alternate) + self.assertEqual(alternate.get("type"), "application/x-mpegURL") + source = alternate.find(f"{{{main.PODCAST_NAMESPACE}}}source") + self.assertEqual( + source.get("uri"), "https://cdn.example.com/main.m3u8?token=abc" + ) + fallback_size.assert_called_once_with() + url_head_info.assert_awaited_once() + + +if __name__ == "__main__": + unittest.main()