v0.5.0: send album art to the Chromecast

The device is on a TV, so it should show the cover. play_media now carries
thumb=, which pychromecast folds into metadata["images"] — the field the
receiver paints full-screen. Verified against the real device: it fetches
both the audio and the artwork URL from us on every track change.

Album art lives in the audio file's tags rather than as a file of its own,
so TrackServer tokens now resolve to an _Asset that is either a path or a
blob held in memory. Audio and art get separate eviction rings so a cover
can't push out the previous track's audio while the device is still
fetching it; Range and HEAD work on both.

The image type is sniffed from the cover's magic bytes rather than trusted
from the tag — ID3 APIC mimes are routinely wrong or blank, and the
receiver silently drops an image whose declared type doesn't match its
content. Anything unrecognized is treated as "no cover".

Best-effort throughout: no art, junk where the art should be, or an
unreadable file all just play without a cover rather than failing the
load. Also sends albumArtist and trackNumber in the metadata.

tests/test_round29.py: 74 tests; 447 pass overall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 22:06:27 -04:00
co-authored by Claude Opus 5
parent 087c4bf103
commit 6c746c3330
7 changed files with 217 additions and 33 deletions
+85 -2
View File
@@ -18,7 +18,7 @@ from unittest.mock import MagicMock, patch
import pytest
from PyQt6.QtCore import QObject, pyqtSignal
from lintunes import player as player_module
from lintunes import player as player_module, tagging
from lintunes.cast.discovery import CastDevice
from lintunes.cast.server import KEEP_TOKENS, TrackServer
from lintunes.cast.sink import (
@@ -26,7 +26,8 @@ from lintunes.cast.sink import (
connection_verdict, duration_ms_from, is_load_error, is_natural_end,
is_playing_state, position_ms_from)
from lintunes.cast.support import (
content_type_for, local_ip_for, parse_range, uncastable_reason)
content_type_for, image_type_for, local_ip_for, parse_range,
uncastable_reason)
from lintunes.gui.cast_dialog import ChromecastDialog
from lintunes.gui.cast_indicator import CastIndicator
from lintunes.gui.icons import transport_icon
@@ -72,6 +73,22 @@ class TestContentType:
assert "WMA" in uncastable_reason("/m/a.wma")
class TestImageType:
def test_sniffs_the_common_cover_formats(self):
assert image_type_for(b"\xff\xd8\xff\xe0rest") == "image/jpeg"
assert image_type_for(b"\x89PNG\r\n\x1a\nrest") == "image/png"
assert image_type_for(b"GIF89a...") == "image/gif"
assert image_type_for(b"RIFF\x00\x00\x00\x00WEBPVP8 ") == "image/webp"
def test_unrecognized_bytes_are_refused(self):
# Better no cover than a cover the receiver silently drops: the type
# is sniffed rather than trusted, because ID3 APIC mimes are often
# wrong or blank.
assert image_type_for(b"") is None
assert image_type_for(b"not an image at all") is None
assert image_type_for(b"\xff\xd8") is None # truncated JPEG magic
# ---- byte ranges ----
class TestParseRange:
@@ -268,6 +285,41 @@ class TestTrackServer:
conn.request("GET", "/t/anything")
conn.getresponse()
def test_serves_album_art_from_memory(self, served):
# Cover art lives in the audio file's tags, not as a file of its own.
art = b"\x89PNG\r\n\x1a\n" + bytes(range(256)) * 4
token = served.publish_bytes(art, "image/png")
status, headers, body = _request(served, f"/t/{token}")
assert status == 200
assert body == art
assert headers["Content-Type"] == "image/png"
assert headers["Content-Length"] == str(len(art))
def test_range_and_head_work_on_art_too(self, served):
art = b"\x89PNG\r\n\x1a\n" + bytes(range(256))
token = served.publish_bytes(art, "image/png")
status, _, body = _request(
served, f"/t/{token}", headers={"Range": "bytes=8-15"})
assert status == 206
assert body == art[8:16]
status, headers, body = _request(served, f"/t/{token}", method="HEAD")
assert status == 200 and body == b""
assert headers["Content-Length"] == str(len(art))
def test_art_and_audio_evict_separately(self, served, tmp_path):
# A track's cover must not push the previous track's audio out from
# under a device that is still fetching it.
audio = served.publish(_audio(tmp_path), "audio/mpeg")
for i in range(KEEP_TOKENS + 2):
served.publish_bytes(b"\x89PNG\r\n\x1a\n" + bytes([i]), "image/png")
status, _, _ = _request(served, f"/t/{audio}")
assert status == 200
def test_tokens_do_not_survive_a_restart(self, tmp_path):
server = TrackServer()
server.start()
@@ -745,6 +797,37 @@ class TestCastSinkFailure:
assert cast.quit_calls == 1
assert cast.disconnects == 1
def test_embedded_cover_is_published_for_the_device(
self, cast_sink, monkeypatch):
sink, _cast = cast_sink
art = b"\xff\xd8\xff\xe0" + b"jpegbody"
monkeypatch.setattr(tagging, "read_embedded_artwork", lambda _p: art)
token = sink._publish_artwork(
Track(track_id=1, name="A", location="/m/a.mp3"))
assert token is not None
asset = sink._server.lookup(token)
assert asset.data == art
assert asset.content_type == "image/jpeg"
def test_a_track_without_a_cover_still_plays(self, cast_sink, monkeypatch):
sink, _cast = cast_sink
track = Track(track_id=1, name="A", location="/m/a.mp3")
monkeypatch.setattr(tagging, "read_embedded_artwork", lambda _p: None)
assert sink._publish_artwork(track) is None
# Unreadable tags, or junk where the cover should be, are equally
# never worth failing the load over.
monkeypatch.setattr(tagging, "read_embedded_artwork", lambda _p: b"junk")
assert sink._publish_artwork(track) is None
def boom(_p):
raise OSError("drive went away")
monkeypatch.setattr(tagging, "read_embedded_artwork", boom)
assert sink._publish_artwork(track) is None
def test_uncastable_track_is_refused_by_the_sink(self, cast_sink):
sink, _cast = cast_sink
alac = Track(track_id=1, name="Lossless", location="/m/a.m4a",