Mus 10 issue playing music from jellyfin (#11)
* fix: proxy Jellyfin audio streams through backend to fix Docker playback * fix: supportsShuffle on Jellyfin
This commit is contained in:
@@ -100,6 +100,9 @@ backend-test-sync-coordinator: $(BACKEND_VENV_STAMP) ## Run sync coordinator tes
|
||||
backend-test-local-files-fallback: $(BACKEND_VENV_STAMP) ## Run local files stale-while-error fallback tests
|
||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_local_files_fallback.py -v
|
||||
|
||||
backend-test-jellyfin-proxy: $(BACKEND_VENV_STAMP) ## Run Jellyfin stream proxy tests
|
||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/routes/test_stream_routes.py -v
|
||||
|
||||
test-audiodb-all: backend-test-audiodb backend-test-audiodb-prewarm backend-test-audiodb-settings backend-test-coverart-audiodb backend-test-audiodb-phase8 backend-test-audiodb-phase9 frontend-test-audiodb-images ## Run every AudioDB test target
|
||||
|
||||
frontend-install: ## Install frontend npm dependencies
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse, Response, StreamingResponse
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from api.v1.schemas.stream import (
|
||||
JellyfinPlaybackUrlResponse,
|
||||
PlaybackSessionResponse,
|
||||
ProgressReportRequest,
|
||||
StartPlaybackRequest,
|
||||
StopReportRequest,
|
||||
)
|
||||
from core.dependencies import (
|
||||
get_jellyfin_repository,
|
||||
get_jellyfin_playback_service,
|
||||
get_local_files_service,
|
||||
get_navidrome_playback_service,
|
||||
)
|
||||
from core.exceptions import ExternalServiceError, PlaybackNotAllowedError, ResourceNotFoundError
|
||||
from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute
|
||||
from repositories.jellyfin_repository import JellyfinRepository
|
||||
from services.jellyfin_playback_service import JellyfinPlaybackService
|
||||
from services.local_files_service import LocalFilesService
|
||||
from services.navidrome_playback_service import NavidromePlaybackService
|
||||
@@ -31,52 +28,30 @@ router = APIRouter(route_class=MsgSpecRoute, prefix="/stream", tags=["streaming"
|
||||
@router.get("/jellyfin/{item_id}")
|
||||
async def stream_jellyfin_audio(
|
||||
item_id: str,
|
||||
jellyfin_repo: JellyfinRepository = Depends(get_jellyfin_repository),
|
||||
) -> JellyfinPlaybackUrlResponse:
|
||||
request: Request,
|
||||
playback_service: JellyfinPlaybackService = Depends(get_jellyfin_playback_service),
|
||||
) -> StreamingResponse:
|
||||
try:
|
||||
playback = await jellyfin_repo.get_playback_url(item_id)
|
||||
logger.info(
|
||||
"Resolved Jellyfin playback metadata",
|
||||
extra={
|
||||
"item_id": item_id,
|
||||
"play_method": playback.play_method,
|
||||
"seekable": playback.seekable,
|
||||
},
|
||||
)
|
||||
return JellyfinPlaybackUrlResponse(
|
||||
url=playback.url,
|
||||
seekable=playback.seekable,
|
||||
playSessionId=playback.play_session_id,
|
||||
)
|
||||
range_header = request.headers.get("Range")
|
||||
return await playback_service.proxy_stream(item_id, range_header=range_header)
|
||||
except ResourceNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Audio item not found")
|
||||
except PlaybackNotAllowedError as e:
|
||||
logger.warning("Playback not allowed for %s: %s", item_id, e)
|
||||
raise HTTPException(status_code=403, detail="Playback not allowed")
|
||||
except ExternalServiceError as e:
|
||||
if "416" in str(e):
|
||||
raise HTTPException(status_code=416, detail="Range not satisfiable")
|
||||
raise HTTPException(status_code=502, detail="Failed to stream from Jellyfin")
|
||||
|
||||
|
||||
@router.head("/jellyfin/{item_id}")
|
||||
async def head_jellyfin_audio(
|
||||
item_id: str,
|
||||
jellyfin_repo: JellyfinRepository = Depends(get_jellyfin_repository),
|
||||
playback_service: JellyfinPlaybackService = Depends(get_jellyfin_playback_service),
|
||||
) -> Response:
|
||||
try:
|
||||
playback = await jellyfin_repo.get_playback_url(item_id)
|
||||
logger.info(
|
||||
"Resolved Jellyfin playback prefetch redirect",
|
||||
extra={
|
||||
"item_id": item_id,
|
||||
"play_method": playback.play_method,
|
||||
"seekable": playback.seekable,
|
||||
},
|
||||
)
|
||||
return RedirectResponse(
|
||||
url=playback.url,
|
||||
status_code=302,
|
||||
headers={"Referrer-Policy": "no-referrer"},
|
||||
)
|
||||
return await playback_service.proxy_head(item_id)
|
||||
except ResourceNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Audio item not found")
|
||||
except PlaybackNotAllowedError as e:
|
||||
|
||||
@@ -10,12 +10,6 @@ class StartPlaybackRequest(AppStruct):
|
||||
play_session_id: str | None = None
|
||||
|
||||
|
||||
class JellyfinPlaybackUrlResponse(AppStruct):
|
||||
url: str
|
||||
seekable: bool
|
||||
playSessionId: str
|
||||
|
||||
|
||||
class ProgressReportRequest(AppStruct):
|
||||
play_session_id: str
|
||||
position_seconds: float
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import httpx
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import msgspec
|
||||
from core.exceptions import ExternalServiceError, PlaybackNotAllowedError, ResourceNotFoundError
|
||||
@@ -16,6 +19,7 @@ from repositories.jellyfin_models import (
|
||||
parse_item,
|
||||
parse_user,
|
||||
)
|
||||
from repositories.navidrome_models import StreamProxyResult
|
||||
from infrastructure.degradation import try_get_degradation_context
|
||||
from infrastructure.integration_result import IntegrationResult
|
||||
|
||||
@@ -681,7 +685,7 @@ class JellyfinRepository:
|
||||
transcoding_url = primary_source.get("TranscodingUrl")
|
||||
|
||||
if supports_direct_play or supports_direct_stream:
|
||||
playback_url = f"{self._base_url}/Audio/{item_id}/stream?static=true&api_key={self._api_key}"
|
||||
playback_url = f"{self._base_url}/Audio/{item_id}/stream?static=true"
|
||||
play_method = "DirectPlay" if supports_direct_play else "DirectStream"
|
||||
seekable = True
|
||||
elif isinstance(transcoding_url, str) and transcoding_url:
|
||||
@@ -737,3 +741,118 @@ class JellyfinRepository:
|
||||
"PositionTicks": position_ticks,
|
||||
}
|
||||
await self._post("/Sessions/Playing/Stopped", json_data=body)
|
||||
|
||||
def _validate_stream_url(self, url: str) -> None:
|
||||
expected = urlparse(self._base_url)
|
||||
actual = urlparse(url)
|
||||
if (actual.scheme, actual.hostname, actual.port) != (
|
||||
expected.scheme, expected.hostname, expected.port
|
||||
):
|
||||
raise ExternalServiceError(
|
||||
"Resolved playback URL does not match configured Jellyfin origin"
|
||||
)
|
||||
|
||||
def _get_stream_headers(self) -> dict[str, str]:
|
||||
return {"X-Emby-Token": self._api_key}
|
||||
|
||||
async def proxy_head_stream(self, item_id: str) -> StreamProxyResult:
|
||||
playback = await self.get_playback_url(item_id)
|
||||
self._validate_stream_url(playback.url)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(connect=10, read=10, write=10, pool=10)
|
||||
) as client:
|
||||
try:
|
||||
resp = await client.head(
|
||||
playback.url, headers=self._get_stream_headers()
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
raise ExternalServiceError("Failed to reach Jellyfin for stream")
|
||||
|
||||
if resp.status_code >= 400:
|
||||
raise ExternalServiceError(
|
||||
f"Jellyfin HEAD returned {resp.status_code} for {item_id}"
|
||||
)
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
for h in _PROXY_FORWARD_HEADERS:
|
||||
v = resp.headers.get(h)
|
||||
if v:
|
||||
headers[h] = v
|
||||
return StreamProxyResult(
|
||||
status_code=resp.status_code,
|
||||
headers=headers,
|
||||
media_type=headers.get("Content-Type", "audio/mpeg"),
|
||||
)
|
||||
|
||||
async def proxy_get_stream(
|
||||
self, item_id: str, range_header: str | None = None
|
||||
) -> StreamProxyResult:
|
||||
playback = await self.get_playback_url(item_id)
|
||||
self._validate_stream_url(playback.url)
|
||||
|
||||
upstream_headers = self._get_stream_headers()
|
||||
if range_header:
|
||||
if not _RANGE_RE.match(range_header):
|
||||
raise ExternalServiceError("416 Range not satisfiable")
|
||||
upstream_headers["Range"] = range_header
|
||||
|
||||
client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(connect=10, read=120, write=30, pool=10)
|
||||
)
|
||||
upstream_resp = None
|
||||
try:
|
||||
try:
|
||||
upstream_resp = await client.send(
|
||||
client.build_request("GET", playback.url, headers=upstream_headers),
|
||||
stream=True,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise ExternalServiceError(
|
||||
f"Failed to connect to Jellyfin for stream: {exc}"
|
||||
)
|
||||
|
||||
if upstream_resp.status_code == 416:
|
||||
raise ExternalServiceError("416 Range not satisfiable")
|
||||
|
||||
if upstream_resp.status_code >= 400:
|
||||
logger.error(
|
||||
"Jellyfin upstream returned %d for %s",
|
||||
upstream_resp.status_code, item_id,
|
||||
)
|
||||
raise ExternalServiceError("Jellyfin returned an error")
|
||||
|
||||
resp_headers: dict[str, str] = {}
|
||||
for header_name in _PROXY_FORWARD_HEADERS:
|
||||
value = upstream_resp.headers.get(header_name)
|
||||
if value:
|
||||
resp_headers[header_name] = value
|
||||
|
||||
status_code = 206 if upstream_resp.status_code == 206 else 200
|
||||
|
||||
async def _stream_body() -> AsyncIterator[bytes]:
|
||||
try:
|
||||
async for chunk in upstream_resp.aiter_bytes(
|
||||
chunk_size=_STREAM_CHUNK_SIZE
|
||||
):
|
||||
yield chunk
|
||||
finally:
|
||||
await upstream_resp.aclose()
|
||||
await client.aclose()
|
||||
|
||||
return StreamProxyResult(
|
||||
status_code=status_code,
|
||||
headers=resp_headers,
|
||||
media_type=resp_headers.get("Content-Type", "audio/mpeg"),
|
||||
body_chunks=_stream_body(),
|
||||
)
|
||||
except Exception:
|
||||
if upstream_resp:
|
||||
await upstream_resp.aclose()
|
||||
await client.aclose()
|
||||
raise
|
||||
|
||||
|
||||
_PROXY_FORWARD_HEADERS = {"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges"}
|
||||
_STREAM_CHUNK_SIZE = 64 * 1024
|
||||
_RANGE_RE = re.compile(r"^bytes=\d*-\d*(,\s*\d*-\d*)*$")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import Any, Protocol
|
||||
|
||||
from repositories.jellyfin_models import JellyfinItem, JellyfinUser, PlaybackUrlResult
|
||||
from repositories.navidrome_models import StreamProxyResult
|
||||
|
||||
|
||||
class JellyfinRepositoryProtocol(Protocol):
|
||||
@@ -131,3 +132,11 @@ class JellyfinRepositoryProtocol(Protocol):
|
||||
self, item_id: str, play_session_id: str, position_ticks: int
|
||||
) -> None:
|
||||
...
|
||||
|
||||
async def proxy_head_stream(self, item_id: str) -> StreamProxyResult:
|
||||
...
|
||||
|
||||
async def proxy_get_stream(
|
||||
self, item_id: str, range_header: str | None = None
|
||||
) -> StreamProxyResult:
|
||||
...
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from core.exceptions import ExternalServiceError, PlaybackNotAllowedError
|
||||
from infrastructure.constants import JELLYFIN_TICKS_PER_SECOND
|
||||
from repositories.navidrome_models import StreamProxyResult
|
||||
from repositories.protocols import JellyfinRepositoryProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -20,6 +22,7 @@ class JellyfinPlaybackService:
|
||||
PlaybackInfoResponse (NotAllowed, NoCompatibleStream, RateLimitExceeded).
|
||||
"""
|
||||
resolved_play_session_id = play_session_id
|
||||
play_method = "DirectPlay"
|
||||
|
||||
if not resolved_play_session_id:
|
||||
info = await self._jellyfin.get_playback_info(item_id)
|
||||
@@ -39,8 +42,20 @@ class JellyfinPlaybackService:
|
||||
)
|
||||
return ""
|
||||
|
||||
media_sources = info.get("MediaSources") or []
|
||||
if media_sources:
|
||||
src = media_sources[0]
|
||||
if src.get("SupportsDirectPlay"):
|
||||
play_method = "DirectPlay"
|
||||
elif src.get("SupportsDirectStream"):
|
||||
play_method = "DirectStream"
|
||||
elif src.get("TranscodingUrl"):
|
||||
play_method = "Transcode"
|
||||
|
||||
try:
|
||||
await self._jellyfin.report_playback_start(item_id, resolved_play_session_id)
|
||||
await self._jellyfin.report_playback_start(
|
||||
item_id, resolved_play_session_id, play_method=play_method
|
||||
)
|
||||
except (httpx.HTTPError, ExternalServiceError) as e:
|
||||
logger.error(
|
||||
"Failed to report playback start for %s: %s", item_id, e
|
||||
@@ -80,3 +95,20 @@ class JellyfinPlaybackService:
|
||||
)
|
||||
except (httpx.HTTPError, ExternalServiceError) as e:
|
||||
logger.warning("Stop report failed for %s: %s", item_id, e)
|
||||
|
||||
async def proxy_head(self, item_id: str) -> Response:
|
||||
result: StreamProxyResult = await self._jellyfin.proxy_head_stream(item_id)
|
||||
return Response(status_code=200, headers=result.headers)
|
||||
|
||||
async def proxy_stream(
|
||||
self, item_id: str, range_header: str | None = None
|
||||
) -> StreamingResponse:
|
||||
result: StreamProxyResult = await self._jellyfin.proxy_get_stream(
|
||||
item_id, range_header=range_header
|
||||
)
|
||||
return StreamingResponse(
|
||||
content=result.body_chunks,
|
||||
status_code=result.status_code,
|
||||
headers=result.headers,
|
||||
media_type=result.media_type,
|
||||
)
|
||||
|
||||
@@ -34,7 +34,7 @@ async def test_get_playback_url_direct_play(repo: JellyfinRepository):
|
||||
|
||||
result = await repo.get_playback_url("item-1")
|
||||
|
||||
assert result.url == "http://jellyfin:8096/Audio/item-1/stream?static=true&api_key=test-api-key"
|
||||
assert result.url == "http://jellyfin:8096/Audio/item-1/stream?static=true"
|
||||
assert result.seekable is True
|
||||
assert result.play_session_id == "sess-1"
|
||||
assert result.play_method == "DirectPlay"
|
||||
@@ -76,7 +76,7 @@ async def test_get_playback_url_direct_stream(repo: JellyfinRepository):
|
||||
|
||||
result = await repo.get_playback_url("item-direct-stream")
|
||||
|
||||
assert result.url == "http://jellyfin:8096/Audio/item-direct-stream/stream?static=true&api_key=test-api-key"
|
||||
assert result.url == "http://jellyfin:8096/Audio/item-direct-stream/stream?static=true"
|
||||
assert result.seekable is True
|
||||
assert result.play_method == "DirectStream"
|
||||
|
||||
@@ -119,6 +119,27 @@ async def test_get_playback_url_not_configured_raises():
|
||||
await unconfigured_repo.get_playback_url("item-4")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_get_stream_validates_url_origin(repo: JellyfinRepository):
|
||||
from repositories.jellyfin_models import PlaybackUrlResult
|
||||
|
||||
repo._request = AsyncMock(
|
||||
return_value={
|
||||
"PlaySessionId": "sess-bad",
|
||||
"MediaSources": [
|
||||
{
|
||||
"SupportsDirectPlay": False,
|
||||
"SupportsDirectStream": False,
|
||||
"TranscodingUrl": "http://evil.example.com/Audio/item-1/stream",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ExternalServiceError, match="does not match"):
|
||||
await repo.proxy_get_stream("item-1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_playback_url_missing_item_raises(repo: JellyfinRepository):
|
||||
repo._request = AsyncMock(return_value=None)
|
||||
|
||||
@@ -3,107 +3,121 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from api.v1.routes.stream import router
|
||||
from core.dependencies import get_jellyfin_playback_service, get_jellyfin_repository
|
||||
from core.dependencies import get_jellyfin_playback_service
|
||||
from core.exceptions import ExternalServiceError, PlaybackNotAllowedError, ResourceNotFoundError
|
||||
from repositories.jellyfin_models import PlaybackUrlResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_jellyfin_repo():
|
||||
mock = MagicMock()
|
||||
mock.get_playback_url = AsyncMock(
|
||||
return_value=PlaybackUrlResult(
|
||||
url="http://jellyfin:8096/Audio/item-1/stream?static=true&api_key=test-key",
|
||||
seekable=True,
|
||||
play_session_id="sess-1",
|
||||
play_method="DirectPlay",
|
||||
)
|
||||
)
|
||||
return mock
|
||||
async def _fake_body():
|
||||
yield b"audio-data-chunk-1"
|
||||
yield b"audio-data-chunk-2"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_playback_service():
|
||||
mock = MagicMock()
|
||||
mock.start_playback = AsyncMock(return_value="sess-start")
|
||||
mock.proxy_head = AsyncMock(
|
||||
return_value=Response(
|
||||
status_code=200,
|
||||
headers={
|
||||
"Content-Type": "audio/flac",
|
||||
"Content-Length": "12345678",
|
||||
"Accept-Ranges": "bytes",
|
||||
},
|
||||
)
|
||||
)
|
||||
mock.proxy_stream = AsyncMock(
|
||||
return_value=StreamingResponse(
|
||||
content=_fake_body(),
|
||||
status_code=200,
|
||||
headers={"Content-Type": "audio/flac", "Content-Length": "12345678"},
|
||||
media_type="audio/flac",
|
||||
)
|
||||
)
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_jellyfin_repo, mock_playback_service):
|
||||
def client(mock_playback_service):
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_jellyfin_repository] = lambda: mock_jellyfin_repo
|
||||
app.dependency_overrides[get_jellyfin_playback_service] = lambda: mock_playback_service
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_get_stream_returns_json_with_seekable_and_session(client):
|
||||
def test_get_stream_returns_proxied_audio(client, mock_playback_service):
|
||||
response = client.get("/stream/jellyfin/item-1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"url": "http://jellyfin:8096/Audio/item-1/stream?static=true&api_key=test-key",
|
||||
"seekable": True,
|
||||
"playSessionId": "sess-1",
|
||||
}
|
||||
assert b"audio-data-chunk" in response.content
|
||||
mock_playback_service.proxy_stream.assert_awaited_once()
|
||||
call_args = mock_playback_service.proxy_stream.call_args
|
||||
assert call_args[0][0] == "item-1"
|
||||
|
||||
|
||||
def test_get_stream_transcode_returns_non_seekable(client, mock_jellyfin_repo):
|
||||
mock_jellyfin_repo.get_playback_url = AsyncMock(
|
||||
return_value=PlaybackUrlResult(
|
||||
url="http://jellyfin:8096/Audio/item-2/universal?container=opus",
|
||||
seekable=False,
|
||||
play_session_id="sess-2",
|
||||
play_method="Transcode",
|
||||
)
|
||||
)
|
||||
def test_get_stream_forwards_range_header(client, mock_playback_service):
|
||||
client.get("/stream/jellyfin/item-1", headers={"Range": "bytes=1000-"})
|
||||
|
||||
response = client.get("/stream/jellyfin/item-2")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["seekable"] is False
|
||||
assert "/universal" in response.json()["url"]
|
||||
call_args = mock_playback_service.proxy_stream.call_args
|
||||
assert call_args[1]["range_header"] == "bytes=1000-"
|
||||
|
||||
|
||||
def test_get_stream_returns_404_when_item_missing(client, mock_jellyfin_repo):
|
||||
mock_jellyfin_repo.get_playback_url.side_effect = ResourceNotFoundError("missing")
|
||||
def test_get_stream_returns_404_when_item_missing(client, mock_playback_service):
|
||||
mock_playback_service.proxy_stream.side_effect = ResourceNotFoundError("missing")
|
||||
|
||||
response = client.get("/stream/jellyfin/missing-item")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_get_stream_returns_403_when_playback_not_allowed(client, mock_jellyfin_repo):
|
||||
mock_jellyfin_repo.get_playback_url.side_effect = PlaybackNotAllowedError("NotAllowed")
|
||||
def test_get_stream_returns_403_when_playback_not_allowed(client, mock_playback_service):
|
||||
mock_playback_service.proxy_stream.side_effect = PlaybackNotAllowedError("NotAllowed")
|
||||
|
||||
response = client.get("/stream/jellyfin/item-denied")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_get_stream_returns_502_on_external_error(client, mock_jellyfin_repo):
|
||||
mock_jellyfin_repo.get_playback_url.side_effect = ExternalServiceError("jellyfin down")
|
||||
def test_get_stream_returns_502_on_external_error(client, mock_playback_service):
|
||||
mock_playback_service.proxy_stream.side_effect = ExternalServiceError("jellyfin down")
|
||||
|
||||
response = client.get("/stream/jellyfin/item-err")
|
||||
|
||||
assert response.status_code == 502
|
||||
|
||||
|
||||
def test_head_stream_returns_redirect(client):
|
||||
response = client.request("HEAD", "/stream/jellyfin/item-1", follow_redirects=False)
|
||||
def test_get_stream_returns_416_on_range_error(client, mock_playback_service):
|
||||
mock_playback_service.proxy_stream.side_effect = ExternalServiceError("416 Range not satisfiable")
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "http://jellyfin:8096/Audio/item-1/stream?static=true&api_key=test-key"
|
||||
response = client.get("/stream/jellyfin/item-range", headers={"Range": "bytes=999999999-"})
|
||||
|
||||
assert response.status_code == 416
|
||||
|
||||
|
||||
def test_head_stream_sets_no_referrer_policy(client):
|
||||
response = client.request("HEAD", "/stream/jellyfin/item-1", follow_redirects=False)
|
||||
def test_head_stream_returns_proxied_headers(client, mock_playback_service):
|
||||
response = client.request("HEAD", "/stream/jellyfin/item-1")
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.headers["referrer-policy"] == "no-referrer"
|
||||
assert response.status_code == 200
|
||||
mock_playback_service.proxy_head.assert_awaited_once_with("item-1")
|
||||
|
||||
|
||||
def test_head_stream_returns_404_when_missing(client, mock_playback_service):
|
||||
mock_playback_service.proxy_head.side_effect = ResourceNotFoundError("missing")
|
||||
|
||||
response = client.request("HEAD", "/stream/jellyfin/missing-item")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_head_stream_returns_403_when_not_allowed(client, mock_playback_service):
|
||||
mock_playback_service.proxy_head.side_effect = PlaybackNotAllowedError("not allowed")
|
||||
|
||||
response = client.request("HEAD", "/stream/jellyfin/item-denied")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_start_stream_uses_existing_play_session_id(client, mock_playback_service):
|
||||
|
||||
@@ -13,7 +13,10 @@ from services.jellyfin_playback_service import (
|
||||
def _make_repo() -> AsyncMock:
|
||||
repo = AsyncMock()
|
||||
repo.get_playback_info = AsyncMock(
|
||||
return_value={"PlaySessionId": "sess-123"}
|
||||
return_value={
|
||||
"PlaySessionId": "sess-123",
|
||||
"MediaSources": [{"SupportsDirectPlay": True, "SupportsDirectStream": True}],
|
||||
}
|
||||
)
|
||||
repo.report_playback_start = AsyncMock()
|
||||
repo.report_playback_progress = AsyncMock()
|
||||
@@ -33,7 +36,9 @@ async def test_start_playback_returns_session_id(service):
|
||||
svc, repo = service
|
||||
result = await svc.start_playback("item-1")
|
||||
assert result == "sess-123"
|
||||
repo.report_playback_start.assert_called_once_with("item-1", "sess-123")
|
||||
repo.report_playback_start.assert_called_once_with(
|
||||
"item-1", "sess-123", play_method="DirectPlay"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -107,3 +112,58 @@ async def test_stop_playback_handles_failure(service):
|
||||
svc, repo = service
|
||||
repo.report_playback_stopped.side_effect = httpx.ConnectError("timeout")
|
||||
await svc.stop_playback("item-1", "sess-123", 10.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_head_delegates_to_repo(service):
|
||||
svc, repo = service
|
||||
from fastapi.responses import Response
|
||||
from repositories.navidrome_models import StreamProxyResult
|
||||
|
||||
repo.proxy_head_stream = AsyncMock(
|
||||
return_value=StreamProxyResult(
|
||||
status_code=200,
|
||||
headers={"Content-Type": "audio/flac", "Content-Length": "999"},
|
||||
media_type="audio/flac",
|
||||
)
|
||||
)
|
||||
result = await svc.proxy_head("item-1")
|
||||
assert isinstance(result, Response)
|
||||
repo.proxy_head_stream.assert_awaited_once_with("item-1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_stream_returns_streaming_response(service):
|
||||
svc, repo = service
|
||||
from fastapi.responses import StreamingResponse
|
||||
from repositories.navidrome_models import StreamProxyResult
|
||||
|
||||
async def _chunks():
|
||||
yield b"data"
|
||||
|
||||
repo.proxy_get_stream = AsyncMock(
|
||||
return_value=StreamProxyResult(
|
||||
status_code=200,
|
||||
headers={"Content-Type": "audio/flac"},
|
||||
media_type="audio/flac",
|
||||
body_chunks=_chunks(),
|
||||
)
|
||||
)
|
||||
result = await svc.proxy_stream("item-1", range_header="bytes=0-")
|
||||
assert isinstance(result, StreamingResponse)
|
||||
repo.proxy_get_stream.assert_awaited_once_with("item-1", range_header="bytes=0-")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_playback_propagates_play_method(service):
|
||||
svc, repo = service
|
||||
repo.get_playback_info.return_value = {
|
||||
"PlaySessionId": "sess-123",
|
||||
"MediaSources": [
|
||||
{"SupportsDirectPlay": False, "SupportsDirectStream": False, "TranscodingUrl": "/transcode"}
|
||||
],
|
||||
}
|
||||
await svc.start_playback("item-1")
|
||||
repo.report_playback_start.assert_called_once_with(
|
||||
"item-1", "sess-123", play_method="Transcode"
|
||||
)
|
||||
|
||||
@@ -19,7 +19,6 @@ const MAX_HISTORY_LENGTH = 3;
|
||||
const SESSION_PERSIST_INTERVAL_MS = 5000;
|
||||
const JELLYFIN_REPORT_INTERVAL_MS = 10_000;
|
||||
const MAX_JELLYFIN_REPORT_FAILURES = 3;
|
||||
type JellyfinPlaybackUrlResponse = { url: string; seekable: boolean; playSessionId: string };
|
||||
|
||||
function createPlayerStore() {
|
||||
let currentSource = $state<PlaybackSource | null>(null);
|
||||
@@ -106,10 +105,8 @@ function createPlayerStore() {
|
||||
void reportNavidromeNowPlaying(item.trackSourceId);
|
||||
return { source: createPlaybackSource('navidrome', { url: url!, seekable: true }), loadUrl: url };
|
||||
}
|
||||
const payload = await api.global.get<JellyfinPlaybackUrlResponse>(API.stream.jellyfin(item.trackSourceId));
|
||||
const uq = [...queue]; uq[index] = { ...item, playSessionId: payload.playSessionId }; queue = uq;
|
||||
isSeekable = payload.seekable;
|
||||
return { source: createPlaybackSource('jellyfin', { url: payload.url, seekable: payload.seekable }), loadUrl: payload.url };
|
||||
isSeekable = true;
|
||||
return { source: createPlaybackSource('jellyfin', { url: url!, seekable: true }), loadUrl: url };
|
||||
}
|
||||
|
||||
async function startJellyfinPlayback(index: number): Promise<void> {
|
||||
|
||||
@@ -10,7 +10,7 @@ export function resolveSourceUrl(item: QueueItem): string | undefined {
|
||||
case 'navidrome':
|
||||
return item.streamUrl ?? API.stream.navidrome(item.trackSourceId);
|
||||
case 'jellyfin':
|
||||
return undefined;
|
||||
return API.stream.jellyfin(item.trackSourceId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
getDefaultSortOrder: (field) => (field === 'SortName' ? 'Ascending' : 'Descending'),
|
||||
supportsGenres: true,
|
||||
supportsFavorites: true,
|
||||
supportsShuffle: false,
|
||||
supportsShuffle: true,
|
||||
errorMessage: "Couldn't reach Jellyfin"
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user