From 687a9255452530c1ecab8acb81c05a4bfa7594f4 Mon Sep 17 00:00:00 2001 From: Harvey <64276030+HabiRabbu@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:03:17 +0100 Subject: [PATCH] Mus 10 issue playing music from jellyfin (#11) * fix: proxy Jellyfin audio streams through backend to fix Docker playback * fix: supportsShuffle on Jellyfin --- Makefile | 3 + backend/api/v1/routes/stream.py | 45 ++----- backend/api/v1/schemas/stream.py | 6 - backend/repositories/jellyfin_repository.py | 121 +++++++++++++++++- backend/repositories/protocols/jellyfin.py | 9 ++ backend/services/jellyfin_playback_service.py | 34 ++++- .../test_jellyfin_playback_url.py | 25 +++- backend/tests/routes/test_stream_routes.py | 114 +++++++++-------- .../test_jellyfin_playback_service.py | 64 ++++++++- frontend/src/lib/stores/player.svelte.ts | 7 +- .../src/lib/stores/playerSourceResolver.ts | 2 +- .../src/routes/library/jellyfin/+page.svelte | 2 +- 12 files changed, 328 insertions(+), 104 deletions(-) diff --git a/Makefile b/Makefile index 5ec2f15..cfb099b 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/backend/api/v1/routes/stream.py b/backend/api/v1/routes/stream.py index 3b3ccec..2841c36 100644 --- a/backend/api/v1/routes/stream.py +++ b/backend/api/v1/routes/stream.py @@ -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: diff --git a/backend/api/v1/schemas/stream.py b/backend/api/v1/schemas/stream.py index 11139ce..4216c1d 100644 --- a/backend/api/v1/schemas/stream.py +++ b/backend/api/v1/schemas/stream.py @@ -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 diff --git a/backend/repositories/jellyfin_repository.py b/backend/repositories/jellyfin_repository.py index f1f7c35..cd42ae1 100644 --- a/backend/repositories/jellyfin_repository.py +++ b/backend/repositories/jellyfin_repository.py @@ -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*)*$") diff --git a/backend/repositories/protocols/jellyfin.py b/backend/repositories/protocols/jellyfin.py index fa462e6..b1b31ff 100644 --- a/backend/repositories/protocols/jellyfin.py +++ b/backend/repositories/protocols/jellyfin.py @@ -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: + ... diff --git a/backend/services/jellyfin_playback_service.py b/backend/services/jellyfin_playback_service.py index d6c02cd..3e3d594 100644 --- a/backend/services/jellyfin_playback_service.py +++ b/backend/services/jellyfin_playback_service.py @@ -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, + ) diff --git a/backend/tests/repositories/test_jellyfin_playback_url.py b/backend/tests/repositories/test_jellyfin_playback_url.py index 64991b9..74b0190 100644 --- a/backend/tests/repositories/test_jellyfin_playback_url.py +++ b/backend/tests/repositories/test_jellyfin_playback_url.py @@ -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) diff --git a/backend/tests/routes/test_stream_routes.py b/backend/tests/routes/test_stream_routes.py index 626127a..94e71ef 100644 --- a/backend/tests/routes/test_stream_routes.py +++ b/backend/tests/routes/test_stream_routes.py @@ -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): diff --git a/backend/tests/services/test_jellyfin_playback_service.py b/backend/tests/services/test_jellyfin_playback_service.py index a836131..a910714 100644 --- a/backend/tests/services/test_jellyfin_playback_service.py +++ b/backend/tests/services/test_jellyfin_playback_service.py @@ -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" + ) diff --git a/frontend/src/lib/stores/player.svelte.ts b/frontend/src/lib/stores/player.svelte.ts index 520f2df..11406e3 100644 --- a/frontend/src/lib/stores/player.svelte.ts +++ b/frontend/src/lib/stores/player.svelte.ts @@ -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(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(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 { diff --git a/frontend/src/lib/stores/playerSourceResolver.ts b/frontend/src/lib/stores/playerSourceResolver.ts index cbf264b..4036bf2 100644 --- a/frontend/src/lib/stores/playerSourceResolver.ts +++ b/frontend/src/lib/stores/playerSourceResolver.ts @@ -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); } } diff --git a/frontend/src/routes/library/jellyfin/+page.svelte b/frontend/src/routes/library/jellyfin/+page.svelte index b082302..2d13756 100644 --- a/frontend/src/routes/library/jellyfin/+page.svelte +++ b/frontend/src/routes/library/jellyfin/+page.svelte @@ -139,7 +139,7 @@ getDefaultSortOrder: (field) => (field === 'SortName' ? 'Ascending' : 'Descending'), supportsGenres: true, supportsFavorites: true, - supportsShuffle: false, + supportsShuffle: true, errorMessage: "Couldn't reach Jellyfin" };