mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-06 13:55:39 -04:00
fix(apple): open cold streams before media preparation
This commit is contained in:
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user