Skip to content

Instantly share code, notes, and snippets.

@CarstenG2
Created March 15, 2026 13:00
Show Gist options
  • Select an option

  • Save CarstenG2/98ff79007bf5d7e5a7416edc8e7e66e7 to your computer and use it in GitHub Desktop.

Select an option

Save CarstenG2/98ff79007bf5d7e5a7416edc8e7e66e7 to your computer and use it in GitHub Desktop.
xShip Quellen-Precaching — Design-Dokumentation

Cache-Hit & Manueller Klick (sources.py)

Cache-Hit Path

Wenn der User einen Film anklickt:

sources.py getSources():
    +-- sourcecacheDB.get_sources(imdb) → cached sources
    +-- Cache Hit: sofortige Anzeige (~15ms)
    |   +-- Ungeprobte Quellen? → _needs_bg_scan=True (erst proben)
    |   +-- Alle geprobt + HD (probe_height >= 720)? → fertig, kein BG-Scan
    |   +-- Alle geprobt + kein HD + is_fully_scanned()? → fertig
    |   +-- Alle geprobt + kein HD + nicht fully_scanned? → _needs_bg_scan=True
    +-- Cache Miss: Foreground-Scan mit Progress-Dialog
        +-- Ueberspringt gecachte Provider
        +-- Early Exit nach 3 Quellen

Entscheidungslogik (_needs_bg_scan)

KEINE Entscheidung auf Basis von Provider-Labels (720p, HD) — NUR Probe-Daten zaehlen.

Zustand _needs_bg_scan Grund
Ungeprobte Quellen vorhanden True Erst proben, dann entscheiden
Alle geprobt + HD False Genug Qualitaet
Alle geprobt + kein HD + fully_scanned False Alle Provider liefen, kein HD vorhanden
Alle geprobt + kein HD + nicht fully_scanned True Weitere Provider koennten HD haben

BG-Scan (addItem)

sources.py addItem() → Progressive List:
    +-- control.idle() (closes busy circle)
    +-- Render cached sources as directory listing
    +-- If _needs_bg_scan + not already running:
    |   +-- Start daemon thread _run_background_scan()
    |   +-- DialogProgressBG with colored resolution counts
    +-- endOfDirectory(updateListing=is_refresh)

_run_background_scan() (daemon thread):
    +-- Seed all_found from existing cached sources
    +-- Skip providers already in cache (cached_providers set)
    +-- Load remaining scrapers, run with ThreadPoolExecutor(max_workers=10)
    +-- Early exit after new_found >= 3
    +-- For each completed scraper:
    |   +-- save_sources() to DB immediately (incremental)
    |   +-- Merge with existing sources (URL + provider/hoster dedup)
    |   +-- sourcesFilter() → rebuild labels
    |   +-- Container.Refresh every 5-10s
    +-- On completion: is_fully_scanned() dynamisch

Foreground-Scan

  • total >= 3 → break (Early Exit)
  • Gecachte Provider ueberspringen (sourcecacheDB.get_sources → skip_providers set)
  • NICHT fully_scanned bei Early Exit → naechster Scan findet weitere Provider

Scoring

_quality_score(source): 3-stufiges Scoring:

  • Geprobte Quellen: 100000 + bitrate_kbits + priority_bonus (immer vor ungeprobten!)
  • Ungeprobte Quellen: priority_bonus (0..1960)
  • Fehlgeschlagene Probes: Score 1
  • priority_bonus = max(0, 100 - priority) * 20
  • Deterministischer Tiebreaker: (-score, provider, source)

sourcesFilter(): sorts by quality_score, removes probe_failed sources, truncates to Top-N (hosts.top_n, default 3).

Filter-Reihenfolge

Sources aus DB → Qualitaets-Filter → Dedup → Score-Sortierung
  → probe_failed entfernen → Top-N → Anzeige

Fehlgeschlagene Probes (_probe.failed) werden VOR dem Top-N-Cut entfernt. Damit verschwendet eine kaputte Source keinen Top-N-Platz.

Failed Source Cleanup

playItem(): Resolve fehlschlaegt → _remove_failed_source()

  • Entfernt Source aus Cache-DB + angezeigter Liste
  • delete_source(imdb, provider, hoster, quality) per UNIQUE key

Deduplication (3 Ebenen)

  1. DB level (save_sources()): Nur 1 Source per (hoster, quality) pro Provider. CDN-Mirrors vor INSERT entfernt.
  2. Display level (sourcesFilter()): Dedup by (provider, source, quality) at render time.
  3. BG scan merge (_run_background_scan()): URL als Primary Key + (provider, hoster-domain) als Secondary Key.

BG-Probe Optimierung

  • Bereits geprobte Sources (mit _probe Daten aus DB) werden uebersprungen
  • Nur ungeprobte Sources werden resolved + geprobt
  • Spart ~5-15s pro bereits geprobte Source
  • Wenn alle bereits geprobt: kein Probe-Thread, kein Container.Refresh

Performance

  • Cache-Hit Path: ~15ms (DB lookup 5ms + is_fully_scanned 10ms)
  • Lazy resolveurl Import: ~709ms gespart (nur in sourcesResolve() wo noetig)
  • Lazy ThreadPoolExecutor: ~10ms gespart (@property mit lazy creation)

DB Schema (sourcecache.db)

Tabellen

source — Gecachte Streaming-Quellen

CREATE TABLE IF NOT EXISTS source (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    imdb_id TEXT NOT NULL,
    provider TEXT NOT NULL,
    hoster TEXT NOT NULL,
    quality TEXT,
    url TEXT NOT NULL,
    direct INTEGER DEFAULT 0,
    language TEXT DEFAULT 'de',
    info TEXT DEFAULT '',
    priority INTEGER DEFAULT 100,
    prio_hoster INTEGER DEFAULT 100,

    -- Pipeline: Resolve-Ergebnis (NULL = noch nicht resolved)
    resolved_url TEXT DEFAULT NULL,
    -- NULL = noch nicht resolved
    -- '' = Resolve fehlgeschlagen
    -- URL = aufgeloeste Stream-URL

    -- Probe-Daten (NULL = noch nicht geprobt)
    probe_width INTEGER,
    probe_height INTEGER,
    probe_bitrate TEXT,
    probe_audio_lang TEXT,
    probe_has_audio INTEGER DEFAULT 1,
    probe_duration TEXT,
    probe_codec TEXT,
    probe_failed INTEGER DEFAULT 0,
    probe_at INTEGER,

    created_at INTEGER NOT NULL,
    expires_at INTEGER NOT NULL,

    UNIQUE(imdb_id, provider, hoster, quality)
);

movie_status — Film-Scan-Status

CREATE TABLE IF NOT EXISTS movie_status (
    imdb_id TEXT PRIMARY KEY,
    scan_at INTEGER,
    scan_source_count INTEGER DEFAULT 0,
    probe_at INTEGER,
    probe_count INTEGER DEFAULT 0,
    probe_ok_count INTEGER DEFAULT 0
);

movie — Persistente Filmliste (DB-Driven Precache)

CREATE TABLE IF NOT EXISTS movie (
    imdb_id TEXT PRIMARY KEY,
    tmdb_id INTEGER,
    title TEXT NOT NULL,
    originaltitle TEXT DEFAULT '',
    year TEXT DEFAULT '',
    added_at INTEGER NOT NULL,      -- Wann in DB eingefuegt
    source TEXT DEFAULT 'discover',  -- 'discover', 'trailer', 'play'
    priority INTEGER DEFAULT 0       -- 0=normal, 1=trailer (hoehere Prio)
);
  • source: Woher der Film kam (discover=TMDB Trend/Neu, trailer=Trailer-Klick, play=User-Klick)
  • priority: Trailer-Filme bekommen priority=1, werden zuerst gescannt
  • added_at: Fuer Sortierung (neueste zuerst bei gleicher Prioritaet)
  • add_movie(): INSERT OR IGNORE — bekannte Filme nicht ueberschreiben
  • add_movie_priority(): ON CONFLICT DO UPDATE SET priority, source — fuer Trailer

Siehe DB-DRIVEN.md fuer Details.

movie_provider — Junction: Film → aufgerufene Provider

CREATE TABLE IF NOT EXISTS movie_provider (
    imdb_id TEXT NOT NULL,
    provider TEXT NOT NULL,
    called_at INTEGER NOT NULL,
    hits INTEGER DEFAULT 0,        -- NULL = in-flight, 0 = kein Treffer, >0 = Anzahl Quellen
    PRIMARY KEY (imdb_id, provider)
);
  • hits IS NULL: Scan dispatcht aber noch kein Ergebnis (in-flight)
  • hits = 0: Provider aufgerufen, keine Quellen gefunden
  • hits > 0: Provider hat N Quellen gefunden

provider — Provider-Statistiken

CREATE TABLE IF NOT EXISTS provider (
    name TEXT PRIMARY KEY,
    priority INTEGER DEFAULT 1,
    call_count INTEGER DEFAULT 0,
    hit_count INTEGER DEFAULT 0,
    total_duration_ms INTEGER DEFAULT 0,
    last_seen_at INTEGER NOT NULL,
    created_at INTEGER NOT NULL
);

Siehe PROVIDER-STATS.md fuer Score-Formel und Tracking.

hoster — Hoster-Statistiken + Busy-Tracking + Stream-Type

CREATE TABLE IF NOT EXISTS hoster (
    name TEXT PRIMARY KEY,
    last_used_at INTEGER DEFAULT 0,
    call_count INTEGER DEFAULT 0,
    ok_count INTEGER DEFAULT 0,
    fail_count INTEGER DEFAULT 0,
    busy_since INTEGER DEFAULT NULL,  -- NULL = frei, timestamp = Resolve in-flight
    stream_type TEXT DEFAULT NULL      -- NULL = unbekannt, 'mp4'/'hls'/'dash' = gelernt
);
  • busy_since wird bei dispatch_probe() gesetzt (vor Resolve-Dispatch)
  • Timeout: 20s (Resolve braucht bis zu 15s)
  • Timestamp wird NIE geloescht — bleibt als Beweis, wird bei naechstem Dispatch ueberschrieben
  • stream_type wird beim ersten erfolgreichen MediaInfo-Probe gelernt
    • Danach: Typ-Erkennung (8KB peek) ueberspringen, direkt zum richtigen Prober
    • Spart ~200-400ms pro Probe

Trigger

source_deleted_cleanup

Raeumt movie_provider auf wenn Sources geloescht werden (z.B. TTL-Ablauf). Wenn die letzte Source eines Providers fuer einen Film geloescht wird, wird der Provider-Eintrag entfernt. Dadurch wird is_fully_scanned() und get_phase() automatisch aktualisiert.

CREATE TRIGGER IF NOT EXISTS source_deleted_cleanup
AFTER DELETE ON source
BEGIN
    DELETE FROM movie_provider
    WHERE imdb_id = OLD.imdb_id AND provider = OLD.provider
    AND NOT EXISTS (
        SELECT 1 FROM source WHERE imdb_id = OLD.imdb_id
        AND provider = OLD.provider AND expires_at > strftime('%s','now')
    );
END;

VIEW: v_movie_phase — Dynamische Phase-Berechnung

Phase wird nie gespeichert sondern per SQL VIEW live berechnet. Die VIEW wird bei jedem _ensure_table() neu erstellt (DROP + CREATE).

CREATE VIEW IF NOT EXISTS v_movie_phase AS
SELECT m.imdb_id, m.tmdb_id, m.title, m.originaltitle, m.year,
       m.priority, m.added_at, m.source AS movie_source,
    CASE
        WHEN sources=0 AND alle_provider_aufgerufen   THEN 'done'
        WHEN sources=0                                 THEN '1a'
        WHEN hd_sources > 0                            THEN 'done'
        WHEN unprobed_sources > 0                      THEN '1b'
        WHEN NOT alle_provider_aufgerufen              THEN '2'
        ELSE 'done'
    END AS phase,
    source_count, unprobed_count, hd_count
FROM movie m
LEFT JOIN (...aggregierte Source-Counts...) ...;

Phase-Reihenfolge (WICHTIG)

  1. Keine Sources + alle Provider → done
  2. Keine Sources + Provider uebrig → 1a
  3. HD gefunden → done (VOR unprobed-Check!)
  4. Ungeprobte Sources (excl. probe_failed) → 1b
  5. Kein HD + Provider uebrig → 2
  6. Alle Provider, kein HD → done

Kritisch: HD-Check MUSS vor unprobed-Check kommen. Ein Film mit HD-Source ist fertig, auch wenn noch ungeprobte Sources existieren.

Source-Counts (nur nicht-abgelaufene)

  • source_count: Alle Sources mit expires_at > now
  • unprobed_count: Sources mit probe_at IS NULL AND probe_failed = 0
  • hd_count: Sources mit probe_height >= 720 AND probe_failed = 0

Nutzung

# Alle scanbaren Filme (eine einzige Query statt N+1)
get_scannable_movies()  # SELECT * FROM v_movie_phase WHERE phase != 'done'

# Phase fuer einzelnen Film
get_phase(imdb_id)      # SELECT phase FROM v_movie_phase WHERE imdb_id = ?

Indizes

CREATE INDEX IF NOT EXISTS idx_source_imdb ON source(imdb_id);
CREATE INDEX IF NOT EXISTS idx_source_expires ON source(expires_at);
CREATE INDEX IF NOT EXISTS idx_source_probe ON source(imdb_id, probe_height);
CREATE INDEX IF NOT EXISTS idx_movie_tmdb ON movie(tmdb_id);
-- Covering-Index: alle 3 LEFT JOINs in v_movie_phase als Index-Only-Scan
CREATE INDEX IF NOT EXISTS idx_source_phase ON source(imdb_id, expires_at, probe_at, probe_failed, probe_height);
  • idx_source_phase: Covering-Index fuer die VIEW-Subqueries. SQLite kann Source-Count, Unprobed-Count und HD-Count komplett aus dem Index lesen ohne die source-Tabelle zu oeffnen.

Migrationen (_ensure_table)

Migrationen laufen automatisch beim Import von sourcecacheDB.py:

  1. JSON → Relational: source_cache + scan_statussource + movie_status
  2. called_providers + phase — entfernt (Legacy, movie_provider + v_movie_phase ersetzen beides)
  3. hoster Stats: call_count, ok_count, fail_count Spalten
  4. hoster busy_since: busy_since INTEGER DEFAULT NULL
  5. hoster stream_type: stream_type TEXT DEFAULT NULL (Pipeline)
  6. source resolved_url: resolved_url TEXT DEFAULT NULL (Pipeline)
  7. movie_provider hits: hits INTEGER DEFAULT 0
  8. Probe-Reset: Sources ohne Dauer (alte Probes) werden zurueckgesetzt
  9. movie-Tabelle: Persistente Filmliste + v_movie_phase VIEW + idx_movie_tmdb Index

Settings

ID Typ Default Beschreibung
precache.ttl slider (1-30) 14 Cache-Gueltigkeit in Tagen
precache.scan_cooldown slider (24-168) 72 Scan-Cooldown in Stunden

Quellen-Precaching Design

Feature: Background Source Precaching for xShip Status: IMPLEMENTIERT (Pool-basierter Dispatcher) Datum: 2026-03-14 Base path: xS-Home/plugin.video.xship/


1. Problem

Der Quellen-Scan in xShip dauert 5-30 Sekunden pro Film. User klickt "Play" und wartet auf den Progress-Dialog, bis ~16 Scraper parallel alle Streaming-Seiten durchsucht haben. Erst danach erscheint die Quellenliste.

2. Loesung

Hintergrund-Precaching: service.py prueft alle 10s ob xShip aktiv ist und startet einen Daemon-Thread der populaere Filme scannt. Ergebnisse in SQLite cachen. Bei Cache-Hit wird der komplette Scan uebersprungen — Quellen sind sofort da (<15ms).

Scraper geben Hoster-Page-URLs zurueck (z.B. voe.sx/e/abc123), nicht finale Stream-URLs. Diese sind wochen- bis monatelang stabil. Aufloesung zum Stream erst beim Abspielen via ResolveURL. Gecacht wird nur die stabile Hoster-URL (via ResolvedUrl str-Subklasse in utils.py).

3. Phasen pro Film

Jeder Film durchlaeuft eigenstaendig 4 Phasen. Phase wird dynamisch aus DB-Zustand berechnet (get_phase() via movie_provider + source + provider Tabellen). Kein gespeicherter Status — immer aktuell.

Phase Ziel Aktion Exit
1a Schnell eine Quelle finden 1 Provider aufrufen Treffer → 1b, kein Treffer → naechster Provider
1b Qualitaet pruefen Ungeprobte Quellen proben (MediaInfo) HD ≥720p → done, sonst → 2
2 Luecken fuellen Weitere Provider scannen + sofort proben HD gefunden → done, alle Provider durch → done
done Fertig Kein weiterer Scan noetig Cache-Ablauf oder Force-Rescan

4. Architektur

Der BG-Scan nutzt einen Pool-basierten Dispatcher (ThreadPoolExecutor). Scans und Probes laufen parallel, DB trackt Busy-State. Details: → DISPATCHER.md

service.py (alle 10s)
    +-- start_if_needed()
        +-- Daemon-Thread: _run_cycle()
            +-- cleanup, TMDB Discover, Metadaten, Scraper laden
            +-- _run_loop(): Pool-Dispatcher (5 Worker)
                +-- Scans + Probes interleaved bis BEIDE Queues leer
                +-- Throttle bei Wiedergabe (Pool=1)
            +-- 5-Min Intervall → naechster Zyklus

5. Dateien

Datei Beschreibung
resources/lib/precacher.py 3-Queue Dispatcher, _run_loop(), _scan_single(), _resolve_single(), _mediainfo_single()
resources/lib/sourcecacheDB.py SQLite Schema, Dispatch-Helpers, Phase-Berechnung, Provider-Stats
resources/lib/sources.py Cache-Hit Path, BG-Scan, Early Exit, Failed Source Cleanup
resources/lib/utils.py ResolvedUrl str-Subklasse (stabile Hoster-URLs)
service.py 10s Monitoring-Loop, startet Precache-Thread
default.py Heartbeat, clearSourceCache, forceRescan

6. Weitere Dokumente

Dokument Inhalt
DB-SCHEMA.md Tabellen, Spalten, Trigger, Migrationen
DISPATCHER.md Pool-Dispatcher, Busy-Tracking, Throttle
PROVIDER-STATS.md Scoring, Reihenfolge, Trust
CACHE-HIT.md Manueller Klick, BG-Scan Trigger
TRAILER-SCAN.md Trailer-Triggered Precache: Film beim Trailer-Abspielen vormerken
DB-DRIVEN.md Redesign: movie-Tabelle statt In-Memory-Liste, automatischer Cache-Refresh
SCENARIOS.md User-Szenarien: Was passiert bei Klick? Cache-Hit, BG-Scan, Probe, Fehler

3-Queue Pipeline Dispatcher

Ueberblick

_run_loop() in precacher.py nutzt ThreadPoolExecutor(5) mit 3 spezialisierten Queues:

  1. Scan (Provider → Quellen finden)
  2. Resolve (Hoster-URL → Stream-URL via ResolveURL)
  3. MediaInfo (Stream-URL → Resolution/Bitrate/Audio)

Prioritaet: Resolve > MediaInfo > Scan. Pipeline vorwaerts: erst fertige Items durchpushen, restliche Slots fuer neue Scans.

Dispatch-Zyklus

while _is_active():
    1. Freie Slots = pool_size(5) - len(futures)
    2. Pool voll? → wait(FIRST_COMPLETED), continue
    3. Alle 3 Queues abfragen
    4. Resolves dispatchen (max free_slots) → free_slots -= dispatched
    5. MediaInfos dispatchen (max free_slots) → free_slots -= dispatched
    6. Scans dispatchen (max free_slots) → free_slots -= dispatched
    7. sleep(1)
    8. wait(FIRST_COMPLETED) → _process_result()
    9. Keine Futures + Queues leer? → idle_rounds++ → break nach 3
   10. Keine Futures + Queues nicht leer? → sleep(3) (Hosters temporaer busy)

Slot-Verwaltung

  • Pool-Groesse: 5 Worker
  • Kein Over-Queuing: Nur free_slots Items pro Runde dispatcht
  • Slot-Filling: Sobald ein Slot frei wird (FIRST_COMPLETED), sofort naechstes Item dispatchen
  • Queue-Prioritaet: Resolve > MediaInfo > Scan (Pipeline vorwaerts, Scans bekommen Rest)
  • Einfaches Fill-Up: Kein proportionales Balancing noetig — bei Cold-Start dominieren Scans (keine Resolves/MediaInfos), danach gleichen sich die Queues natuerlich aus

Busy-Tracking in DB

Provider-Busy (movie_provider.hits)

Kein neues Feld noetig. Bestehende movie_provider-Tabelle:

  • Dispatch: INSERT (imdb, provider, called_at=now, hits=NULL) — NULL = in-flight
  • Fertig: UPDATE hits = count (0 oder >0)
  • Busy-Check: hits IS NULL AND (now - called_at) <= 5 → Provider beschaeftigt (5s Scan-Timeout)
  • (now - called_at) > 5 mit hits IS NULL → Request verloren, Provider frei fuer ANDERE Filme
  • Called-Check: get_called_providers() gibt ALLE Provider zurueck (inkl. in-flight) — verhindert Re-Dispatch fuer denselben Film

Hoster-Busy (hoster.busy_since)

  • Dispatch: UPDATE busy_since = now (vor Resolve-Dispatch)
  • Busy-Check: busy_since IS NOT NULL AND (now - busy_since) <= 20 → Hoster beschaeftigt (20s Resolve-Timeout)
  • (now - busy_since) > 20 → Request verloren oder fertig, Hoster frei
  • busy_since wird NIE geloescht — bleibt als Beweis, wird bei naechstem Dispatch ueberschrieben
  • MediaInfo braucht KEIN Busy-Tracking — greift auf CDN-URL zu, nicht auf Hoster

Timeouts

Typ Timeout Grund
Scan 5s Scraper-Calls normalerweise < 5s
Resolve 20s ResolveURL braucht bis zu 15s
MediaInfo Kein Tracking noetig (CDN-URL, kein Hoster-Limit)

Kein Cleanup noetig. Timestamps bleiben stehen. Busy-Check nutzt Zeitfenster.

Scan-Dispatch (Queue 1)

Pro Runde: 1 Scan pro Film, 1 Film pro Provider (max free_slots Items)

def get_dispatchable_scans(movie_imdb_ids, available_providers):
    busy_providers = SELECT DISTINCT provider FROM movie_provider
                     WHERE hits IS NULL AND (now - called_at) <= 5
    free_providers = available - busy
    for imdb_id in movie_imdb_ids:
        phase = get_phase(imdb_id)  # nur 1a oder 2
        called = get_called_providers(imdb_id)  # ALLE (inkl. in-flight)
        for name in get_ordered_providers():
            if name in free_providers and name not in called:
                items.append((imdb_id, name))
                free_providers.discard(name)  # Provider nur 1x pro Runde
                break

HD-Skip: get_phase() gibt done zurueck wenn Film HD hat → Film nicht in Scan-Queue.

Resolve-Dispatch (Queue 2)

Pro Runde: 1 Resolve pro freien Hoster (max restliche free_slots)

def get_dispatchable_resolves():
    SELECT s.* FROM source s
    LEFT JOIN hoster h ON s.hoster = h.name
    WHERE s.resolved_url IS NULL AND s.probe_at IS NULL AND s.probe_failed = 0
    AND (h.busy_since IS NULL OR (now - h.busy_since) > 20)
    AND NOT EXISTS (  -- HD-Skip
        SELECT 1 FROM source s2 WHERE s2.imdb_id = s.imdb_id
        AND s2.probe_height >= 720 AND s2.expires_at > now)
    GROUP BY s.hoster  -- 1 pro Hoster

MediaInfo-Dispatch (Queue 3)

Pro Runde: alle resolved Sources die noch nicht geprobt sind (max restliche free_slots)

def get_dispatchable_mediainfos():
    SELECT s.* FROM source s
    WHERE s.resolved_url IS NOT NULL AND s.resolved_url != ''
    AND s.probe_at IS NULL AND s.probe_failed = 0
    AND NOT EXISTS (  -- HD-Skip
        SELECT 1 FROM source s2 WHERE s2.imdb_id = s.imdb_id
        AND s2.probe_height >= 720 AND s2.expires_at > now)
    ORDER BY s.created_at

Kein Hoster-Busy-Check — MediaInfo greift auf CDN-URL zu, nicht auf Hoster.

HD-Skip (Film-Level)

Alle 3 Queues pruefen: Hat der Film bereits eine HD-Quelle (probe_height >= 720)?

  • Scans: get_phase() gibt done zurueck → Film nicht in Scan-Queue
  • Resolves: NOT EXISTS (... probe_height >= 720 ...) in Query
  • MediaInfos: Gleicher Check in Query

Wenn erste Probe fuer Film X HD findet → alle weiteren Sources fuer Film X werden automatisch uebersprungen (naechste Dispatch-Runde sieht HD in DB).

Worker-Funktionen

_resolve_single(src, provider_sources)

Pipeline Schritt 1: Hoster-URL → Stream-URL.

  1. _resolve_source(provider_sources, src) via ResolveURL
  2. Erfolg: set_resolved_url(url) + hoster_touch(success=True)
  3. Fehler: set_resolved_url('') + update_probe(failed=True) + hoster_touch(success=False)

_mediainfo_single(src)

Pipeline Schritt 2: Stream-URL → Resolution/Bitrate.

  1. get_hoster_stream_type(hoster) — bekannter Typ? → skip Typ-Erkennung
  2. getMediaInfo(resolved_url, NullDialog, deadline, stream_type_hint=...)
  3. Parse: _parse_probe_info(info_str)
  4. Speichern: update_probe(probe_data)
  5. Stream-Type lernen: wenn Hoster-Typ unbekannt, aus Probe-Ergebnis setzen

Hoster Stream-Type (Lern-Cache)

hoster.stream_type TEXT DEFAULT NULL
-- NULL → normaler Probe-Ablauf (Typ-Erkennung via 8KB peek)
-- 'mp4'/'hls'/'dash' → direkt zum richtigen Prober

Lern-Ablauf

  1. Erster Probe fuer Hoster X → normaler Ablauf (8KB peek + Typ-Erkennung)
  2. MediaInfo erkennt Typ (z.B. 'mp4') → hoster.stream_type = 'mp4'
  3. Alle weiteren Probes fuer Hoster X → kein 8KB peek, direkt _probeDirect()
  4. Spart ~200-400ms pro Probe

Throttle bei Wiedergabe

Wenn ein Video laeuft:

  1. Alle laufenden Futures abwarten (wait(ALL_COMPLETED))
  2. free_slots = min(free_slots, 1) → nur 1 Item dispatchen
  3. Effektiv: Pool hat 5 Slots, aber nur 1 gleichzeitiger Request
  4. Gilt fuer alle 3 Queues zusammen

Loop-Ende

Loop endet wenn ALLE 3 Queues leer:

  • Keine Scans: kein Film in Phase 1a/2
  • Keine Resolves: get_dispatchable_resolves() leer
  • Keine MediaInfos: get_dispatchable_mediainfos() leer
  • Idle 3 Runden → break
  • Oder: _is_active() = False (User hat xShip verlassen)
  • 5-Min Intervall ist nur Start-Trigger fuer naechsten Zyklus, KEIN Timeout

_process_result()

Verarbeitet abgeschlossene Futures:

  • Scan: save_sources() + complete_scan(hits=N) → macht Provider frei
  • Resolve: Ergebnis schon in DB (set_resolved_url + hoster_touch in _resolve_single)
  • MediaInfo: Ergebnis schon in DB (update_probe in _mediainfo_single)
  • Exception: complete_scan(hits=0) bei Scan-Fehler, Hoster-Timeout laeuft automatisch ab

Load Balancing: Gleichmaessige Cache-Erneuerung

Feature: Langfristige Lastverteilung fuer Quellen-Precaching Status: ENTWURF Datum: 2026-03-14


1. Problem

Der aktuelle Precache arbeitet burst-basiert:

  1. xShip startet → grosser Scan-Burst (35+ Filme, alle Provider)
  2. Ergebnisse gueltig fuer 14 Tage (TTL)
  3. 14 Tage Leerlauf — kein Scan, kein Netzwerk-Traffic
  4. TTL laeuft ab → alles auf einmal ungueltig → naechster Burst
Woche 1  |████████████████|  ← Burst: 35 Filme × 16 Provider
Woche 2  |                |  ← Leerlauf
Woche 3  |████████████████|  ← Naechster Burst

Probleme:

  • Spitzenlast: Erster Zyklus dauert 15-30 Min (alle Filme + Probes)
  • Veralteter Cache: Am Tag 13 sind alle Quellen fast abgelaufen
  • Keine Reserve-Kapazitaet: Waehrend des Bursts keine Zeit fuer zusaetzliche Listen
  • Alles-oder-nichts: Quellen laufen gleichzeitig ab, kein gestaffelter Uebergang

2. Ziel

Last gleichmaessig ueber die TTL-Periode verteilen:

Woche 1  |████            |  ← Initiales Fuellen
Woche 2  |  ██            |  ← Anteilige Erneuerung
Woche 3  |    ██          |  ← Anteilige Erneuerung

Vorteile:

  • Gleichmaessige Ressourcennutzung (kein Peak/Leerlauf-Wechsel)
  • Frischerer Cache (Quellen werden vor Ablauf erneuert)
  • Freie Kapazitaet fuer zusaetzliche Filmlisten
  • Bessere User-Erfahrung (immer aktuelle Quellen)

3. Vorschlaege

3.1 TTL-gesteuerte Erneuerung

Statt auf Ablauf zu warten: Sources proaktiv erneuern wenn sie "bald" ablaufen.

Prinzip: Bei TTL = 14 Tage taeglich 1/14 der Sources erneuern.

REFRESH_WINDOW = 2 * 86400  # 2 Tage vor Ablauf

def get_expiring_movies(window_days=2):
    """Filme deren Sources bald ablaufen, aelteste zuerst."""
    now = int(time.time())
    threshold = now + (window_days * 86400)
    return cursor.execute("""
        SELECT DISTINCT imdb_id,
               MIN(expires_at) as earliest_expiry
        FROM source
        WHERE expires_at > ? AND expires_at <= ?
        GROUP BY imdb_id
        ORDER BY earliest_expiry ASC
    """, (now, threshold)).fetchall()

Priorisierung:

  1. Sources die in < 2 Tagen ablaufen (dringend)
  2. Filme die der User haeufig angeklickt hat (wertvoll)
  3. Filme mit nur SD-Quellen (Hoffnung auf HD bei Rescan)
  4. Rest nach Alter (aelteste zuerst)

3.2 Zusaetzliche Filmlisten

Aktuell nur 2 Listen: "Neu im Kino" + "Im Trend". Weitere TMDB-Listen:

Liste TMDB Endpoint Nutzen
Beliebteste movie/popular Mainstream-Filme vorab gecacht
Bestbewertet movie/top_rated Klassiker, stabiler Content
Box Office discover + revenue.gte Blockbuster mit hoher Klick-Wahrscheinlichkeit
Genre-spezifisch discover + with_genres User-Praeferenzen bedienen

Konfiguration: Setting welche Listen aktiv sind.

<setting id="precache.lists.popular" type="bool" label="Beliebteste scannen" default="false" />
<setting id="precache.lists.toprated" type="bool" label="Bestbewertet scannen" default="false" />
<setting id="precache.lists.boxoffice" type="bool" label="Box Office scannen" default="false" />

3.3 Gesehene Filme ueberspringen

xShip speichert Watch-History in playcountDB.py (SQLite playcount.db):

def is_watched(imdb_id):
    """Prueft ob Film als gesehen markiert ist."""
    from resources.lib.playcountDB import getPlaycount
    pc = getPlaycount('movie', 'imdb_id', imdb_id)
    return pc is not None and pc > 0

Logik:

  • Gesehene Filme aus Scan-Queue entfernen
  • Bestehende gecachte Quellen duerfen ablaufen (kein aktives Loeschen)
  • Setting: precache.skip_watched (default: true)
<setting id="precache.skip_watched" type="bool"
         label="Gesehene Filme ueberspringen" default="true" />

Einschraenkung: playcountDB trackt nur Filme die ueber xShip abgespielt wurden. Kodis native videodb waere umfassender, aber der Zugriff ist komplexer (JSON-RPC oder direkte DB).

3.4 Wartungsmodus nach Erstbefuellung

Zwei Modi in _run_cycle():

Modus Trigger Verhalten
Initial Keine/wenige Sources im Cache Voller Scan aller Listen (wie bisher)
Wartung Cache gefuellt, nur Erneuerung noetig Nur ablaufende Sources refreshen
def _run_cycle():
    cleanup_expired()

    # Modus bestimmen
    stats = get_stats()
    expiring = get_expiring_movies(window_days=2)

    if stats['unique_movies'] < 10:
        # Initial: voller Scan
        movie_ids = _fetch_all_lists()
    elif expiring:
        # Wartung: nur ablaufende Filme
        movie_ids = [row['imdb_id'] for row in expiring]
    else:
        # Alles frisch — nichts zu tun
        return

    # ... Rest wie bisher (Metadaten, Scraper, _run_loop)

Throttle im Wartungsmodus:

  • Max 5 Filme pro Zyklus refreshen (nicht alles auf einmal)
  • Kuerzeres Intervall moeglich (z.B. alle 30 Min statt 5 Min)
  • Bei Wiedergabe: Wartung komplett pausieren

3.5 Listen-Rotation

Nicht alle Listen in jedem Zyklus scannen:

Zyklus 1: Neu im Kino + Im Trend       (Pflichtlisten, immer)
Zyklus 2: Neu + Trend + Beliebteste     (1 Extra-Liste rotieren)
Zyklus 3: Neu + Trend + Box Office
Zyklus 4: Neu + Trend + Bestbewertet
Zyklus 5: Neu + Trend + Genre (Action)

Umsetzung: Rotations-Index in DB oder Window Property.

_EXTRA_LISTS = ['popular', 'toprated', 'boxoffice']

def _get_rotation_list():
    """Naechste Extra-Liste aus Rotation."""
    idx = int(xbmcgui.Window(10000).getProperty('precache.rotation') or '0')
    enabled = [l for l in _EXTRA_LISTS if getSetting('precache.lists.' + l) == 'true']
    if not enabled:
        return None
    pick = enabled[idx % len(enabled)]
    xbmcgui.Window(10000).setProperty('precache.rotation', str(idx + 1))
    return pick

3.6 Prioritaetsbasierte Erneuerung

Nicht alle Filme gleich behandeln — dynamische Refresh-Prioritaet:

Faktor Effekt auf Refresh-Prio
User hat Film angeklickt Hoehere Prio (haeufiger refreshen)
Film hat HD-Quelle Niedrigere Prio (kein dringender Bedarf)
Film hat nur SD Hoehere Prio (bei Rescan evtl. HD verfuegbar)
Source fast abgelaufen Hoehere Prio (dringend)
Film in mehreren Listen Hoehere Prio (populaerer)
-- Refresh-Score pro Film (hoeher = dringender)
SELECT s.imdb_id,
    -- Ablauf-Dringlichkeit (0-100, hoeher = bald ablaufend)
    MAX(0, 100 - ((MIN(s.expires_at) - ?) / 86400.0 * 7)) as urgency,
    -- HD-Penalty (HD vorhanden = weniger dringend)
    CASE WHEN MAX(s.probe_height) >= 720 THEN 0 ELSE 30 END as quality_bonus,
    -- Klick-Bonus (aus bookmarkDB oder kuenftigem Click-Tracking)
    0 as click_bonus
FROM source s
WHERE s.expires_at > ?
GROUP BY s.imdb_id
ORDER BY (urgency + quality_bonus + click_bonus) DESC

3.7 Smart Refresh (qualitaetsabhaengig)

Refresh ist nicht gleich Initial-Scan. Wir haben movie_provider-Historie — wir wissen welche Provider was geliefert haben. Das nutzen wir.

Drei Strategien im Vergleich:

Strategie Aufwand Vorteil Nachteil
Full Rescan (alle Provider) Hoch (13+ Calls/Film) Findet neue/bessere Quellen 80% Provider liefern 0 — Verschwendung
Known-Good only (nur hits>0) Niedrig (2-3 Calls/Film) Schnell, verifiziert bestehende Verpasst neue Provider
Smart: Known-Good + 1 exploratory Mittel (3-4 Calls/Film) Schnell + Chance auf Neues Etwas mehr Traffic

Empfehlung: Smart Refresh, differenziert nach Qualitaet:

Film hat HD-Quelle

Besten ungetesteten Provider zuerst, known-good HD-Provider als Fallback.

movie_provider-Historie:
  megakino (prio 8)   → hits=2 (davon 1x HD via Probe)
  filmpalast (prio 1) → nie getestet fuer diesen Film

Refresh-Reihenfolge:
1. filmpalast (bester ungetesteter, prio 1) → HD? → neuer Top-Source!
2. Falls kein HD: megakino (known-good HD) re-verifizieren
3. Falls megakino fail: naechster ungetesteter

Rationale: Known-good HD-Provider hat evtl. schlechte Prio. Besserer Provider koennte gleiche/bessere Qualitaet haben. Im schlimmsten Fall: 2 Calls. Im besten: 1 Call mit besserem Ergebnis.

Film hat nur SD-Quellen

Known-Good Provider + 1 neuen/ungetesteten Provider pro Refresh-Zyklus.

movie_provider-Historie:
  einschalten → hits=1 (SD)
  filmpro     → hits=1 (SD)
  filmpalast  → hits=0

Refresh: einschalten + filmpro (known-good) + kinoger (neu, noch nie probiert)
Chance auf HD-Upgrade durch neuen Provider.

Film hatte 0 Hits

1 Provider pro Zyklus rotieren. Film wurde vielleicht inzwischen bei einem Provider hinzugefuegt.

Letzter Scan: filmpalast (0), einschalten (0), filmpro (0)
Refresh: moflix (naechster in Rotation) — nur 1 Call

Was behalten nach Refresh

Top 3 nach echter Probe-Qualitaet (nicht Provider-Label), mit mindestens 2 verschiedenen Providern wenn moeglich. Rest loeschen.

Gruende:

  • Ein Film braucht keine 15 Quellen wenn 3 davon HD sind
  • Weniger DB-Platz, weniger Probe-Zeit beim naechsten Refresh
  • Provider-Diversitaet als Fallback (Provider A down → Provider B hat auch HD)
def _prune_sources_after_refresh(imdb_id, keep_n=3, min_providers=2):
    """Behaelt Top-N Sources nach Qualitaet, Rest loeschen."""
    sources = get_sources(imdb_id)  # sortiert nach quality_score
    kept = []
    providers_seen = set()
    for src in sources:
        if len(kept) < keep_n:
            kept.append(src)
            providers_seen.add(src['provider'])
        elif len(providers_seen) < min_providers and src['provider'] not in providers_seen:
            # Provider-Diversitaet sicherstellen
            kept.append(src)
            providers_seen.add(src['provider'])
    # Rest loeschen
    to_delete = [s for s in sources if s not in kept]
    for s in to_delete:
        delete_source(imdb_id, s['provider'], s['hoster'], s['quality'])

DB-Unterstuetzung

movie_provider hat bereits alles was noetig ist:

  • hits > 0 → known-good Provider
  • hits = 0 → Provider ohne Ergebnis
  • Provider-Reihenfolge via provider.priority (dynamisch)
  • Neuer Provider = nicht in movie_provider fuer diesen Film

Kein neues Schema noetig. Nur die Refresh-Logik in _run_cycle() muss zwischen Initial und Refresh unterscheiden.

3.8 Refresh-Tiers und Intervalle

Nicht alle Filme gleich oft refreshen. 3 Tiers nach Qualitaet:

Tier Bedingung Refresh-Intervall Scan-Strategie
A — HD probe_height >= 720 1× pro TTL (14d) Bester ungetesteter + known-good Fallback
B — nur SD Sources vorhanden, kein HD 1× pro TTL (14d) Known-good + 1 neuer Provider
C — keine Sources Full-Scan 0 Hits 1× pro 2× TTL (28d) 1 Provider rotierend

Intervall-Logik (zwei Modi):

Modus Trigger Intervall Zweck
Arbeits-Modus Queues haben Items 5 Min Unterbrochene Arbeit schnell fortsetzen
Idle-Modus Alle Queues leer 60 Min Periodischer Check auf ablaufende Sources
# In start_if_needed():
has_work = get_pending_work_count() > 0  # Scan/Resolve/MediaInfo Queues
interval = 5 * 60 if has_work else 60 * 60  # 5min vs 60min

5-Min Intervall ist nur fuer den Fall dass xShip verlassen wird und wir weitermachen muessen. Wenn alle Queues fertig sind, reicht 1× pro Stunde zum Pruefen ob Sources bald ablaufen.

3.9 Sourceless Filme in der Liste kennzeichnen

Filme ohne Quellen nach Full-Scan in der UI markieren — User weiss VOR dem Klick dass nichts verfuegbar ist.

Umsetzung (in sources.py / listitem.py):

# Bei Cache-Hit: Pruefen ob Film sourceless + fully_scanned
if not cached_sources and sourcecacheDB.is_fully_scanned(imdb):
    # Titel markieren
    label = '[COLOR grey]%s[/COLOR] [keine Quellen]' % title
    # Oder im Plot ergaenzen
    plot = 'Keine Streaming-Quellen gefunden.\n\n' + original_plot

Optionen:

  • [COLOR grey]Filmtitel[/COLOR] — ausgegraut (subtil)
  • Filmtitel [–] — Minus-Zeichen (kompakt)
  • Filmtitel [keine Quellen] — explizit (klar)
  • Plot-Ergaenzung — sichtbar erst bei Info-Ansicht

Verhalten bei Klick:

  • Trotzdem Foreground-Scan starten (vielleicht hat sich was geaendert)
  • Oder sofort "Keine Quellen verfuegbar" Dialog zeigen (spart Zeit)
  • Konfigurierbar via Setting?

4. Offene Fragen

  • Watched-Erkennung: playcountDB nur fuer xShip-Wiedergaben. Kodis videodb per JSON-RPC abfragen (VideoLibrary.GetMovies mit filter: playcount > 0)? Oder reicht playcountDB?
  • TTL pro Source vs pro Film: Aktuell TTL pro Source (jede hat eigenes expires_at). Pro Film waere einfacher (ein Datum fuer alle Sources), aber weniger flexibel.
  • Aggressivitaet: Wie viele Filme pro Wartungs-Zyklus? Zu wenige = Cache laeuft trotzdem ab. Zu viele = wieder Burst-artig.
  • Separater Modus oder integriert?: Eigener _run_maintenance() oder in _run_cycle() einbauen? Letzteres ist einfacher, aber weniger uebersichtlich.
  • Neue Sources bei Rescan: Wenn ein Provider beim Rescan neue Quellen liefert (die beim Erstscann nicht da waren) — voller Probe-Zyklus oder nur neue Sources proben?
  • Rotations-Persistenz: Window Property geht bei Kodi-Neustart verloren. Besser in DB speichern?

5. Naechste Schritte

  1. get_expiring_movies() in sourcecacheDB.py — Query fuer bald ablaufende Filme
  2. Wartungs-Modus in _run_cycle() — Initial vs. Wartung unterscheiden
  3. precache.skip_watched Setting — playcountDB-Abfrage in Scan-Queue einbauen
  4. Throttle fuer Wartung — Max Filme pro Zyklus konfigurierbar
  5. Extra-Listen TMDB-Fetcher_fetch_movie_ids_popular(), _fetch_movie_ids_toprated() etc.
  6. Listen-Rotation — Rotations-Index + Setting pro Liste
  7. Refresh-Score Query — Prioritaetsbasierte Reihenfolge in Wartungsmodus
  8. Testing — Kuerzere TTL (z.B. 1 Tag) zum Testen des Wartungsmodus

Quellen-Precaching — Zusammenfassung

Was bringt es dem Endanwender?

Vorher (ohne Precaching)

  • User klickt auf einen Film → 5-30 Sekunden Wartezeit (Ladekreis)
  • 16 Scraper durchsuchen parallel Streaming-Seiten
  • Erst danach erscheint die Quellenliste
  • Jedes Mal die gleiche Wartezeit, auch beim zweiten Klick

Nachher (mit Precaching)

  • User klickt auf einen Film → Quellenliste sofort da (<15ms)
  • Populaere Filme wurden bereits im Hintergrund gescannt
  • Echte Resolutions (1080p, 720p) statt Scraper-Labels
  • Kaputte Quellen (Hoster down) automatisch aussortiert
  • Kein Unterschied in der Bedienung — alles passiert unsichtbar

Konkreter Vergleich

Ohne Precaching Mit Precaching
Erster Klick auf Film 5-30s Wartezeit <1s (sofort)
Quellen-Qualitaet Scraper-Labels ("1080p") Echte Resolution (1920x1080)
Kaputte Quellen Sichtbar in der Liste Automatisch entfernt
Sortierung Zufaellig / nach Label Nach echter Bitrate
Zweiter Klick Wieder 5-30s Wartezeit <1s (Cache)

Wie funktioniert es?

Hintergrund-Scan (unsichtbar)

Sobald der User xShip oeffnet, startet ein Hintergrund-Prozess:

  1. Holt populaere Filme von TMDB (Trending + Discover)
  2. Scannt Quellen fuer jeden Film (gleiche Scraper wie beim manuellen Klick)
  3. Prueft die Qualitaet jeder Quelle (Resolution, Bitrate, Audio)
  4. Speichert alles in einer lokalen SQLite-Datenbank

Der Scan laeuft nur wenn xShip aktiv ist und drosselt sich automatisch waehrend der Wiedergabe (max 1 paralleler Worker statt 5).

Cache-Hit (beim Klick)

Wenn der User einen Film anklickt:

  • Gibt es gecachte Quellen? → Sofort anzeigen
  • Gibt es echte HD-Quellen (≥720p)? → Fertig, kein weiterer Scan
  • Kein HD? → Hintergrund-Scan sucht weitere Provider
  • Hoster down? → Quelle wird automatisch aus der Liste entfernt

Phasen pro Film

Jeder Film durchlaeuft automatisch 4 Phasen:

Phase Beschreibung Wann fertig?
1a Ersten Provider scannen Quellen gefunden → weiter zu 1b
1b Qualitaet pruefen (MediaInfo) HD gefunden → fertig
2 Weitere Provider scannen HD gefunden oder alle Provider durch
done Fertig Cache-Ablauf (14 Tage) → erneuter Scan

Einstellungen

Alle Einstellungen unter xShip → Einstellungen → Quellen → Precaching:

Einstellung Standard Beschreibung
Precaching aktiviert An Hintergrund-Scan ein/ausschalten
Cache-Dauer (Tage) 14 Wie lange Quellen gueltig bleiben
Anzahl Filme 20 Wie viele Filme pro Zyklus gescannt werden
Scan-Intervall (Min) 5 Pause zwischen Scan-Zyklen
Scan-Cooldown (Std) 72 Mindestwartezeit bevor ein Film erneut gescannt wird

Zusaetzlich: "Cache loeschen" Button zum Zuruecksetzen.


Technische Highlights

  • 3-Queue Pipeline: Scan, Resolve und MediaInfo laufen parallel statt sequentiell
  • Provider-Statistiken: Schnellste/zuverlaessigste Scraper werden zuerst aufgerufen
  • HD-Skip: Sobald eine HD-Quelle gefunden wird, stoppt die Suche fuer diesen Film
  • Hoster-Lerneffekt: Stream-Typ (MP4/HLS/DASH) wird pro Hoster gelernt → spart ~200ms/Probe
  • Relationales DB-Schema: 6 Tabellen, 1 VIEW, Covering-Index fuer optimale Performance
  • Thread-sicher: Connection-per-Call Pattern (wie playcountDB.py)
  • Kein Eingriff in bestehende Funktionalitaet: Precaching ist rein additiv

Geaenderte Dateien

Datei Aenderung Zeilen
resources/lib/precacher.py NEU — Pool-Dispatcher, TMDB Discovery +531
resources/lib/sourcecacheDB.py NEU — SQLite Schema, Queries, Migrationen +1430
resources/lib/sources.py Cache-Hit, BG-Scan, BG-Probe, Scoring +879
resources/lib/mediainfo.py stream_type_hint Parameter +40
resources/lib/utils.py ResolvedUrl Klasse (stabile Hoster-URLs) +13
default.py forceRescan, clearCache, Trailer-Signal +32
service.py Precache-Monitor (10s Loop) +12
resources/settings.xml 5 neue Precache-Einstellungen +8

Probe-Pipeline: 3-Queue Dispatcher

Problem

Aktuell: _probe_single_pooled() blockiert einen Pool-Slot fuer die gesamte Dauer (Resolve 5-15s + MediaInfo 1-3s = 6-18s). Bei Pool-Groesse 5 heisst das max 5 gleichzeitige Probes. Resolve ist der Bottleneck (80% der Zeit), MediaInfo ist schnell.

Loesung: 3 Queues

Statt eines einzelnen Pools: 3 spezialisierte Queues mit eigenem Busy-Tracking.

Queue 1: SCAN (Provider aufrufen → Quellen finden)
    Pool: 5 Worker
    Input:  (imdb_id, provider_name)
    Output: Sources in DB (save_sources)
    Busy:   movie_provider.hits IS NULL (5s Timeout)

Queue 2: RESOLVE (Hoster-URL → Stream-URL)
    Pool: 5 Worker
    Input:  source aus DB (hoster-URL)
    Output: resolved_url in source-Tabelle (neue Spalte)
    Busy:   hoster.busy_since (20s Timeout)

Queue 3: MEDIAINFO (Stream-URL → Resolution/Bitrate)
    Pool: 3 Worker
    Input:  source mit resolved_url
    Output: probe_width/height/bitrate/codec in source-Tabelle
    Busy:   Kein DB-Tracking noetig (schnell, kein Hoster-Limit)

Dispatch-Zyklus (neu)

while _is_active():
    free = pool_size - len(futures)
    if free <= 0: wait(FIRST_COMPLETED), continue

    # Alle 3 Queues abfragen
    scans = get_dispatchable_scans()
    resolves = get_dispatchable_resolves()
    mediainfos = get_dispatchable_mediainfos()

    # Pipeline vorwaerts: erst fertige Items durchpushen
    # 1. Resolves zuerst (warten auf Scan-Ergebnisse)
    for src in resolves[:free]:
        dispatch_resolve(src.hoster)
        submit(_resolve_single, src)
        free -= 1

    # 2. MediaInfos (warten auf Resolve-Ergebnisse)
    for src in mediainfos[:free]:
        submit(_mediainfo_single, src)
        free -= 1

    # 3. Scans (neue Sources erzeugen — restliche Slots)
    for scan in scans[:free]:
        submit(_scan_single, ...)
        free -= 1
    sleep(1)

    # Ergebnisse verarbeiten
    wait(FIRST_COMPLETED) → _process_result()

Prioritaet: Resolve > MediaInfo > Scan

Pipeline vorwaerts: erst fertige Items durchpushen, restliche Slots fuer neue Scans. Bei Cold-Start sind Resolve/MediaInfo-Queues leer, alle Slots gehen an Scans. Sobald erste Scan-Ergebnisse da sind, fuellen Resolves und MediaInfos die Slots natuerlich auf. Kein kompliziertes Balancing noetig.

DB-Aenderungen

source-Tabelle: resolved_url Spalte

ALTER TABLE source ADD COLUMN resolved_url TEXT DEFAULT NULL;
-- NULL = noch nicht resolved
-- '' = Resolve fehlgeschlagen
-- URL = aufgeloeste Stream-URL

hoster-Tabelle: stream_type Spalte

ALTER TABLE hoster ADD COLUMN stream_type TEXT DEFAULT NULL;
-- NULL = unbekannt (1x testen, dann speichern)
-- 'mp4' = Direkter Stream (MP4/MKV)
-- 'hls' = HLS Manifest (.m3u8)
-- 'dash' = DASH Manifest (.mpd)

Beim ersten erfolgreichen MediaInfo-Probe eines Hosters: stream_type setzen. Danach: Typ-Erkennung (8KB peek / HEAD) ueberspringen, direkt zum richtigen Prober.

Dispatch-Queries

get_dispatchable_resolves()

SELECT s.* FROM source s
LEFT JOIN hoster h ON s.hoster = h.name
WHERE s.expires_at > ?
  AND s.resolved_url IS NULL           -- noch nicht resolved
  AND s.probe_failed = 0               -- nicht fehlgeschlagen
  AND (h.busy_since IS NULL OR (? - h.busy_since) > 20)  -- Hoster frei
  -- HD-Skip: Filme mit HD-Quelle ueberspringen
  AND NOT EXISTS (
    SELECT 1 FROM source s2 WHERE s2.imdb_id = s.imdb_id
    AND s2.probe_height >= 720 AND s2.expires_at > ?
  )
GROUP BY s.hoster  -- 1 pro Hoster
ORDER BY s.created_at

get_dispatchable_mediainfos()

SELECT s.* FROM source s
WHERE s.expires_at > ?
  AND s.resolved_url IS NOT NULL        -- resolved
  AND s.resolved_url != ''              -- nicht fehlgeschlagen
  AND s.probe_at IS NULL                -- noch nicht geprobt
  AND s.probe_failed = 0
  -- HD-Skip: Filme mit HD-Quelle ueberspringen
  AND NOT EXISTS (
    SELECT 1 FROM source s2 WHERE s2.imdb_id = s.imdb_id
    AND s2.probe_height >= 720 AND s2.expires_at > ?
  )
ORDER BY s.created_at
LIMIT ?  -- max free_slots

Kein Hoster-Busy-Check noetig — MediaInfo greift nicht auf den Hoster zu (nur auf die CDN-URL).

get_dispatchable_probes() (Legacy)

Bleibt als Fallback fuer Quellen die NOCH den alten Weg nutzen (combined resolve+mediainfo). Langfristig: entfernen.

Worker-Funktionen

_resolve_single(src)

def _resolve_single(src, provider_sources):
    """Schritt 1: Hoster-URL → Stream-URL. Speichert in DB."""
    resolved = _resolve_source(provider_sources, src)
    if resolved:
        sourcecacheDB.set_resolved_url(src['imdb_id'], src['provider'],
                                        src['hoster'], src['quality'], resolved)
        sourcecacheDB.hoster_touch(src['hoster'], success=True)
    else:
        sourcecacheDB.set_resolved_url(src['imdb_id'], src['provider'],
                                        src['hoster'], src['quality'], '')  # leer = failed
        sourcecacheDB.hoster_touch(src['hoster'], success=False)
        sourcecacheDB.update_probe(src['imdb_id'], src['provider'],
                                    src['hoster'], src['quality'], {'failed': True})

_mediainfo_single(src)

def _mediainfo_single(src):
    """Schritt 2: Stream-URL → Resolution/Bitrate. Speichert in DB."""
    resolved_url = src['resolved_url']
    hoster = src['hoster']

    # Hoster-Stream-Type bekannt? → Typ-Erkennung ueberspringen
    stream_type = sourcecacheDB.get_hoster_stream_type(hoster)

    info_str = mediainfo.getMediaInfo(resolved_url, _NullDialog(),
                                       time.time() + 15,
                                       stream_type_hint=stream_type)
    if not info_str:
        sourcecacheDB.update_probe(..., {'failed': True})
        return

    probe = SourcesClass._parse_probe_info(info_str)
    if not probe.get('height'):
        sourcecacheDB.update_probe(..., {'failed': True})
        return

    sourcecacheDB.update_probe(..., probe)

    # Stream-Type lernen (einmalig pro Hoster)
    if stream_type is None and probe.get('stream_type'):
        sourcecacheDB.set_hoster_stream_type(hoster, probe['stream_type'])

HD-Skip (Film-Level)

Alle 3 Queues pruefen: Hat der Film bereits eine HD-Quelle (probe_height >= 720)?

  • Scans: get_phase() gibt done zurueck → Film nicht in Scan-Queue
  • Resolves: NOT EXISTS (... probe_height >= 720 ...) in Query
  • MediaInfos: Gleicher Check in Query

Wenn erste Probe fuer Film X HD findet → alle weiteren Sources fuer Film X werden automatisch uebersprungen (naechste Dispatch-Runde sieht HD in DB).

Hoster Stream-Type (Lern-Cache)

-- hoster-Tabelle erweitert:
stream_type TEXT DEFAULT NULL
-- NULL → normaler Probe-Ablauf (Typ-Erkennung via 8KB peek)
-- 'mp4'/'hls'/'dash' → direkt zum richtigen Prober

Lern-Ablauf

  1. Erster Probe fuer Hoster X → normaler Ablauf (8KB peek + Typ-Erkennung)
  2. MediaInfo erkennt Typ (z.B. 'mp4') → hoster.stream_type = 'mp4'
  3. Alle weiteren Probes fuer Hoster X → kein 8KB peek, direkt _probeDirect()
  4. Spart ~200-400ms pro Probe

getMediaInfo Erweiterung

def getMediaInfo(url, dialog, deadline=None, stream_type_hint=None):
    if stream_type_hint == 'hls':
        return _probeHLS(url, dialog, deadline)
    if stream_type_hint == 'dash':
        return _probeDASH(url, dialog, deadline)
    if stream_type_hint == 'mp4':
        return _probeDirect(url, dialog, deadline, ...)
    # Kein Hint → normaler Ablauf (8KB peek)
    ...

Throttle bei Wiedergabe

Wie bisher: free_slots = min(free_slots, 1) wenn Video laeuft. Gilt fuer alle 3 Queues zusammen.

Loop-Ende

Loop endet wenn ALLE 3 Queues leer:

  • Keine Scans: kein Film in Phase 1a/2
  • Keine Resolves: get_dispatchable_resolves() leer
  • Keine MediaInfos: get_dispatchable_mediainfos() leer
  • Idle 3 Runden → break

Migration

# In _ensure_table():

# source: resolved_url Spalte
cursor.execute("PRAGMA table_info(source)")
cols = [row[1] for row in cursor.fetchall()]
if 'resolved_url' not in cols:
    cursor.execute("ALTER TABLE source ADD COLUMN resolved_url TEXT DEFAULT NULL")

# hoster: stream_type Spalte
cursor.execute("PRAGMA table_info(hoster)")
cols = [row[1] for row in cursor.fetchall()]
if 'stream_type' not in cols:
    cursor.execute("ALTER TABLE hoster ADD COLUMN stream_type TEXT DEFAULT NULL")

Erwarteter Gewinn

Vorher (1 Queue, sequential resolve+mediainfo)

  • 20 Filme × 3 Sources = 60 Probes
  • 60 × 12s avg = 720s / 5 Worker = ~144s

Nachher (3 Queues + HD-Skip)

  • 20 Filme × 3 Sources = 60 Probes
  • HD-Skip: ~15 Filme haben HD nach 1. Probe → 15 × 2 skipped = 30 Probes gespart
  • Verbleibend: ~30 Probes
  • Resolve (30 × 10s / 5 Worker) = ~60s
  • MediaInfo laeuft parallel dazu (30 × 2s / 3 Worker) = ~20s (ueberlappt mit Resolve)
  • Hoster-Stream-Type: spart ~200ms × 25 = ~5s
  • ~60-65s (55% schneller)

Dateien

Datei Aenderung
sourcecacheDB.py resolved_url Spalte, stream_type Spalte, neue Dispatch-Queries
precacher.py _resolve_single(), _mediainfo_single(), 3-Queue Dispatch in _run_loop()
mediainfo.py stream_type_hint Parameter, Stream-Type Rueckgabe

Provider-Statistiken (dynamische Reihenfolge)

Problem

Alle Priority-1 Provider sind gleichwertig — der erste in der Scraper-Load-Order wird immer zuerst aufgerufen. Kein Mechanismus um schnelle/zuverlaessige Provider zu bevorzugen.

Loesung

provider Tabelle in sourcecache.db trackt pro Provider:

  • Priority (aus Scraper, aktualisiert bei jedem Sync)
  • Durchschnittliche Geschwindigkeit (wie lange scraper.run() dauert)
  • Hit-Rate (wie oft der Provider mindestens 1 Quelle liefert)

Provider werden dynamisch sortiert: beste zuerst.

Schema

CREATE TABLE IF NOT EXISTS provider (
    name TEXT PRIMARY KEY,
    priority INTEGER DEFAULT 1,
    call_count INTEGER DEFAULT 0,
    hit_count INTEGER DEFAULT 0,
    total_duration_ms INTEGER DEFAULT 0,
    last_seen_at INTEGER NOT NULL,
    created_at INTEGER NOT NULL
);
  • call_count: Gesamtzahl run()-Aufrufe (auch ohne Ergebnis)
  • hit_count: Aufrufe mit >= 1 Quelle
  • total_duration_ms: Kumulative Dauer aller Aufrufe
  • Abgeleitet: avg_speed = total_duration_ms / call_count, hit_rate = hit_count / call_count

Score-Formel

Wenn call_count >= 3 (genug Daten):
    score = (priority * 10) + (speed_factor * 3) + (hit_penalty * 5)

    speed_factor = min(avg_speed_ms / 3000, 10)   # 0-10 Skala
    hit_penalty  = (1 - hit_rate) * 5              # 100% hit = 0, 0% hit = 5

Wenn call_count < 3 (neue/kaum getestete Provider):
    score = (priority * 10) + 5   # Mitte ihrer Priority-Klasse

Warum diese Gewichtung:

  • priority * 10 dominiert: Priority-2 (Score >= 20) schlaegt nie Priority-1 durch Geschwindigkeit allein
  • speed_factor * 3: 3s avg = +3, 15s avg = +15
  • hit_penalty * 5: 80% Hit-Rate = +1, 20% Hit-Rate = +4
  • Neue Provider (< 3 Calls) bekommen Score in der Mitte

Helper-Funktionen (sourcecacheDB.py)

Funktion Beschreibung
sync_providers(provider_list) UPSERT aller geladenen Scraper (priority + timestamps)
update_provider_stats(name, duration_ms, hit) Atomares Update nach jedem scraper.run()
get_ordered_providers() Provider-Namen sortiert nach Score (beste zuerst)
get_provider_stats() Debug-Logging: calls, hits, avg_speed, hit_rate%

Tracking-Stellen

Datei Stelle Was
precacher.py _scan_single() Timing + Hit nach jedem Scraper-Aufruf
sources.py _getSource() Timing + Hit im Foreground-Scan
sources.py _bg_scan_single() Timing + Hit im Background-Scan

Hoster-Statistiken

-- Teil der hoster-Tabelle (siehe DB-SCHEMA.md)
call_count, ok_count, fail_count
  • hoster_touch(name, success): Aktualisiert nach jeder Probe
  • get_hoster_stats(): Debug-Logging

Spaeter: Trust/Reliability

Neue Spalte quality_trust REAL DEFAULT 1.0 in provider:

  • Nach jeder Probe: actual_height / claimed_height berechnen
  • Provider die systematisch SD als HD labeln: Trust sinkt (z.B. 0.44)
  • Trust fliesst in Score: score *= (2 - trust) → Trust 1.0 = neutral, Trust 0.5 = Score verdoppelt
  • Threshold: actual >= 80% claimed = accurate

Spaeter: Exponential Backoff

Unresponsive Provider/Hoster bekommen progressive Sperre: 1st fail → 5s, 2nd → 10s, 3rd → 20s, 4th → 30s, 5th → 60s, 6th → 300s, 7th+ → 3600s (max). Success resets counter. Braucht consecutive_fails + backoff_until Spalten.

Szenarien: Was passiert wenn der User einen Film anklickt?

Szenario 1: Cache-Hit mit HD — Sofort fertig

Ausgangslage: Film wurde bereits gescannt + geprobt, HD-Quelle vorhanden.

User klickt Film
  → get_sources() → 14 Sources in DB
  → sourcesFilter() → Top-3 (alle mit Probe-Daten)
  → Anzeige: 3 Sources mit echter Resolution (z.B. 1920x1080)
  → bg_scan = False (HD vorhanden)
  → BG-Probe: alle 3 haben _probe → "Alle bereits geprobt, ueberspringe"
  → Fertig. Keine Wartezeit.

Dauer: ~15ms (DB lookup)


Szenario 2: Cache-Hit ohne HD, ungeprobte Quellen

Ausgangslage: Film hat gecachte Sources, aber keine geprobte HD-Quelle. Einige Sources wurden vorher geprobt, andere nicht.

User klickt Film
  → get_sources() → 14 Sources in DB
  → sourcesFilter():
    - probe_failed Sources werden entfernt
    - Score-Sortierung (geprobte oben, ungeprobte unten)
    - Top-3
  → Anzeige: z.B. 1 mit Resolution, 2 mit Scraper-Label ("1080p")
  → bg_scan = True (kein HD + ungeprobte Quellen)

BG-Probe startet (Hintergrund-Thread):
  → Filtert bereits geprobte Sources raus
  → Probt nur die 2 ungeprobten (spart ~5s pro bereits geprobte)
  → Jede Probe: Resolve (2-15s) + MediaInfo (1-3s)
  → Ergebnis in DB speichern

  Falls Probe fehlschlaegt:
    → Source bekommt _probe.failed = True
    → Wird nach Container.Refresh nicht mehr angezeigt
    → Platz frei fuer naechste Source aus DB

Container.Refresh:
  → Liste wird neu geladen mit echten Resolutions
  → Failed Sources verschwinden
  → User sieht nur funktionierende Quellen

Dauer: ~5-35s (abhaengig von Hoster-Antwortzeiten)


Szenario 3: Cache-Hit, kein HD, alle geprobt, Provider uebrig

Ausgangslage: Alle Sources geprobt, keine HD, aber noch Provider die nicht gescannt wurden.

User klickt Film
  → get_sources() → Sources aus DB (alle < 720p)
  → Anzeige: Top-3 mit echten Resolutions (z.B. 640x360)
  → bg_scan = True (kein HD + nicht fully_scanned)

BG-Scan startet:
  → DialogProgressBG oben rechts ("Suche Quellen...")
  → Neue Provider werden gescannt (ThreadPoolExecutor, max 10 Worker)
  → Gecachte Provider uebersprungen
  → Neue Sources in DB + Merge mit bestehenden
  → Container.Refresh alle 5-10s (Liste waechst)
  → Early Exit nach 3 neuen Quellen

BG-Probe fuer neue Sources:
  → Resolve + MediaInfo fuer ungeprobte
  → HD gefunden? → Fertig
  → Container.Refresh mit aktualisierten Daten

Dauer: 10-60s (abhaengig von Anzahl uebrige Provider)


Szenario 4: Cache-Hit, kein HD, alle Provider durch

Ausgangslage: Alle Provider gescannt, alle Sources geprobt, keine HD.

User klickt Film
  → get_sources() → Sources aus DB
  → Anzeige: Top-3 (beste verfuegbare Qualitaet, z.B. SD)
  → bg_scan = False (fully_scanned + kein HD = done)
  → BG-Probe: bereits geprobte werden uebersprungen
  → Fertig. Film hat einfach keine HD-Quellen.

Dauer: ~15ms


Szenario 5: Cache-Miss — Frischer Scan

Ausgangslage: Film wurde noch nie gescannt (kein Cache-Eintrag).

User klickt Film
  → get_sources() → 0 Sources in DB
  → Foreground-Scan mit Progress-Dialog (Ladekreis)
  → 16 Scraper parallel (ThreadPoolExecutor)
  → Early Exit nach 3 Quellen
  → Sources in DB speichern
  → sourcesFilter() → Top-3
  → Anzeige: 3 Sources mit Scraper-Labels

BG-Probe startet:
  → Probt alle 3 (keine hat _probe Daten)
  → Container.Refresh nach letzter Probe
  → Echte Resolutions sichtbar
  → Failed Sources verschwinden

Dauer: 5-30s (Foreground-Scan) + 5-35s (BG-Probe)


Szenario 6: Probe schlaegt fehl (Hoster down)

Ausgangslage: Source wird geprobt, aber Resolve oder MediaInfo schlaegt fehl.

BG-Probe [2/3] Resolve: moflix / FileLions
  → 31s Timeout → Keine MediaInfo
  → _probe = {failed: True, height: 0}
  → In DB gespeichert (probe_failed = 1)

Container.Refresh:
  → sourcesFilter() entfernt probe_failed Sources
  → Liste schrumpft (z.B. 3 → 2)
  → Naechster Cache-Hit: FileLions nicht mehr in Top-N
    (Score 1, wird durch bessere Source ersetzt)

Szenario 7: Precacher im Hintergrund (service.py)

Ausgangslage: User browst in xShip, Precacher laeuft unsichtbar.

service.py (alle 10s):
  → xShip aktiv? → Precacher starten
  → TMDB Discover → Populaere Filme in movie-Tabelle
  → v_movie_phase VIEW berechnet Phase pro Film:
    - Phase 1a: Kein Source → 1 Provider scannen
    - Phase 1b: Ungeprobte Sources → Proben
    - Phase 2: Kein HD + Provider uebrig → Weitere scannen
    - done: HD gefunden oder alle Provider durch
  → Pool-Dispatcher arbeitet Filme nach Phase ab
  → Wenn User spaeter Film anklickt: Cache-Hit (Szenario 1 oder 2)

Dauer: Laeuft kontinuierlich im Hintergrund


Filter-Reihenfolge in sourcesFilter()

Sources aus DB (z.B. 14)
  → Qualitaets-Filter (4K/1080p/720p je nach Setting)
  → Dedup (1 pro provider+hoster+quality)
  → Score-Sortierung (geprobte oben, ungeprobte unten)
  → probe_failed entfernen (keine MediaInfo = unbrauchbar)
  → Top-N abschneiden (default 3)
  → Anzeige

BG-Probe Optimierung

  • Bereits geprobte Sources (mit _probe Daten) werden uebersprungen
  • Nur ungeprobte Sources werden resolved + geprobt
  • Spart ~5-15s pro bereits geprobte Source
  • Log: [BG-Probe] Start: 2/3 Quellen proben (1 bereits geprobt)
  • Wenn alle bereits geprobt: [BG-Probe] Alle 3 Quellen bereits geprobt, ueberspringe
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment