diff --git a/CHANGELOG.md b/CHANGELOG.md index 995aa41..12fea09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to coverpy are documented here. +## [1.1.0] - 2026-07-25 + +### Added + +- Experimental Apple Music motion artwork lookup for albums and search results. +- Square and tall HLS URLs plus browser-friendly standard and high-quality direct MP4 URLs. +- A `--motion` CLI option for human-readable and JSON output. + +### Changed + +- Prefer H.264 motion artwork variants over HEVC for wider browser compatibility. + ## [1.0.0] - 2026-07-25 ### Added diff --git a/README.md b/README.md index 6484085..0df6e65 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ through Apple's public iTunes Search API. It has no API key requirement. - Python 3.10+ with complete type information - Album, song, artist, music video, and mix searches - ID and UPC lookups +- Experimental Apple Music motion artwork with HLS and direct MP4 URLs - Rich result metadata, including release dates, prices, genres, explicitness, duration, streamability, previews, and Store URLs - Configurable storefront, timeout, language, explicit-content filtering, and artwork size @@ -88,6 +89,39 @@ with CoverPy() as client: `search()` and the lookup methods return an empty list when there are no matches. `get_cover()` keeps the original convenience behavior and raises `NoResultsError`. +### Motion artwork + +Resolve the animated square and tall artwork Apple Music provides for some albums: + +```python +from coverpy import CoverPy + +with CoverPy() as client: + album = client.get_cover("Kyoto Phoebe Bridgers") + motion = client.get_motion_artwork(album) + +if motion: + print(motion.video_url) # Browser-friendly standard MP4 + print(motion.hq_video_url) # Highest H.264 MP4 available + print(motion.hls_url) # Original square HLS playlist + print(motion.tall_hls_url) # Optional 3:4 HLS playlist +``` + +`get_motion_artwork()` also accepts an Apple Music album ID. It returns `None` when the album +has no motion artwork. The feature prefers H.264 variants for broad browser support and can +be used from the CLI: + +```console +uvx coverpy "Kyoto Phoebe Bridgers" --motion +uvx coverpy "Kyoto Phoebe Bridgers" --motion --json +``` + +Motion artwork is experimental. Apple presents animated artwork in Apple Music, but its +`editorialVideo` catalog field is not part of the documented public API. CoverPy discovers +the same short-lived web token used by the Apple Music web player, so Apple can change or +remove this endpoint without notice. Cache successful responses and retain static artwork +as a fallback. + ### Compatibility The original names still work: `CoverPy`, `Result`, `NoResultsException`, `result.artist`, @@ -134,8 +168,10 @@ publishes them to PyPI. ## API and artwork terms Apple documents the iTunes Search API as rate-limited and recommends caching for heavier -usage. Artwork and previews are promotional content governed by Apple's terms. Review the +usage. Artwork, previews, and motion artwork are promotional content governed by Apple's +terms. Review the [iTunes Search API overview](https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/) +and [Apple Music animated artwork guidance](https://help.apple.com/itc/albummotionguide/) before shipping them in a product. ## License diff --git a/pyproject.toml b/pyproject.toml index 14a2979..c14aa71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "coverpy" -version = "1.0.0" +version = "1.1.0" description = "A modern Python client for music artwork from the iTunes Search API" readme = "README.md" requires-python = ">=3.10" license = "MIT" license-files = ["LICENSE"] authors = [{ name = "Sergio Diaz" }] -keywords = ["album-art", "artwork", "itunes", "music", "search"] +keywords = ["album-art", "artwork", "itunes", "motion-artwork", "music", "search"] classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", diff --git a/src/coverpy/__init__.py b/src/coverpy/__init__.py index 6bfa6a9..79d1b2d 100644 --- a/src/coverpy/__init__.py +++ b/src/coverpy/__init__.py @@ -7,10 +7,12 @@ ArtworkUnavailableError, CoverPyError, InvalidResponseError, + MotionArtworkError, NoResultsError, NoResultsException, ) from .models import Result +from .motion import MotionArtwork try: __version__ = version("coverpy") @@ -24,6 +26,8 @@ "CoverPyError", "Entity", "InvalidResponseError", + "MotionArtwork", + "MotionArtworkError", "NoResultsError", "NoResultsException", "Result", diff --git a/src/coverpy/cli.py b/src/coverpy/cli.py index 7af67da..a373f54 100644 --- a/src/coverpy/cli.py +++ b/src/coverpy/cli.py @@ -13,6 +13,7 @@ from .client import CoverPy, Entity from .exceptions import ArtworkUnavailableError, CoverPyError from .models import Result +from .motion import MotionArtwork def build_parser() -> argparse.ArgumentParser: @@ -26,12 +27,15 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--country", default="US", help="two-letter iTunes Store country code") parser.add_argument("--size", type=int, default=1200, help="square artwork size in pixels") parser.add_argument("--no-explicit", action="store_true", help="exclude explicit content") + parser.add_argument( + "--motion", action="store_true", help="resolve experimental Apple Music motion artwork" + ) parser.add_argument("--json", action="store_true", help="emit normalized JSON") parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") return parser -def _format_result(result: Result, size: int) -> str: +def _format_result(result: Result, size: int, motion: MotionArtwork | None = None) -> str: try: artwork = result.artwork(size) except ArtworkUnavailableError: @@ -44,6 +48,10 @@ def _format_result(result: Result, size: int) -> str: lines.append(f"Released: {result.release_date.date().isoformat()}") if result.primary_genre_name: lines.append(f"Genre: {result.primary_genre_name}") + if motion: + lines.append(f"Motion: {motion.video_url or motion.hls_url}") + if motion.hq_video_url and motion.hq_video_url != motion.video_url: + lines.append(f"Motion HQ: {motion.hq_video_url}") return "\n".join(lines) @@ -59,13 +67,29 @@ def main(argv: Sequence[str] | None = None) -> int: entity=args.entity, explicit=False if args.no_explicit else None, ) + motion_artwork = ( + [client.get_motion_artwork(result) for result in results] + if args.motion + else [None] * len(results) + ) if not results: print(f"No results found for {args.term!r}.", file=sys.stderr) return 1 if args.json: - print(json.dumps([result.as_dict() for result in results], indent=2)) + payload = [] + for result, motion in zip(results, motion_artwork, strict=True): + item = result.as_dict() + if args.motion: + item["motion_artwork"] = motion.as_dict() if motion else None + payload.append(item) + print(json.dumps(payload, indent=2)) else: - print("\n\n".join(_format_result(result, args.size) for result in results)) + print( + "\n\n".join( + _format_result(result, args.size, motion) + for result, motion in zip(results, motion_artwork, strict=True) + ) + ) except (CoverPyError, requests.RequestException, TypeError, ValueError) as error: print(f"coverpy: {error}", file=sys.stderr) return 2 diff --git a/src/coverpy/client.py b/src/coverpy/client.py index ce9c47c..a5baa4b 100644 --- a/src/coverpy/client.py +++ b/src/coverpy/client.py @@ -10,6 +10,7 @@ from .exceptions import InvalidResponseError, NoResultsError from .models import Result +from .motion import MotionArtwork, MotionArtworkResolver DEFAULT_BASE_URL = "https://itunes.apple.com" DEFAULT_USER_AGENT = "coverpy (+https://github.com/matteing/coverpy)" @@ -45,6 +46,7 @@ def __init__( self._session = session or requests.Session() self._owns_session = session is None self._session.headers["User-Agent"] = DEFAULT_USER_AGENT + self._motion_artwork = MotionArtworkResolver(self._session, self.timeout) def __enter__(self) -> CoverPy: return self @@ -160,6 +162,22 @@ def lookup_upc( params["entity"] = self._validate_entity(entity) return self._request("lookup", params) + def get_motion_artwork( + self, + album: Result | int | str, + *, + storefront: str | None = None, + ) -> MotionArtwork | None: + """Return experimental Apple Music motion artwork for an album.""" + if isinstance(album, Result): + if album.collection_id is None: + raise ValueError("result does not include a collection ID") + album_id = album.collection_id + else: + album_id = int(self._validate_identifier(album)) + country = self._validate_country(storefront or self.country).lower() + return self._motion_artwork.get(album_id, country) + def _request(self, endpoint: str, params: Mapping[str, str | int]) -> list[Result]: response = self._session.get( f"{self.base_url}/{endpoint}", params=dict(params), timeout=self.timeout diff --git a/src/coverpy/exceptions.py b/src/coverpy/exceptions.py index f896dcd..4aa392e 100644 --- a/src/coverpy/exceptions.py +++ b/src/coverpy/exceptions.py @@ -17,5 +17,9 @@ class ArtworkUnavailableError(CoverPyError): """Raised when a result does not include artwork.""" +class MotionArtworkError(CoverPyError): + """Raised when Apple Music motion artwork cannot be resolved.""" + + # Kept for users of the original 2016 API. NoResultsException = NoResultsError diff --git a/src/coverpy/motion.py b/src/coverpy/motion.py new file mode 100644 index 0000000..3a5c74d --- /dev/null +++ b/src/coverpy/motion.py @@ -0,0 +1,250 @@ +"""Experimental Apple Music motion artwork support.""" + +from __future__ import annotations + +import base64 +import json +import re +import time +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any +from urllib.parse import urljoin + +import requests + +from .exceptions import MotionArtworkError + +_APPLE_MUSIC_ORIGIN = "https://music.apple.com" +_AMP_API_BASE_URL = "https://amp-api.music.apple.com/v1/catalog" +_TOKEN_BOOTSTRAP_URL = f"{_APPLE_MUSIC_ORIGIN}/us/album/1693323844" +_SCRIPT_PATTERN = re.compile(r"src=[\"'](?P/assets/index[^\"']+\.js)[\"']") +_TOKEN_PATTERN = re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b") +_ATTRIBUTE_PATTERN = re.compile(r'([A-Z0-9-]+)=(?:"([^"]*)"|([^,]*))(?:,|$)') +_MAP_URI_PATTERN = re.compile(r'URI="([^"]+)"') + + +@dataclass(frozen=True, slots=True) +class MotionArtwork: + """Motion artwork URLs for an Apple Music album.""" + + album_id: int + hls_url: str + video_url: str | None = None + hq_video_url: str | None = None + tall_hls_url: str | None = None + + def as_dict(self) -> dict[str, int | str | None]: + """Return motion artwork data suitable for JSON serialization.""" + return { + "album_id": self.album_id, + "hls_url": self.hls_url, + "video_url": self.video_url, + "hq_video_url": self.hq_video_url, + "tall_hls_url": self.tall_hls_url, + } + + +@dataclass(frozen=True, slots=True) +class _HLSVariant: + playlist_url: str + bandwidth: int + width: int | None + codecs: str + + +class MotionArtworkResolver: + """Resolve Apple's undocumented editorial video metadata and HLS streams.""" + + def __init__(self, session: requests.Session, timeout: float) -> None: + self._session = session + self._timeout = timeout + self._web_token: str | None = None + self._web_token_expiry = 0.0 + + def get(self, album_id: int, storefront: str) -> MotionArtwork | None: + """Return motion artwork for an album when Apple provides it.""" + response = self._session.get( + f"{_AMP_API_BASE_URL}/{storefront}/albums/{album_id}", + params={"extend": "editorialVideo"}, + headers={ + "Authorization": f"Bearer {self._get_web_token()}", + "Origin": _APPLE_MUSIC_ORIGIN, + }, + timeout=self._timeout, + ) + response.raise_for_status() + payload = self._json_object(response, "Apple Music returned invalid album JSON") + data = payload.get("data") + if not isinstance(data, list): + raise MotionArtworkError("Apple Music response is missing album data") + if not data: + return None + + album = data[0] + if not isinstance(album, Mapping): + raise MotionArtworkError("Apple Music returned invalid album data") + attributes = album.get("attributes") + if not isinstance(attributes, Mapping): + raise MotionArtworkError("Apple Music response is missing album attributes") + editorial_video = attributes.get("editorialVideo") + if editorial_video is None: + return None + if not isinstance(editorial_video, Mapping): + raise MotionArtworkError("Apple Music returned invalid motion artwork data") + + hls_url = self._video_url(editorial_video, "motionSquareVideo1x1", "motionDetailSquare") + if hls_url is None: + return None + tall_hls_url = self._video_url(editorial_video, "motionTallVideo3x4", "motionDetailTall") + video_url, hq_video_url = self._resolve_hls(hls_url) + return MotionArtwork( + album_id=album_id, + hls_url=hls_url, + video_url=video_url, + hq_video_url=hq_video_url, + tall_hls_url=tall_hls_url, + ) + + def _get_web_token(self) -> str: + if self._web_token is not None and time.time() < self._web_token_expiry - 60: + return self._web_token + + page_response = self._session.get(_TOKEN_BOOTSTRAP_URL, timeout=self._timeout) + page_response.raise_for_status() + script_match = _SCRIPT_PATTERN.search(page_response.text) + if script_match is None: + raise MotionArtworkError("could not find the Apple Music web bundle") + + script_response = self._session.get( + urljoin(_APPLE_MUSIC_ORIGIN, script_match.group("path")), timeout=self._timeout + ) + script_response.raise_for_status() + for match in _TOKEN_PATTERN.finditer(script_response.text): + token = match.group(0) + payload = self._decode_token_payload(token) + if payload.get("iss") != "AMPWebPlay": + continue + expiry = payload.get("exp") + self._web_token = token + self._web_token_expiry = float(expiry) if isinstance(expiry, (int, float)) else 0.0 + return token + raise MotionArtworkError("could not extract the Apple Music web token") + + def _resolve_hls(self, hls_url: str) -> tuple[str | None, str | None]: + response = self._session.get(hls_url, timeout=self._timeout) + response.raise_for_status() + variants = self._parse_master_playlist(hls_url, response.text) + if not variants: + video_url = self._direct_video_url(hls_url, response.text) + return video_url, video_url + + compatible = [variant for variant in variants if "avc1" in variant.codecs.lower()] + candidates = compatible or variants + standard = self._standard_variant(candidates) + high_quality = max(candidates, key=lambda variant: (variant.width or 0, variant.bandwidth)) + video_url = self._fetch_direct_video_url(standard.playlist_url) + if high_quality == standard: + return video_url, video_url + hq_video_url = self._fetch_direct_video_url(high_quality.playlist_url) + return video_url, hq_video_url or video_url + + def _fetch_direct_video_url(self, playlist_url: str) -> str | None: + response = self._session.get(playlist_url, timeout=self._timeout) + response.raise_for_status() + return self._direct_video_url(playlist_url, response.text) + + @staticmethod + def _parse_master_playlist(hls_url: str, text: str) -> list[_HLSVariant]: + lines = [line.strip() for line in text.splitlines()] + variants: list[_HLSVariant] = [] + for index, line in enumerate(lines): + if not line.startswith("#EXT-X-STREAM-INF:"): + continue + attributes = { + match.group(1): match.group(2) or match.group(3) + for match in _ATTRIBUTE_PATTERN.finditer(line.partition(":")[2]) + } + playlist_url = next( + ( + candidate + for candidate in lines[index + 1 :] + if candidate and not candidate.startswith("#") + ), + None, + ) + if playlist_url is None: + continue + resolution = attributes.get("RESOLUTION", "") + width_text = resolution.partition("x")[0] + width = int(width_text) if width_text.isdigit() else None + bandwidth_text = attributes.get("AVERAGE-BANDWIDTH") or attributes.get("BANDWIDTH", "0") + bandwidth = int(bandwidth_text) if bandwidth_text.isdigit() else 0 + variants.append( + _HLSVariant( + playlist_url=urljoin(hls_url, playlist_url), + bandwidth=bandwidth, + width=width, + codecs=attributes.get("CODECS", ""), + ) + ) + return variants + + @staticmethod + def _standard_variant(variants: list[_HLSVariant]) -> _HLSVariant: + preferred = [ + variant + for variant in variants + if variant.width is not None and 480 <= variant.width <= 720 + ] + if preferred: + return min( + preferred, + key=lambda variant: (abs((variant.width or 600) - 600), variant.bandwidth), + ) + ordered = sorted(variants, key=lambda variant: variant.bandwidth) + return ordered[len(ordered) // 3] if len(ordered) >= 3 else ordered[0] + + @staticmethod + def _direct_video_url(playlist_url: str, text: str) -> str | None: + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("#EXT-X-MAP:"): + match = _MAP_URI_PATTERN.search(stripped) + if match is not None: + return str(urljoin(playlist_url, match.group(1))) + for line in text.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#") and ".mp4" in stripped: + return urljoin(playlist_url, stripped) + return None + + @staticmethod + def _video_url(editorial_video: Mapping[Any, Any], *keys: str) -> str | None: + for key in keys: + value = editorial_video.get(key) + if isinstance(value, Mapping): + video = value.get("video") + if isinstance(video, str) and video: + return video + return None + + @staticmethod + def _decode_token_payload(token: str) -> Mapping[str, Any]: + try: + encoded = token.split(".")[1] + padding = "=" * (-len(encoded) % 4) + payload = json.loads(base64.urlsafe_b64decode(encoded + padding)) + except (IndexError, ValueError, json.JSONDecodeError): + return {} + return payload if isinstance(payload, Mapping) else {} + + @staticmethod + def _json_object(response: requests.Response, message: str) -> Mapping[str, Any]: + try: + payload: Any = response.json() + except requests.exceptions.JSONDecodeError as error: + raise MotionArtworkError(message) from error + if not isinstance(payload, Mapping): + raise MotionArtworkError(message) + return payload diff --git a/tests/test_cli.py b/tests/test_cli.py index 8b2c84d..ba2318a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,6 +5,7 @@ import responses +from coverpy import MotionArtwork from coverpy.cli import main SEARCH_URL = "https://itunes.apple.com/search" @@ -51,3 +52,46 @@ def test_cli_handles_validation_errors(capsys: Any) -> None: assert exit_code == 2 assert "limit" in capsys.readouterr().err + + +@responses.activate +def test_cli_prints_motion_artwork( + album_item: dict[str, Any], capsys: Any, monkeypatch: Any +) -> None: + responses.get(SEARCH_URL, json={"resultCount": 1, "results": [album_item]}) + motion = MotionArtwork( + album_id=1097861387, + hls_url="https://video.example/master.m3u8", + video_url="https://video.example/cover.mp4", + hq_video_url="https://video.example/cover-hq.mp4", + ) + monkeypatch.setattr("coverpy.cli.CoverPy.get_motion_artwork", lambda *_: motion) + + exit_code = main(["OK Computer", "--motion", "--json"]) + + captured = capsys.readouterr() + payload = json.loads(captured.out) + assert exit_code == 0 + assert payload[0]["motion_artwork"]["video_url"] == "https://video.example/cover.mp4" + assert captured.err == "" + + +@responses.activate +def test_cli_prints_human_motion_artwork( + album_item: dict[str, Any], capsys: Any, monkeypatch: Any +) -> None: + responses.get(SEARCH_URL, json={"resultCount": 1, "results": [album_item]}) + motion = MotionArtwork( + album_id=1097861387, + hls_url="https://video.example/master.m3u8", + video_url="https://video.example/cover.mp4", + hq_video_url="https://video.example/cover-hq.mp4", + ) + monkeypatch.setattr("coverpy.cli.CoverPy.get_motion_artwork", lambda *_: motion) + + exit_code = main(["OK Computer", "--motion"]) + + output = capsys.readouterr().out + assert exit_code == 0 + assert "Motion: https://video.example/cover.mp4" in output + assert "Motion HQ: https://video.example/cover-hq.mp4" in output diff --git a/tests/test_motion.py b/tests/test_motion.py new file mode 100644 index 0000000..43f3e5a --- /dev/null +++ b/tests/test_motion.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import base64 +import json +from typing import Any + +import pytest +import requests +import responses +from responses import matchers + +from coverpy import CoverPy, MotionArtworkError, Result +from coverpy.motion import MotionArtworkResolver + +TOKEN_PAGE_URL = "https://music.apple.com/us/album/1693323844" +SCRIPT_URL = "https://music.apple.com/assets/index~test.js" +ALBUM_URL = "https://amp-api.music.apple.com/v1/catalog/us/albums/1097861387" +HLS_URL = "https://video.example/master.m3u8" +TALL_HLS_URL = "https://video.example/tall.m3u8" + + +def _encode(value: dict[str, Any]) -> str: + return base64.urlsafe_b64encode(json.dumps(value).encode()).decode().rstrip("=") + + +def _token(issuer: str = "AMPWebPlay", *, expiry: Any = 4_000_000_000) -> str: + return f"{_encode({'alg': 'none'})}.{_encode({'iss': issuer, 'exp': expiry})}.signature" + + +def _register_token(*, script: str | None = None) -> str: + token = _token() + responses.get(TOKEN_PAGE_URL, body='') + responses.get(SCRIPT_URL, body=script or f"{_token('SomethingElse')} {token}") + return token + + +def _register_album(editorial_video: Any) -> None: + responses.get( + ALBUM_URL, + json={"data": [{"attributes": {"editorialVideo": editorial_video}}]}, + match=[matchers.query_param_matcher({"extend": "editorialVideo"})], + ) + + +def _register_hls() -> None: + responses.get( + HLS_URL, + body="""#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=400000,RESOLUTION=360x360,CODECS="avc1.4d401e" +low/playlist.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=640x640,CODECS="avc1.4d401f,mp4a.40.2" +standard/playlist.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=2160x2160,CODECS="hvc1.2.4.L153.B0" +hevc/playlist.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=5000000,RESOLUTION=1920x1920,CODECS="avc1.640033" +hq/playlist.m3u8 +""", + ) + responses.get( + "https://video.example/standard/playlist.m3u8", + body='#EXTM3U\n#EXT-X-MAP:URI="cover-standard.mp4",BYTERANGE="897@0"', + ) + responses.get( + "https://video.example/hq/playlist.m3u8", + body="#EXTM3U\n#EXTINF:10,\n../files/cover-hq.mp4", + ) + + +@responses.activate +def test_get_motion_artwork_resolves_browser_compatible_videos() -> None: + token = _register_token() + _register_album( + { + "motionSquareVideo1x1": {"video": HLS_URL}, + "motionTallVideo3x4": {"video": TALL_HLS_URL}, + } + ) + _register_hls() + + with CoverPy() as client: + artwork = client.get_motion_artwork(Result(collection_id=1097861387)) + + assert artwork is not None + assert artwork.album_id == 1097861387 + assert artwork.hls_url == HLS_URL + assert artwork.tall_hls_url == TALL_HLS_URL + assert artwork.video_url == "https://video.example/standard/cover-standard.mp4" + assert artwork.hq_video_url == "https://video.example/files/cover-hq.mp4" + assert artwork.as_dict()["album_id"] == 1097861387 + album_request = next( + call.request for call in responses.calls if (call.request.url or "").startswith(ALBUM_URL) + ) + assert album_request.headers["Authorization"] == f"Bearer {token}" + assert album_request.headers["Origin"] == "https://music.apple.com" + assert not any("hevc/playlist.m3u8" in (call.request.url or "") for call in responses.calls) + + +@responses.activate +def test_get_motion_artwork_caches_web_token_and_uses_storefront() -> None: + _register_token() + gb_url = "https://amp-api.music.apple.com/v1/catalog/gb/albums/1097861387" + responses.get( + gb_url, + json={"data": [{"attributes": {"editorialVideo": None}}]}, + match=[matchers.query_param_matcher({"extend": "editorialVideo"})], + ) + + client = CoverPy() + assert client.get_motion_artwork("1097861387", storefront="GB") is None + assert client.get_motion_artwork(1097861387, storefront="gb") is None + + assert sum(call.request.url == TOKEN_PAGE_URL for call in responses.calls) == 1 + + +@responses.activate +def test_get_motion_artwork_supports_fallback_fields_and_media_playlist() -> None: + _register_token() + _register_album( + { + "motionDetailSquare": {"video": HLS_URL}, + "motionDetailTall": {"video": TALL_HLS_URL}, + } + ) + responses.get(HLS_URL, body='#EXTM3U\n#EXT-X-MAP:URI="single.mp4"') + + artwork = CoverPy().get_motion_artwork(1097861387) + + assert artwork is not None + assert artwork.video_url == "https://video.example/single.mp4" + assert artwork.hq_video_url == artwork.video_url + assert artwork.tall_hls_url == TALL_HLS_URL + + +@responses.activate +@pytest.mark.parametrize( + "editorial_video", + [None, {}, {"motionSquareVideo1x1": {}}, {"motionSquareVideo1x1": {"video": ""}}], +) +def test_get_motion_artwork_returns_none_when_unavailable(editorial_video: Any) -> None: + _register_token() + _register_album(editorial_video) + + assert CoverPy().get_motion_artwork(1097861387) is None + + +@pytest.mark.parametrize("album", [Result(), 0, True, "not-an-id"]) +def test_get_motion_artwork_validates_album(album: Any) -> None: + with pytest.raises(ValueError, match=r"collection ID|identifier"): + CoverPy().get_motion_artwork(album) + + +@responses.activate +@pytest.mark.parametrize( + ("page", "script", "message"), + [ + ("no script", "", "web bundle"), + ('', "no token", "web token"), + ('', _token("SomethingElse"), "web token"), + ], +) +def test_get_motion_artwork_rejects_missing_web_credentials( + page: str, script: str, message: str +) -> None: + responses.get(TOKEN_PAGE_URL, body=page) + if "index" in page: + responses.get(SCRIPT_URL, body=script) + + with pytest.raises(MotionArtworkError, match=message): + CoverPy().get_motion_artwork(1097861387) + + +@responses.activate +@pytest.mark.parametrize( + ("payload", "message"), + [ + ("not-json", "invalid album JSON"), + ({}, "missing album data"), + ({"data": ["bad"]}, "invalid album data"), + ({"data": [{}]}, "missing album attributes"), + ( + {"data": [{"attributes": {"editorialVideo": "bad"}}]}, + "invalid motion artwork data", + ), + ], +) +def test_get_motion_artwork_rejects_invalid_album_responses(payload: Any, message: str) -> None: + _register_token() + if isinstance(payload, str): + responses.get(ALBUM_URL, body=payload, content_type="application/json") + else: + responses.get(ALBUM_URL, json=payload) + + with pytest.raises(MotionArtworkError, match=message): + CoverPy().get_motion_artwork(1097861387) + + +@responses.activate +def test_get_motion_artwork_returns_none_for_empty_catalog_data() -> None: + _register_token() + responses.get(ALBUM_URL, json={"data": []}) + + assert CoverPy().get_motion_artwork(1097861387) is None + + +@responses.activate +def test_motion_resolver_uses_non_h264_and_low_quality_fallbacks() -> None: + _register_token() + _register_album({"motionSquareVideo1x1": {"video": HLS_URL}}) + responses.get( + HLS_URL, + body="""#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=100,RESOLUTION=100x100,CODECS="hvc1" +low.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=200,RESOLUTION=200x200,CODECS="hvc1" +middle.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=300,RESOLUTION=300x300,CODECS="hvc1" +high.m3u8 +""", + ) + responses.get("https://video.example/middle.m3u8", body="#EXTM3U") + responses.get("https://video.example/high.m3u8", body='#EXT-X-MAP:URI="high.mp4"') + + artwork = CoverPy().get_motion_artwork(1097861387) + + assert artwork is not None + assert artwork.video_url is None + assert artwork.hq_video_url == "https://video.example/high.mp4" + + +def test_motion_helpers_handle_expiry_and_invalid_playlist_data() -> None: + session = requests.Session() + resolver = MotionArtworkResolver(session, 1) + invalid_expiry_token = _token(expiry="later") + assert resolver._decode_token_payload(invalid_expiry_token)["iss"] == "AMPWebPlay" + assert resolver._decode_token_payload("eyJbad.bad.bad") == {} + assert resolver._parse_master_playlist(HLS_URL, "#EXT-X-STREAM-INF:BANDWIDTH=nope") == [] + assert resolver._direct_video_url(HLS_URL, "#EXTM3U\nsegment.ts") is None + session.close() diff --git a/uv.lock b/uv.lock index cd5154a..8c814bd 100644 --- a/uv.lock +++ b/uv.lock @@ -257,7 +257,7 @@ toml = [ [[package]] name = "coverpy" -version = "1.0.0" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "requests" },