Skip to content

Instantly share code, notes, and snippets.

@CarstenG2
Created April 5, 2026 16:41
Show Gist options
  • Select an option

  • Save CarstenG2/15640a6993432e664c81368688762dea to your computer and use it in GitHub Desktop.

Select an option

Save CarstenG2/15640a6993432e664c81368688762dea to your computer and use it in GitHub Desktop.
Backoff-Integration Design Docs

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, consecutive_fails, backoff_until, avg_response_ms
  • hoster_touch(name, success, duration_ms): Aktualisiert nach jeder Probe/Resolve
  • get_hoster_stats(): Debug-Logging (inkl. consecutive_fails, backoff_until, avg_response_ms)

EMA 90/10 Response-Time (Provider + Hoster)

Dynamischer Durchschnitt statt kumulativem Mittel:

new_avg = 0.9 * old_avg + 0.1 * last_response_ms
  • Reagiert in ~10 Calls auf aktuelle Aenderungen
  • Seed: erster Call setzt avg_response_ms direkt auf den gemessenen Wert
  • Spalte avg_response_ms in hoster UND provider Tabelle
  • Provider: EMA in update_provider_stats(), Score in get_ordered_providers()
  • Hoster: EMA in hoster_touch(duration_ms=...), Dispatch-Sortierung in SQL
  • total_duration_ms (Provider) bleibt fuer historische Daten, wird weiter aktualisiert

Dispatch-Sortierung nach Hoster-Speed

ORDER BY
  movie.priority ASC,
  CASE WHEN h.avg_response_ms > 0 THEN h.avg_response_ms ELSE 5000 END ASC,
  s.created_at

Film-Prioritaet dominiert, innerhalb gleicher Prio schnellste Hoster zuerst. Neue Hoster (avg=0) bekommen neutralen Wert 5000ms.

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

Exponential Backoff (implementiert)

Hoster mit wiederholten Fehlern bekommen progressive Sperre:

1st fail -> 5s, 2nd -> 10s, 3rd -> 20s, 4th -> 30s, 5th -> 60s, 6th -> 300s, 7th+ -> 3600s (max)

Success resettet consecutive_fails auf 0.

Schema-Erweiterung (hoster-Tabelle)

  • consecutive_fails INTEGER DEFAULT 0 -- Anzahl Fehler in Folge ohne Erfolg
  • backoff_until INTEGER DEFAULT 0 -- Unix-Timestamp bis wann der Hoster gesperrt ist

Filterstufen

Kontext Funktion Logik
Vordergrund-Scan (sourcesFilter, Progress, Early-Exit, Cache-Dots) hoster_healthy(name) consecutive_fails == 0 ODER backoff_until abgelaufen
BG-Precacher (Dispatch-Queries) backoff_until <= now in SQL Zeitbasiert -- probiert nach Ablauf nochmal
BG-Probe (sources.py) hoster_available(name, cooldown=0) Zeitbasiert wie Precacher

hoster_healthy() prüft: keine Fehler ODER Backoff-Timer abgelaufen (neue Chance). Damit funktioniert das System auch ohne BG-Precacher -- nach Ablauf des Timers (max 1 Stunde) wird der Hoster automatisch wieder angezeigt.

Wenn nach Filter 0 Quellen übrig: "Keine Quellen gefunden" Notification mit Filmtitel.

Tracking-Stellen

hoster_touch(name, success) wird aufgerufen in:

Datei Stelle Kontext
precacher.py _resolve_single() BG-Precacher Resolve OK/Fail
precacher.py _mediainfo_single() BG-Precacher Probe OK/Fail
sources.py sourcesResolve() Vordergrund-Klick, Autoplay, Trailer
sources.py _probe_single() BG-Probe nach Foreground-Scan

Netzwerk-Ausfall-Schutz

Problem: Bei Netzwerk-Ausfall schlagen alle Hoster gleichzeitig fehl -> alle im Backoff -> naechster Scan zeigt 0 Quellen obwohl kein Hoster defekt ist.

Loesung: _last_success_at Modul-Level-Timestamp in sourcecacheDB.py.

  • Wird bei jedem hoster_touch(success=True) auf now gesetzt
  • hoster_touch(success=False) prueft: wenn _last_success_at aelter als 30s, war wahrscheinlich das Netz down -> Fail wird NICHT gezaehlt (kein Backoff-Increment)
  • Sobald das Netz wieder da ist und ein Hoster Erfolg hat, laeuft alles normal weiter

Logging

  • [xShip-Backoff] name: N consecutive fails, backoff Xs -- bei 4+ consecutive fails
  • [sourcesFilter] N Quellen entfernt (Hoster fehlerhaft) -- Vordergrund-Filter
  • [BG-Probe] N Quellen uebersprungen (Hoster im Backoff) -- BG-Probe-Filter
  • Hoster-Stats am Ende jedes Precache-Zyklus zeigen backoff N fails

Cache-Dots (Farbige Punkte in Filmliste)

Dot Farbe Bedingung
Grün FF4CAF50 Gesunde Source mit probe_height >= 720 (oder HD-Label wenn Probing aus)
Gelb FFFFEB3B Gesunde Sources vorhanden, aber kein HD (oder ungeprobt bei Probing an)
Rot FFF44336 Scan fertig + keine Quellen, ODER alle Hoster im Backoff
Kein Dot Film nicht gescannt / keine Sources in DB

