"""Provider protocols and the normalized types everything above this layer speaks. Two protocols, deliberately separate (ยง3.1): MediaProvider knows what exists, HistoryProvider knows what was watched. On this network those are two different machines โ€” Plex on Loki, Tautulli on Isis โ€” and Emby/Jellyfin later will have no Tautulli equivalent at all. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Iterator, Protocol, runtime_checkable class ProviderError(RuntimeError): """Any failure talking to a source. Carries a message safe to display.""" class AuthError(ProviderError): """Credentials rejected. Never retried โ€” a retry loop won't fix a bad token.""" @dataclass class ServerInfo: kind: str name: str version: str = "" server_id: str = "" # Plex machineIdentifier / Tautulli pms_identifier base_url: str = "" @dataclass class Library: provider_key: str title: str kind: str # 'movie' | 'show' locations: list[str] = field(default_factory=list) @dataclass class Part: file_path: str size_bytes: int = 0 provider_part_id: str | None = None container: str | None = None resolution: str | None = None video_codec: str | None = None audio_codec: str | None = None bitrate: int | None = None @dataclass class Item: """A movie or an episode as the source reports it. Episodes carry their season and show identity so the ingest can roll them up without a second pass over the API. """ provider_item_id: str kind: str # 'movie' | 'episode' title: str library_key: str guid: str | None = None sort_title: str | None = None year: int | None = None added_at: int | None = None updated_at: int | None = None duration_ms: int = 0 view_count: int = 0 last_viewed_at: int | None = None resolution: str | None = None video_codec: str | None = None parts: list[Part] = field(default_factory=list) # episode-only show_id: str | None = None show_title: str | None = None show_guid: str | None = None season_id: str | None = None season_number: int | None = None episode_number: int | None = None @property def size_bytes(self) -> int: return sum(p.size_bytes for p in self.parts) @dataclass class WatchEvent: """One playback event, normalized across sources.""" source: str # 'tautulli' | 'plex' source_row_id: str provider_item_id: str viewed_at: int account_id: str | None = None reference_id: str | None = None stopped_at: int | None = None play_duration_s: int | None = None paused_counter_s: int | None = None percent_complete: int | None = None watched_status: float | None = None media_type: str | None = None platform: str | None = None @dataclass class Account: account_id: str name: str | None = None friendly_name: str | None = None @dataclass class Coverage: earliest_event_at: int | None latest_event_at: int | None event_count: int @runtime_checkable class MediaProvider(Protocol): def server_info(self) -> ServerInfo: ... def libraries(self) -> list[Library]: ... def items(self, library: Library) -> Iterator[Item]: ... def refresh_library(self, library: Library) -> None: ... # v2 only @runtime_checkable class HistoryProvider(Protocol): name: str def server_info(self) -> ServerInfo: ... def accounts(self) -> list[Account]: ... def watch_events(self, since: int | None = None) -> Iterator[WatchEvent]: ... def coverage(self) -> Coverage: ... @property def has_completion_data(self) -> bool: ...