Skip to content

Design the Provider abstraction seam #9

Closed

Description

@ranokay

Question

Design the Provider seam both TIDAL and Apple plug into: the interface (search, link resolve, catalog metadata, quality tiers, download, lyrics, artwork), where it lives, how the bridge and QML consume it without changing the TIDAL path's behavior, how provider-specific quality concepts (TIDAL tiers vs Apple's AAC/ALAC/Atmos) map into one model the chooser can present, and how ownership/library detection stays provider-aware. Constraint: additive only; TIDAL behavior unchanged.

Activity

  1. self-assigned this
    on Sep 1, 2026
  2. ranokay commented on Sep 1, 2026

    @ranokay
    OwnerAuthor

    Resolution

    Design: one fused Provider interface that both TIDAL and Apple implement now — TIDAL by thin delegation (no rewrites), Apple as the first green-field implementer. Grounded in docs/research/provider-seam-analysis.md (the seam inventory and its two invariants) and the engine decision (ticket #8).

    The five decisions

    1. Both providers implement the seam immediately. TIDAL's implementation delegates to the existing helper/tidal.py, config.py, and download.py bodies unchanged; the bridge routes through the interface instead of reaching them directly. The existing test suite pins TIDAL behavior during the mechanical call-routing. Lives in a new waves/providers/ package: base.py (interface + neutral types), tidal.py, apple.py.
    2. The row-dict schema is the contract. Each provider builds the exact plain dicts QML consumes today (search payload, result rows, queue rows) from its own engine objects; shared builder code is extracted where identical; the field-name contract is documented in base.py. Zero QML work for Apple beyond tier words. The _objs id-bucket pattern stays per-provider.
    3. Namespaced string ids — tidal:123, apple:456 — as the one format everywhere new (ownership rows, on-disk tags, _objs buckets, queue rows, _JobSpec). The ownership store backfills existing rows as tidal:; legacy bare ids read as tidal. On disk, a generic namespaced tag is written alongside the legacy WAVES_TIDAL_* tags, so every file already in a library stays recognized. Ownership gates become provider-scoped automatically: owning on TIDAL never satisfies an Apple gate (per-provider Ownership, per CONTEXT.md).
    4. One quality model, Waves-owned. The four rungs LOW < HIGH < LOSSLESS < HI_RES_LOSSLESS become a Waves enum (no longer tidalapi's Quality); each provider maps its engine codecs onto it (Apple: AAC 256 → HIGH, ALAC 16/44.1 → LOSSLESS, ALAC 24/192 → HI_RES_LOSSLESS). Audio type (stereo/Atmos) stays orthogonal per the glossary. The queue's pinned-quality string parses through the Waves enum, retiring the tidalapi Quality(raw) parse point (backend.py:7586). The chooser renders provider detail ("ALAC 24/192") as label text, never as rank. The three rank comparison sites (ownership store, engine gate, bridge gate) keep their scale.
    5. Composition, capability flags, and the fence. Download takes a Provider: stream resolution becomes provider.resolve_stream(...) returning a neutral StreamInfo (replacing TrackStreamInfo's tidalapi payloads); the TIDAL refusal taxonomy becomes a per-provider classify_refusal hook; TIDAL's Atmos session-swap machinery stays fenced inside TidalProvider — never generalized. Capability flags on the interface keep My TIDAL, Browse, mixes, and videos TIDAL-only without if-branches. No separate in-code Engine abstraction layer in v1: the fallback-engine swap (WorldObs-v3, ticket Choose the Apple engine approach #8) happens behind the same AppleProvider methods.

    The interface sketch

    class Provider(ABC):
        id: str                      # "tidal" / "apple"
        name: str                    # display name
        capabilities: frozenset      # SEARCH OPEN_URL CATALOG DOWNLOAD LYRICS ART BROWSE FAVORITES MIXES VIDEOS
    
        # session/auth
        def login_begin(self) -> str                          # auth URL / flow start
        def login_complete(self, payload: str) -> bool
        def logout(self) -> None
        is_logged_in: bool
        def apply_quality(self, tier: QualityTier, audio_type: AudioType) -> None
    
        # catalog read — returns the app's row dicts; the schema is the contract
        def search(self, needle: str) -> dict                 # searchResults payload, provider-sectioned by caller
        def open_url(self, url: str) -> dict | None
        def get_object(self, kind: str, raw_id: str) -> object
        def collection_items(self, obj, include_videos: bool) -> list
        def user_collections(self) -> dict | None             # None when capability absent
    
        # quality — what the chooser presents
        def advertised_tier(self, obj) -> QualityTier | None
        def advertised_deliveries(self, obj) -> list[tuple[QualityTier, AudioType]]
        def advertised_ceiling(self, obj) -> int | None
    
        # per-track delivery
        def resolve_stream(self, track, tier: QualityTier, audio_type: AudioType) -> StreamInfo
        def fetch_lyrics(self, track) -> tuple[str, str]      # (synced, plain)
        def cover_url(self, obj, dimension: int) -> str | None
        def track_facts(self, track) -> dict                  # the fact schema metadata_write reads (extracted from download.py:3772)
    
        # refusals
        def classify_refusal(self, exc) -> Refusal            # engine error → shared refusal vocabulary
    @dataclass
    class StreamInfo:
        urls: list[str]                      # segment/BTS/plain URLs
        file_extension: str
        codecs: str
        requires_flac_extraction: bool
        delivered: dict                      # {tier, audio_type, bit_depth, sample_rate}
        replay_gain: dict | None             # TIDAL-measured; None on Apple → tags left untagged (existing rule)

    How the bridge consumes it

    self.providers: dict[str, Provider] keyed by provider id. Search fans out over enabled providers and sections results per provider (v1 shape per the map). _JobSpec carries (provider_id, kind, namespaced_id) and resolves via get_object at dispatch instead of holding a raw tidalapi object — behavior-preserving for TIDAL. Settings rows for per-provider quality ride the existing schema-driven SettingsPage (ticket #11's territory).

    Constraint check

    Additive-only holds by construction: TIDAL's bodies don't move — they get wrapped; the refactor is call-routing with the suite as the safety net. The two research invariants (id namespacing, unified rank scale) are decisions 3 and 4.

    Graduated fog

    The fog line "library layout, ownership and IN LIBRARY/DOWNLOADED detection across providers" is now specifiable — the id/tag/ownership mechanics above settled its half; the remaining question (recognizing Apple files, per-provider badge verdicts, path-template sharing) graduates to its own ticket.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions