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:
+85
-2
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user