diff --git a/lib/streaming_providers/base/channel_cache.py b/lib/streaming_providers/base/channel_cache.py
new file mode 100644
index 0000000..f827fba
--- /dev/null
+++ b/lib/streaming_providers/base/channel_cache.py
@@ -0,0 +1,276 @@
+# streaming_providers/base/channel_cache.py
+"""
+In-memory channel-list cache for the provider layer.
+
+What it gives the PVR client:
+ * a warm cache: GET /api/providers/
/channels is answered from memory
+ * single-flight: concurrent callers (warm-up, PVR, M3U generation) share ONE
+ upstream load per (provider, params); nobody logs in twice in parallel
+ * stale-while-revalidate: past the TTL the old list is served at once and
+ refreshed in a background thread; only a cold miss blocks
+ * a status per provider (idle / warming / ready / error) for /api/providers
+
+Memory only, by design: channel objects are not serialised anywhere yet.
+invalidate() must be called whenever the provider instance or its account
+changes (credentials, reinitialize, enable/disable, rediscover). A load that
+was already running when invalidate() was called is delivered to its waiters
+but never stored (generation counter).
+"""
+
+import re
+import threading
+import time
+from dataclasses import dataclass
+from typing import Any, Callable, Dict, Hashable, List, Optional, Tuple
+
+from .utils.logger import logger
+
+DEFAULT_TTL = 24 * 3600.0 # channel lists are stable: fresh for 24 h
+DEFAULT_MAX_STALE = 7 * 24 * 3600.0 # serve stale (and refresh) up to 7 days; older = cold miss
+EMPTY_RESULT_TTL = 30.0 # an empty list is probably a transient failure: retry soon
+REFRESH_BACKOFF = 60.0 # after a failed background refresh, wait before the next one
+ERROR_TTL = 60.0 # a failed COLD load is remembered: no upstream login per client retry
+
+Key = Tuple[str, Hashable]
+
+_URL_RE = re.compile(r"https?://\S+")
+
+
+def _safe_error(exc: BaseException) -> str:
+ """Error text for the API: no URLs (they may carry tokens), max 200 chars."""
+ return _URL_RE.sub("", f"{type(exc).__name__}: {exc}")[:200]
+
+
+@dataclass
+class _Entry:
+ channels: List[Any]
+ fetched_at: float
+ ttl: float
+ retry_after: float = 0.0
+ owner: Any = None # the provider instance this list came from
+
+
+class _Flight:
+ __slots__ = ("done", "result", "error", "gen", "owner")
+
+ def __init__(self, gen: int, owner: Any = None):
+ self.owner = owner
+ self.done = threading.Event()
+ self.result: Optional[List[Any]] = None
+ self.error: Optional[BaseException] = None
+ self.gen = gen
+
+
+class ChannelCache:
+ def __init__(
+ self,
+ ttl: float = DEFAULT_TTL,
+ max_stale: float = DEFAULT_MAX_STALE,
+ empty_ttl: float = EMPTY_RESULT_TTL,
+ refresh_backoff: float = REFRESH_BACKOFF,
+ error_ttl: float = ERROR_TTL,
+ clock: Callable[[], float] = time.monotonic,
+ ):
+ self._ttl = ttl
+ self._max_stale = max_stale
+ self._empty_ttl = empty_ttl
+ self._backoff = refresh_backoff
+ self._error_ttl = error_ttl
+ self._clock = clock
+ self._lock = threading.Lock()
+ self._entries: Dict[Key, _Entry] = {}
+ self._flights: Dict[Key, _Flight] = {}
+ self._generation: Dict[str, int] = {}
+ self._last_error: Dict[str, str] = {}
+ self._failures: Dict[Key, Tuple[BaseException, float, Any]] = {}
+
+ # ------------------------------------------------------------------
+ def get(
+ self,
+ provider: str,
+ loader: Callable[[], List[Any]],
+ *,
+ params: Hashable = (),
+ ttl: Optional[float] = None,
+ owner: Any = None,
+ ) -> List[Any]:
+ """
+ Return the channel list, loading it through `loader` when needed.
+
+ `owner` is the provider instance. A list (or failure, or running load)
+ that belongs to a DIFFERENT instance is never served: replacing an
+ instance invalidates its cache by identity, whatever code path did it.
+ """
+ key: Key = (provider, params)
+ ttl = self._ttl if ttl is None else ttl
+ now = self._clock()
+
+ with self._lock:
+ entry = self._entries.get(key)
+ if entry is not None and owner is not None and entry.owner is not owner:
+ del self._entries[key]
+ entry = None
+ if entry is not None:
+ age = now - entry.fetched_at
+ if age < entry.ttl:
+ return list(entry.channels)
+ if age < self._max_stale:
+ stale = list(entry.channels)
+ if now >= entry.retry_after:
+ flight, created = self._flight_locked(key, owner)
+ if created:
+ self._spawn_refresh(key, provider, loader, flight, ttl)
+ return stale
+ if key not in self._flights:
+ failure = self._failures.get(key)
+ if failure is not None:
+ if owner is not None and failure[2] is not owner:
+ del self._failures[key]
+ elif now - failure[1] < self._error_ttl:
+ # Same answer as the failed load, without asking the upstream again.
+ raise failure[0].with_traceback(None)
+ else:
+ del self._failures[key]
+ flight, created = self._flight_locked(key, owner)
+
+ if created:
+ self._run(key, provider, loader, flight, ttl)
+ else:
+ flight.done.wait()
+ if flight.error is not None:
+ raise flight.error
+ return list(flight.result or [])
+
+ def refresh(
+ self,
+ provider: str,
+ loader: Callable[[], List[Any]],
+ *,
+ params: Hashable = (),
+ ttl: Optional[float] = None,
+ owner: Any = None,
+ ) -> bool:
+ """
+ Reload now, in the calling thread, even if the entry is still fresh.
+
+ Single-flight like get(): returns False (and does nothing) when a load
+ for this key is already running. On failure the old list is kept and
+ the exception propagates (used by the background refresher).
+ """
+ key: Key = (provider, params)
+ with self._lock:
+ flight, created = self._flight_locked(key, owner)
+ if not created:
+ return False
+ self._run(key, provider, loader, flight, self._ttl if ttl is None else ttl)
+ return True
+
+ def due(self, fraction: float) -> List[Key]:
+ """Keys whose age reached `fraction` of their TTL and are not in backoff."""
+ now = self._clock()
+ with self._lock:
+ return [
+ key
+ for key, e in self._entries.items()
+ if now - e.fetched_at >= e.ttl * fraction and now >= e.retry_after
+ ]
+
+ def invalidate(self, provider: Optional[str] = None) -> None:
+ """Drop cached lists (one provider, or all) and discard loads in flight."""
+ with self._lock:
+ if provider is None:
+ names = (
+ set(self._generation)
+ | {k[0] for k in self._entries}
+ | {k[0] for k in self._flights}
+ )
+ self._entries.clear()
+ self._last_error.clear()
+ self._failures.clear()
+ else:
+ names = {provider}
+ for key in [k for k in self._entries if k[0] == provider]:
+ del self._entries[key]
+ self._last_error.pop(provider, None)
+ for key in [k for k in self._failures if k[0] == provider]:
+ del self._failures[key]
+ for name in names:
+ self._generation[name] = self._generation.get(name, 0) + 1
+
+ def status(self, provider: str) -> Dict[str, Any]:
+ """Snapshot for the API: state is idle | warming | ready | error."""
+ now = self._clock()
+ with self._lock:
+ entries = [e for k, e in self._entries.items() if k[0] == provider]
+ loading = any(k[0] == provider for k in self._flights)
+ error = self._last_error.get(provider)
+ if entries:
+ newest = max(entries, key=lambda e: e.fetched_at)
+ age = now - newest.fetched_at
+ return {
+ "state": "ready",
+ "channel_count": len(newest.channels),
+ "age_seconds": int(age),
+ "stale": age >= newest.ttl,
+ "refreshing": loading,
+ "error": error,
+ }
+ if loading:
+ return {"state": "warming", "error": error}
+ if error:
+ return {"state": "error", "error": error}
+ return {"state": "idle"}
+
+ # ------------------------------------------------------------------
+ def _flight_locked(self, key: Key, owner: Any = None) -> Tuple[_Flight, bool]:
+ flight = self._flights.get(key)
+ if flight is not None:
+ if owner is None or flight.owner is owner:
+ return flight, False
+ # Running load belongs to a replaced instance: discard its result, start anew.
+ self._generation[key[0]] = self._generation.get(key[0], 0) + 1
+ flight = _Flight(self._generation.get(key[0], 0), owner)
+ self._flights[key] = flight
+ return flight, True
+
+ def _spawn_refresh(self, key, provider, loader, flight, ttl) -> None:
+ def work():
+ try:
+ self._run(key, provider, loader, flight, ttl)
+ except Exception as exc: # already recorded in _last_error
+ logger.warning(f"ChannelCache: background refresh of '{provider}' failed: {exc}")
+
+ threading.Thread(target=work, name=f"channel-refresh-{provider}", daemon=True).start()
+
+ def _run(self, key: Key, provider: str, loader, flight: _Flight, ttl: float) -> None:
+ try:
+ channels = list(loader())
+ except Exception as exc:
+ flight.error = exc
+ with self._lock:
+ if self._generation.get(provider, 0) == flight.gen:
+ self._last_error[provider] = _safe_error(exc)
+ self._failures[key] = (exc, self._clock(), flight.owner)
+ entry = self._entries.get(key)
+ if entry is not None: # keep serving stale, but do not hammer upstream
+ entry.retry_after = self._clock() + self._backoff
+ raise
+ else:
+ flight.result = channels
+ with self._lock:
+ if self._generation.get(provider, 0) == flight.gen:
+ self._entries[key] = _Entry(
+ channels,
+ self._clock(),
+ ttl if channels else min(ttl, self._empty_ttl),
+ owner=flight.owner,
+ )
+ self._last_error.pop(provider, None)
+ self._failures.pop(key, None)
+ finally:
+ with self._lock:
+ if self._flights.get(key) is flight:
+ del self._flights[key]
+ if flight.result is None and flight.error is None:
+ flight.error = RuntimeError("channel load aborted")
+ flight.done.set()
\ No newline at end of file
diff --git a/lib/streaming_providers/base/channel_operations.py b/lib/streaming_providers/base/channel_operations.py
index e853e6c..41ce04b 100644
--- a/lib/streaming_providers/base/channel_operations.py
+++ b/lib/streaming_providers/base/channel_operations.py
@@ -4,8 +4,11 @@
Channel-related operations separated from core registry.
"""
-from typing import Dict, List, Optional
+import threading
+import time
+from typing import Any, Dict, Iterable, List, Optional
+from .channel_cache import ChannelCache
from .models import StreamingChannel
from .utils.logger import logger
@@ -13,20 +16,46 @@ from .utils.logger import logger
class ChannelOperations:
"""Handles all channel-related operations."""
- def __init__(self, registry):
+ def __init__(self, registry, cache: Optional[ChannelCache] = None):
self.registry = registry
+ self.cache = cache or ChannelCache()
+ self._refresher: Optional[threading.Thread] = None
+ self._refresher_stop = threading.Event()
logger.debug("ChannelOperations: Initialized")
def get_channels(
self, provider_name: str, fetch_manifests: bool = False, **kwargs
) -> List[StreamingChannel]:
- """Get channels from a specific provider."""
+ """
+ Get channels from a specific provider (cached, see ChannelCache).
+
+ `fetch_manifests` is accepted for compatibility but has no effect (the
+ enrichment it controlled is disabled); it is deliberately NOT part of
+ the cache key -- callers disagree on its default and would otherwise
+ split one list into two cache entries.
+ """
provider = self.registry.get_provider(provider_name)
if not provider:
raise ValueError(f"Provider '{provider_name}' not found or disabled")
- channels = provider.get_channels(**kwargs)
- logger.info(f"Retrieved {len(channels)} channels from '{provider_name}'")
+ # None-valued kwargs (e.g. country=None from the route) do not change the
+ # result, so they must not split the cache key from the warm-up call.
+ params = tuple(sorted((k, v) for k, v in kwargs.items() if v is not None))
+ try:
+ hash(params)
+ except TypeError:
+ return self._load(provider_name, provider, kwargs) # uncacheable
+
+ # A provider whose Channel objects embed expiring URLs/tokens can
+ # shorten its cache with a class attribute CHANNEL_CACHE_TTL (seconds).
+ ttl = getattr(provider, "CHANNEL_CACHE_TTL", None)
+ channels = self.cache.get(
+ provider_name,
+ lambda: self._load(provider_name, provider, kwargs),
+ params=params,
+ ttl=ttl,
+ owner=provider,
+ )
# if fetch_manifests and not provider.uses_dynamic_manifests:
# enriched = []
@@ -39,6 +68,146 @@ class ChannelOperations:
return channels
+ @staticmethod
+ def _load(provider_name: str, provider, kwargs: Dict[str, Any]) -> List[StreamingChannel]:
+ channels = provider.get_channels(**kwargs)
+ logger.info(f"Retrieved {len(channels)} channels from '{provider_name}'")
+ return channels
+
+ # ------------------------------------------------------------------
+ # Warm-up / cache state
+ # ------------------------------------------------------------------
+ def warm_up(self, provider_names: Optional[Iterable[str]] = None, max_parallel: int = 4) -> None:
+ """
+ Load channel lists into the cache in the background (returns at once).
+
+ Goes through get_channels(), so auth + fetch happen exactly as for a
+ client request and concurrent requests join the running load.
+ """
+ names = list(provider_names) if provider_names is not None else self.registry.list_providers()
+ if not names:
+ return
+
+ def run_all():
+ gate = threading.Semaphore(max(1, max_parallel))
+
+ def one(name: str):
+ try:
+ self.get_channels(name)
+ except Exception as exc:
+ logger.warning(f"ChannelOperations: warm-up of '{name}' failed: {exc}")
+ finally:
+ gate.release()
+
+ for name in names:
+ gate.acquire()
+ threading.Thread(
+ target=one, args=(name,), name=f"channel-warmup-{name}", daemon=True
+ ).start()
+
+ threading.Thread(target=run_all, name="channel-warmup", daemon=True).start()
+ logger.info(f"ChannelOperations: warm-up started for {len(names)} providers")
+
+ # ------------------------------------------------------------------
+ # Automatic refresh: keeps the cache warm for every client, forever
+ # ------------------------------------------------------------------
+ def start_auto_refresh(
+ self,
+ check_interval: float = 900.0,
+ refresh_fraction: float = 0.8,
+ error_retry: float = 3600.0,
+ max_parallel: int = 4,
+ ) -> None:
+ """
+ Background thread that, every `check_interval` seconds:
+ * reloads lists that reached `refresh_fraction` of their TTL, so a
+ client never meets a stale or cold list while the backend runs
+ * (re)loads providers that have NO list yet: never loaded (idle) at
+ once, failed ones (error) at most every `error_retry` seconds, so
+ wrong credentials are not retried against the upstream login
+ every few minutes.
+ A failed reload keeps the old list. Idempotent.
+ """
+ if self._refresher is not None and self._refresher.is_alive():
+ return
+ self._refresher_stop.clear()
+ attempts: Dict[str, float] = {}
+
+ def loop():
+ while not self._refresher_stop.wait(check_interval):
+ try:
+ self.refresh_tick(refresh_fraction, error_retry, attempts, max_parallel)
+ except Exception as exc:
+ logger.warning(f"ChannelOperations: refresh tick failed: {exc}")
+
+ self._refresher = threading.Thread(target=loop, name="channel-refresher", daemon=True)
+ self._refresher.start()
+ logger.info(f"ChannelOperations: auto refresh every {int(check_interval)}s")
+
+ def stop_auto_refresh(self) -> None:
+ self._refresher_stop.set()
+
+ def refresh_tick(
+ self,
+ refresh_fraction: float,
+ error_retry: float,
+ attempts: Dict[str, float],
+ max_parallel: int = 4,
+ join_timeout: float = 300.0,
+ ) -> None:
+ """
+ One refresher pass (public so it can be tested without waiting).
+ A hung provider cannot block the refresher beyond `join_timeout`; its
+ load keeps running and is skipped by later ticks (single-flight).
+ """
+ now = time.monotonic()
+ targets: Dict[Any, Dict[str, Any]] = {}
+ for name, params in self.cache.due(refresh_fraction):
+ targets[(name, params)] = dict(params)
+ for name in self.registry.list_providers():
+ state = self.cache.status(name)["state"]
+ if state == "idle" or (state == "error" and now - attempts.get(name, 0.0) >= error_retry):
+ attempts[name] = now
+ targets[(name, ())] = {}
+ if not targets:
+ return
+
+ gate = threading.Semaphore(max(1, max_parallel))
+ threads = []
+
+ def one(name: str, params, kwargs: Dict[str, Any]):
+ try:
+ provider = self.registry.get_provider(name)
+ if provider is None:
+ return
+ self.cache.refresh(
+ name,
+ lambda: self._load(name, provider, kwargs),
+ params=params,
+ ttl=getattr(provider, "CHANNEL_CACHE_TTL", None),
+ owner=provider,
+ )
+ except Exception as exc:
+ logger.warning(f"ChannelOperations: refresh of '{name}' failed: {exc}")
+ finally:
+ gate.release()
+
+ for (name, params), kwargs in targets.items():
+ gate.acquire()
+ t = threading.Thread(target=one, args=(name, params, kwargs), name=f"channel-refresh-{name}", daemon=True)
+ t.start()
+ threads.append(t)
+ deadline = time.monotonic() + join_timeout
+ for t in threads:
+ t.join(max(0.0, deadline - time.monotonic()))
+
+ def get_channel_state(self, provider_name: str) -> Dict[str, Any]:
+ """idle | warming | ready | error (+ count/age/stale for ready)."""
+ return self.cache.status(provider_name)
+
+ def invalidate_channels(self, provider_name: Optional[str] = None) -> None:
+ self.cache.invalidate(provider_name)
+
def get_channel_manifest(self, provider_name: str, channel_id: str, **kwargs) -> Optional[str]:
"""Get manifest URL for a specific channel."""
provider = self.registry.get_provider(provider_name)
@@ -70,4 +239,4 @@ class ChannelOperations:
result[name] = []
logger.info(f"Retrieved {total} total channels")
- return result
+ return result
\ No newline at end of file
diff --git a/lib/streaming_providers/base/manager.py b/lib/streaming_providers/base/manager.py
index a8e1b87..549a1c3 100644
--- a/lib/streaming_providers/base/manager.py
+++ b/lib/streaming_providers/base/manager.py
@@ -82,6 +82,9 @@ class ProviderManager:
"""
logger.info(f"ProviderManager: Re-discovering providers for country '{country}'...")
discovered = self.registry.discover_all_providers(country)
+ # Rediscovery builds fresh instances (auth state is gone): drop cached lists.
+ self.channel_ops.invalidate_channels()
+ self.channel_ops.warm_up()
logger.info(f"ProviderManager: Re-discovery complete. Found {len(discovered)} providers")
return discovered
@@ -89,16 +92,38 @@ class ProviderManager:
return self.registry.get_provider(provider_name)
def set_provider_enabled(self, provider_name: str, enabled: bool) -> bool:
- return self.registry.set_provider_enabled(provider_name, enabled)
+ success = self.registry.set_provider_enabled(provider_name, enabled)
+ self.channel_ops.invalidate_channels(provider_name)
+ if success and enabled:
+ self.channel_ops.warm_up([provider_name])
+ return success
def get_all_providers_metadata(self) -> List[Dict[str, Any]]:
return self.registry.get_all_providers_metadata()
def reinitialize_provider(self, provider_name: str) -> bool:
- return self.registry.reinitialize_provider(provider_name)
+ success = self.registry.reinitialize_provider(provider_name)
+ # New instance / possibly new account: the cached list is not trustworthy.
+ self.channel_ops.invalidate_channels(provider_name)
+ if success:
+ self.channel_ops.warm_up([provider_name])
+ return success
def reinitialize_providers(self, provider_names: List[str]) -> Dict[str, bool]:
- return {name: self.registry.reinitialize_provider(name) for name in provider_names}
+ return {name: self.reinitialize_provider(name) for name in provider_names}
+
+ def warm_up(self, provider_names: Optional[List[str]] = None) -> None:
+ """Preload channel lists of enabled providers in the background."""
+ self.channel_ops.warm_up(provider_names)
+
+ def start_auto_refresh(self, **kwargs) -> None:
+ self.channel_ops.start_auto_refresh(**kwargs)
+
+ def stop_auto_refresh(self) -> None:
+ self.channel_ops.stop_auto_refresh()
+
+ def get_channel_state(self, provider_name: str) -> Dict[str, Any]:
+ return self.channel_ops.get_channel_state(provider_name)
def reinitialize_all_providers(self) -> Dict[str, bool]:
enabled = self.registry.get_enabled_providers()
@@ -112,6 +137,7 @@ class ProviderManager:
def clear_providers(self):
self.registry.clear_providers()
+ self.channel_ops.invalidate_channels()
@staticmethod
def get_provider_class(provider_name: str):
diff --git a/lib/streaming_providers/base/provider_registry.py b/lib/streaming_providers/base/provider_registry.py
index 158e635..62095db 100644
--- a/lib/streaming_providers/base/provider_registry.py
+++ b/lib/streaming_providers/base/provider_registry.py
@@ -341,9 +341,31 @@ class ProviderRegistry:
if instance:
self.providers[instance_name] = instance
+ self._migrate_enable_default()
+
logger.info(f"ProviderRegistry: Discovered {len(discovered)} provider instances")
return discovered
+ def _migrate_enable_default(self) -> None:
+ """
+ One-time: freeze the just-discovered enable state in providers_enabled.json
+ and make "disabled" the default for providers without an entry.
+ M3U groups share the single enable key "m3u".
+ """
+ try:
+ from .settings.provider_enable_manager import ProviderEnableManager
+
+ manager = ProviderEnableManager()
+ if not manager.needs_default_migration():
+ return
+ states = {
+ ("m3u" if m.plugin_name == "m3u" else name): m.enabled
+ for name, m in self.provider_metadata.items()
+ }
+ manager.migrate_default_to_disabled(states)
+ except Exception as e:
+ logger.warning(f"ProviderRegistry: enable-default migration skipped: {e}")
+
def _discover_m3u_groups(self, plugin_class, default_country: str) -> List[str]:
"""
Discover M3U groups and register each as a separate provider instance.
diff --git a/lib/streaming_providers/base/settings/provider_enable_manager.py b/lib/streaming_providers/base/settings/provider_enable_manager.py
index 92b15a3..c481a7f 100644
--- a/lib/streaming_providers/base/settings/provider_enable_manager.py
+++ b/lib/streaming_providers/base/settings/provider_enable_manager.py
@@ -3,7 +3,8 @@
Provider Enable Manager - Handles provider enable/disable status with precedence:
1. Kodi settings (if in Kodi environment and setting exists)
2. providers_enabled.json file
-3. Default to enabled (True)
+3. Default from the file's "default_enabled" (True for legacy files without the key,
+ False once migrate_default_to_disabled() has run)
Works in both Kodi and standalone environments.
"""
@@ -117,6 +118,10 @@ class ProviderEnableManager:
)
del data["providers"][provider]
+ # Persisted default for providers without an explicit entry
+ if "default_enabled" in data and not isinstance(data["default_enabled"], bool):
+ del data["default_enabled"]
+
# Add last_updated if missing
if "last_updated" not in data:
data["last_updated"] = current_time
@@ -230,7 +235,7 @@ class ProviderEnableManager:
Precedence:
1. Kodi settings (if in Kodi and setting exists)
2. providers_enabled.json file
- 3. Default to True
+ 3. File's "default_enabled" (legacy files: True; migrated files: False)
Args:
provider_name: Provider name (e.g., "joyn_de", "rtlplus")
@@ -250,8 +255,8 @@ class ProviderEnableManager:
if file_enabled is not None:
return file_enabled
- # 3. Default to enabled
- return True
+ # 3. Persisted default (legacy files have no key -> unchanged behaviour)
+ return bool(data.get("default_enabled", True))
def get_enabled_source(self, provider_name: str) -> EnableSource:
"""
@@ -422,6 +427,7 @@ class ProviderEnableManager:
default_data = {
"version": self.DEFAULT_VERSION,
"last_updated": time.time(),
+ "default_enabled": False,
"providers": {},
}
@@ -464,6 +470,51 @@ class ProviderEnableManager:
),
}
+ def needs_default_migration(self) -> bool:
+ """
+ True if providers_enabled.json is missing or readable but has no
+ "default_enabled" key. An unreadable / malformed file returns False:
+ never rewrite what we cannot parse, the legacy default (True) stays.
+ """
+ try:
+ if not self.vfs.exists(self.DEFAULT_FILENAME):
+ return True
+ raw = self.vfs.read_json(self.DEFAULT_FILENAME)
+ except Exception as e:
+ logger.warning(f"ProviderEnableManager: cannot read file for migration check: {e}")
+ return False
+ if not isinstance(raw, dict):
+ return False
+ return "default_enabled" not in raw
+
+ def migrate_default_to_disabled(self, current_states: Dict[str, bool]) -> Tuple[bool, str, int]:
+ """
+ One-time migration: freeze today's effective state, then flip the default.
+
+ Every provider in `current_states` (name -> enabled as it was just
+ discovered under the OLD default) gets an explicit entry unless the file
+ already has one; afterwards providers WITHOUT an entry default to
+ disabled. Existing installs therefore keep exactly what they have.
+
+ Returns (success, message, entries_added). Idempotent.
+ """
+ if not self.needs_default_migration():
+ return True, "default already migrated (or file unreadable)", 0
+
+ data = self._load_file(force_reload=True)
+ added = 0
+ for name, enabled in current_states.items():
+ if name not in data["providers"]:
+ data["providers"][name] = bool(enabled)
+ added += 1
+ data["default_enabled"] = False
+
+ if not self._save_file(data):
+ return False, "Failed to save migrated provider defaults", 0
+ message = f"Default flipped to disabled; froze {added} provider entries"
+ logger.info(f"ProviderEnableManager: {message}")
+ return True, message, added
+
def migrate_from_kodi(self) -> Tuple[bool, str, int]:
"""
Migrate all Kodi enable settings to the file.
@@ -516,4 +567,4 @@ class ProviderEnableManager:
except Exception as e:
message = f"Error migrating from Kodi: {e}"
logger.error(f"ProviderEnableManager: {message}")
- return False, message, 0
+ return False, message, 0
\ No newline at end of file
diff --git a/routes/providers.py b/routes/providers.py
index b15ad52..b4cbc8a 100644
--- a/routes/providers.py
+++ b/routes/providers.py
@@ -67,6 +67,9 @@ def setup_provider_routes(app, manager, service):
try:
# Get metadata for ALL providers (enabled + disabled)
all_metadata = manager.get_all_providers_metadata()
+ for metadata in all_metadata:
+ if metadata["enabled"]:
+ metadata["channel_state"] = manager.get_channel_state(metadata["name"])
# For backward compatibility, also get details for enabled providers
enabled_providers = []
@@ -131,6 +134,8 @@ def setup_provider_routes(app, manager, service):
"enabled": metadata["enabled"],
"instance_ready": metadata["instance_ready"],
"requires_credentials": metadata["requires_credentials"],
+ # idle | warming | ready | error (+ channel_count, age_seconds, stale)
+ "channel_state": metadata.get("channel_state"),
}
enabled_providers.append(provider_details)
diff --git a/service.py b/service.py
index 500d1ec..b2b93c0 100644
--- a/service.py
+++ b/service.py
@@ -2231,14 +2231,24 @@ def start_service(service_instance):
# Determine if we should run in debug mode
debug_mode = service_instance.env_manager.get_config("debug_mode", False)
- run(
- service_instance.app,
- server=ThreadedWSGIRefServer,
- host="0.0.0.0",
- port=port,
- quiet=not debug_mode,
- debug=debug_mode,
- )
+ # Preload channel lists in the background; the server below starts at once.
+ try:
+ service_instance.manager.warm_up()
+ service_instance.manager.start_auto_refresh()
+ except Exception as e:
+ logger.warning(f"Channel warm-up could not be started: {e}")
+
+ try:
+ run(
+ service_instance.app,
+ server=ThreadedWSGIRefServer,
+ host="0.0.0.0",
+ port=port,
+ quiet=not debug_mode,
+ debug=debug_mode,
+ )
+ finally:
+ service_instance.manager.stop_auto_refresh()
def run_kodi_service():