get_cached_tmdb_ids() gibt 3 Sets zurück:

  • hd_ids: mindestens 1 gesunde Source mit HD (Backoff-Hoster gefiltert)
  • sd_ids: gesunde Sources vorhanden, aber kein HD
  • backoff_ids: Sources vorhanden, aber ALLE Hoster im Backoff

Probing-Logik für Dot-Farbe:

  • Probing an + geprobt: reale Auflösung entscheidet (probe_height >= 720 = HD)
  • Probing an + nicht geprobt: als SD behandeln (gelber Dot, unsicher)
  • Probing aus: Provider-Label als Fallback (HD-Label = grüner Dot)

hoster_healthy() für Dots und Filter:

  • consecutive_fails == 0 → gesund
  • consecutive_fails > 0 aber backoff_until abgelaufen → gesund (neue Chance)
  • consecutive_fails > 0 und backoff_until aktiv → nicht gesund

Damit funktioniert das System auch ohne BG-Precacher -- nach Ablauf des Timers (max 1 Stunde) wird der Hoster automatisch wieder angezeigt und bekommt eine neue Chance.

Backoff-Vorab-Check im Cache-Hit-Pfad

Vor der "Gespeicherte Quellen laden" Notification: prüfe ob gesunde Sources vorhanden.

Wenn alle Hoster im Backoff (keine gesunde Source im Cache):

  1. Cache-Hit überspringen (kein Popup, kein altes Ergebnis)
  2. _force_scan = True setzen → alle Provider abfragen (kein skip_providers)
  3. Early-Exit zählt nur gesunde Hoster (Backoff-Sources nicht mitzählen)
  4. Progress-Dialog zeigt nur gesunde Hoster-Sources
  5. sourcesFilter() filtert Backoff-Hoster aus Ergebnisliste
  6. Wenn 0 Quellen übrig: "Keine Quellen gefunden" mit Filmtitel als Heading

Dadurch verhält sich ein normaler Klick auf einen Film mit nur Backoff-Hostern automatisch wie ein Force-Scan -- ohne dass der User es explizit auslösen muss.

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 (mit FolderPath Guard)
    +-- On completion: is_fully_scanned() dynamisch

Container.Refresh Guard

Sowohl BG-Probe als auch BG-Scan pruefen vor jedem Container.Refresh ob die Quellenliste noch aktiv ist:

folder_path = xbmc.getInfoLabel('Container.FolderPath')


---

## Backoff-Vorab-Check im Cache-Hit-Pfad

Vor der "Gespeicherte Quellen laden" Notification: prüfe ob gesunde Sources vorhanden.

Wenn alle Hoster im Backoff (keine gesunde Source im Cache):
1. Cache-Hit überspringen (kein Popup, kein altes Ergebnis)
2. `_force_scan = True` setzenalle Provider abfragen (kein skip_providers)
3. Early-Exit zählt nur gesunde Hoster (Backoff-Sources nicht mitzählen)
4. Progress-Dialog zeigt nur gesunde Hoster-Sources
5. `sourcesFilter()` filtert Backoff-Hoster aus Ergebnisliste
6. Wenn 0 Quellen übrig: "Keine Quellen gefunden" mit Filmtitel als Heading

Dadurch verhält sich ein normaler Klick auf einen Film mit nur Backoff-Hostern
automatisch wie ein Force-Scan -- ohne dass der User es explizit auslösen muss.

## 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()


Manueller Scan (Kontextmenue "Quellen scannen")

Einstiegspunkt: default.py action=scanSources -> scan_on_open=true -> play()

Verhalten in getSources() wenn _force_scan=True:

  1. Kein Cache-Hit -- gecachte Quellen werden NICHT angezeigt, frischer Scan sofort
  2. Kein skip_providers -- alle Provider werden abgefragt, auch bereits gecachte
  3. Early-Exit greift normal -- nicht alle Provider muessen antworten
  4. Probe-Daten bleiben -- bestehende Probe-Ergebnisse aus DB werden an neue Sources angehaengt
  5. Orphan-Cleanup -- nach dem Scan werden pro Provider (nur die tatsaechlich gelaufen sind) Sources aus der DB entfernt die nicht mehr geliefert wurden
scanSources
  -> scan_on_open=true
  -> play() -> getSources()
     -> _force_scan=True: Cache-Hit ueberspringen
     -> clearProperty('scan_on_open') (verhindert Doppel-Scan)
     -> clearProperty('probe_done') (Probe-Phase neu starten)
     -> Alle Provider scannen (kein skip_providers)
     -> Early-Exit bei scrapers.early_exit Treffern
     -> save_sources() pro Provider
     -> remove_orphaned_sources() NUR fuer _completed_providers
        (Provider die nicht gelaufen sind wegen Early-Exit: Sources bleiben)

remove_orphaned_sources(tmdb, provider, found_keys):

  • Vergleicht DB-Sources mit frisch gefundenen Sources (hoster+quality als Key)
  • Loescht Sources die der Provider nicht mehr liefert
  • Provider mit 0 Sources: alle alten Sources dieses Providers geloescht
  • Provider nicht gelaufen (Early-Exit): keine Aenderung

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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment