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:
Harvey
2026-04-04 17:03:17 +01:00
committed by GitHub
parent d2893b93f2
commit 687a925545
12 changed files with 328 additions and 104 deletions
+3
View File
@@ -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
+10 -35
View File
@@ -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:
-6
View File
@@ -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
+120 -1
View File
@@ -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:
...
+33 -1
View File
@@ -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)
+64 -50
View File
@@ -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"
)
+2 -5
View File
@@ -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"
};