Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/coverpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
ArtworkUnavailableError,
CoverPyError,
InvalidResponseError,
MotionArtworkError,
NoResultsError,
NoResultsException,
)
from .models import Result
from .motion import MotionArtwork

try:
__version__ = version("coverpy")
Expand All @@ -24,6 +26,8 @@
"CoverPyError",
"Entity",
"InvalidResponseError",
"MotionArtwork",
"MotionArtworkError",
"NoResultsError",
"NoResultsException",
"Result",
Expand Down
30 changes: 27 additions & 3 deletions src/coverpy/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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)


Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/coverpy/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/coverpy/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading