0f25ebc26d
* plex integration * The big one - Full Music Source page rework + Playlist importing + Full Plex Integration + Discovery Options + More Like This/Surprise Me/Instant Mix + More... * Music source track page - Play all / shuffle fixes * lint * format * fix type checks * format
130 lines
4.6 KiB
Python
130 lines
4.6 KiB
Python
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 infrastructure.cache.memory_cache import CacheInterface
|
|
from repositories.navidrome_models import StreamProxyResult
|
|
from repositories.protocols import JellyfinRepositoryProtocol
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class JellyfinPlaybackService:
|
|
def __init__(
|
|
self,
|
|
jellyfin_repo: JellyfinRepositoryProtocol,
|
|
cache: CacheInterface | None = None,
|
|
):
|
|
self._jellyfin = jellyfin_repo
|
|
self._cache = cache
|
|
|
|
async def _invalidate_sessions_cache(self) -> None:
|
|
if not self._cache:
|
|
return
|
|
uid = getattr(self._jellyfin, '_user_id', None) or 'default'
|
|
await self._cache.delete(f"jellyfin:sessions:{uid}")
|
|
|
|
async def start_playback(self, item_id: str, play_session_id: str | None = None) -> str:
|
|
"""Report playback start to Jellyfin. Returns play_session_id.
|
|
|
|
Handles nullable PlaySessionId and checks for ErrorCode in the
|
|
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)
|
|
|
|
error_code = info.get("ErrorCode")
|
|
if error_code:
|
|
raise PlaybackNotAllowedError(
|
|
f"Jellyfin playback not allowed: {error_code}"
|
|
)
|
|
|
|
resolved_play_session_id = info.get("PlaySessionId")
|
|
if not resolved_play_session_id:
|
|
logger.warning(
|
|
"Jellyfin returned null PlaySessionId for item %s, "
|
|
"streaming without session reporting",
|
|
item_id,
|
|
)
|
|
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, play_method=play_method
|
|
)
|
|
await self._invalidate_sessions_cache()
|
|
except (httpx.HTTPError, ExternalServiceError) as e:
|
|
logger.error(
|
|
"Failed to report playback start for %s: %s", item_id, e
|
|
)
|
|
|
|
return resolved_play_session_id
|
|
|
|
async def report_progress(
|
|
self,
|
|
item_id: str,
|
|
play_session_id: str,
|
|
position_seconds: float,
|
|
is_paused: bool,
|
|
) -> None:
|
|
if not play_session_id:
|
|
return
|
|
position_ticks = int(position_seconds * JELLYFIN_TICKS_PER_SECOND)
|
|
try:
|
|
await self._jellyfin.report_playback_progress(
|
|
item_id, play_session_id, position_ticks, is_paused
|
|
)
|
|
await self._invalidate_sessions_cache()
|
|
except (httpx.HTTPError, ExternalServiceError) as e:
|
|
logger.warning("Progress report failed for %s: %s", item_id, e)
|
|
|
|
async def stop_playback(
|
|
self,
|
|
item_id: str,
|
|
play_session_id: str,
|
|
position_seconds: float,
|
|
) -> None:
|
|
if not play_session_id:
|
|
return
|
|
position_ticks = int(position_seconds * JELLYFIN_TICKS_PER_SECOND)
|
|
try:
|
|
await self._jellyfin.report_playback_stopped(
|
|
item_id, play_session_id, position_ticks
|
|
)
|
|
await self._invalidate_sessions_cache()
|
|
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,
|
|
)
|