fix(apple): open cold streams before media preparation

This commit is contained in:
joshpatra committed 2026-09-22 12:33:43 -04:00
1 parent d58a3a1897
commit b2d95046c7
4 files changed
+86 -13

No files matched your search

@@ -117,10 +117,12 @@ release is available.
## Apple Music cold playback
For an uncached `apple-download` track, Allstarr opens the compatible sidecar's
`api/stream/{id}` response with `ResponseHeadersRead`. The sidecar
streams FFmpeg's FLAC stdout after Apple fetch/decryption instead of waiting
for a second complete converted file. The gateway returns FLAC, so Allstarr
relays those exact bytes immediately while teeing the
`api/stream/{id}` response with `ResponseHeadersRead`. The sidecar first emits a
metadata-free ID3v2.4 padding tag accepted by the supported FLAC readers, then
streams FFmpeg's FLAC stdout after Apple fetch/decryption. This opens the response
for clients with short prefix deadlines without inserting audio, changing
duration, resampling, or routing to another provider. The gateway returns FLAC,
so Allstarr relays those exact bytes immediately while teeing the
same bytes to a temporary file. Metadata resolution happens concurrently and is
used only when the completed cache file is published. A partial artifact is never
registered. Completed cache files support normal byte ranges and seeks.
+2 -1
View File
@@ -17,7 +17,8 @@ both on Allstarr's private network. Do not expose the gateway or wrapper login e
- `GET /api/search?q=...&type=song|album|artist&limit=...`
- `GET /api/song/{songId}`
- `GET /api/download/{songId}?quality=...` — complete managed FLAC artifact
- `GET /api/stream/{songId}?quality=...` — progressive FLAC after Apple fetch/decryption
- `GET /api/stream/{songId}?quality=...` — progressive FLAC with an immediate,
metadata-free ID3v2 padding prelude while Apple fetch/decryption completes
- `HEAD /api/stream/{songId}?quality=...` — FLAC type/disposition only; no media preparation
- `POST /api/jobs/download` for supported Apple catalog or library URLs
- `GET /api/jobs/download/{jobId}`
@@ -39,6 +39,17 @@ CAPABILITIES = (
)
PREPARED_CACHE_TTL_SECONDS = 6 * 60 * 60
PREPARED_CACHE_MAX_TRACKS = 32
FLAC_GUIDANCE_PADDING_BYTES = 64 * 1024
def _id3_padding_tag(payload_size: int) -> bytes:
if not 0 <= payload_size <= 0x0FFFFFFF:
raise ValueError("ID3 payload size is outside the synchsafe range")
size = bytes((payload_size >> shift) & 0x7F for shift in (21, 14, 7, 0))
return b"ID3\x04\x00\x00" + size + bytes(payload_size)
FLAC_GUIDANCE_PREFIX = _id3_padding_tag(FLAC_GUIDANCE_PADDING_BYTES)
def _version(distribution: str) -> str:
@@ -334,6 +345,9 @@ def create_app(
@application.get("/api/stream/{song_id}")
async def stream_song(song_id: str, quality: str = "alac-16-44") -> StreamingResponse:
async def content() -> AsyncIterator[bytes]:
# ID3v2 tags may precede FLAC. A padding-only tag opens the response
# immediately while Apple prepares the real, otherwise unchanged audio.
yield FLAC_GUIDANCE_PREFIX
source = await prepare_song(song_id, quality, "aac-web")
async for chunk in process_runner.stream_flac(source):
yield chunk
+64 -8
View File
@@ -12,7 +12,12 @@ import pytest
from fastapi.testclient import TestClient
from fastapi.responses import StreamingResponse
from apple_gateway.app import API_VERSION, create_app
from apple_gateway.app import (
API_VERSION,
FLAC_GUIDANCE_PADDING_BYTES,
FLAC_GUIDANCE_PREFIX,
create_app,
)
from apple_gateway.catalog import CatalogClient
from apple_gateway.config import Settings
from apple_gateway.runner import BoundedProcessRunner, ProcessFailure
@@ -237,7 +242,7 @@ def test_song_download_uses_safe_id_quality_mapping_and_flac_contract(client):
streamed = client[0].get("/api/stream/102", params={"quality": "aac-320"})
assert streamed.status_code == 200
assert streamed.headers["content-type"].startswith("audio/flac")
assert streamed.content == b"fLaCfixture"
assert streamed.content == FLAC_GUIDANCE_PREFIX + b"fLaCfixture"
assert client[2].transcodes == ["file", "stream"]
assert client[2].calls[-1] == ("https://music.apple.com/us/album/fixture/1?i=102", "aac")
assert client[0].get("/api/download/not-an-id").status_code == 400
@@ -260,7 +265,11 @@ def test_song_stream_head_reports_only_known_facts_without_preparing_media(clien
async def test_song_stream_opens_before_preparing_configured_quality(settings: Settings):
runner = FakeRunner()
app = create_app(settings, FakeWrapper(), FakeCatalog(), runner)
route = next(route for route in app.routes if getattr(route, "path", None) == "/api/stream/{song_id}")
route = next(
route
for route in app.routes
if getattr(route, "path", None) == "/api/stream/{song_id}"
)
response = await route.endpoint("102", "alac-16-44")
@@ -268,10 +277,58 @@ async def test_song_stream_opens_before_preparing_configured_quality(settings: S
assert response.headers["cache-control"] == "no-store"
assert response.headers["x-accel-buffering"] == "no"
assert runner.calls == []
assert b"".join([chunk async for chunk in response.body_iterator]) == b"fLaCfixture"
assert b"".join(
[chunk async for chunk in response.body_iterator]
) == FLAC_GUIDANCE_PREFIX + b"fLaCfixture"
assert runner.calls[-1][1] == "alac"
def test_flac_guidance_prefix_is_a_metadata_free_id3v24_padding_tag():
assert FLAC_GUIDANCE_PREFIX[:6] == b"ID3\x04\x00\x00"
encoded_size = FLAC_GUIDANCE_PREFIX[6:10]
assert all(byte < 0x80 for byte in encoded_size)
decoded_size = sum(
byte << shift for byte, shift in zip(encoded_size, (21, 14, 7, 0))
)
assert decoded_size == FLAC_GUIDANCE_PADDING_BYTES
assert len(FLAC_GUIDANCE_PREFIX) == 10 + FLAC_GUIDANCE_PADDING_BYTES
assert not any(FLAC_GUIDANCE_PREFIX[10:])
@pytest.mark.asyncio
async def test_song_stream_sends_guidance_prefix_before_apple_preparation(settings: Settings):
class BlockingRunner(FakeRunner):
def __init__(self):
super().__init__()
self.started = asyncio.Event()
self.release = asyncio.Event()
async def download(
self, url: str, quality: str, output: Path, temporary: Path
) -> list[Path]:
self.started.set()
await self.release.wait()
return await super().download(url, quality, output, temporary)
runner = BlockingRunner()
app = create_app(settings, FakeWrapper(), FakeCatalog(), runner)
route = next(
route
for route in app.routes
if getattr(route, "path", None) == "/api/stream/{song_id}"
)
response = await route.endpoint("102", "aac-320")
assert await anext(response.body_iterator) == FLAC_GUIDANCE_PREFIX
assert not runner.started.is_set()
first_audio = asyncio.create_task(anext(response.body_iterator))
await asyncio.wait_for(runner.started.wait(), timeout=0.5)
assert not first_audio.done()
runner.release.set()
assert await asyncio.wait_for(first_audio, timeout=0.5) == b"fLaC"
def test_song_stream_falls_back_to_web_aac_when_lossless_is_unavailable(settings):
class FallbackRunner(FakeRunner):
async def download(self, url: str, quality: str, output: Path, temporary: Path) -> list[Path]:
@@ -319,10 +376,9 @@ async def test_simultaneous_song_streams_share_preparation(settings):
route.endpoint("102", "aac-320"),
route.endpoint("102", "aac-320"),
)
await asyncio.gather(*(
anext(response.body_iterator)
for response in responses
))
prefixes = await asyncio.gather(*(anext(response.body_iterator) for response in responses))
assert prefixes == [FLAC_GUIDANCE_PREFIX, FLAC_GUIDANCE_PREFIX]
await asyncio.gather(*(anext(response.body_iterator) for response in responses))
assert len(runner.calls) == 1