Skip to content
9 changes: 6 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,12 @@ All notable changes to this project are documented here. The format follows

### Fixed

- Broad `except Exception: pass` handlers are gone from the database layer,
recorder, worker and clip engine; 46 such handlers remain elsewhere (18 in
the web UI, the rest in storage providers and API clients).
- Broad `except Exception: pass` handlers are gone from the whole code base
(database layer, recorder, worker, clip engine, web UI, storage providers
and API clients). The failures they hid are now caught by class and logged;
fallback values are unchanged.
- A deleted VOD or clip whose file was still on disk opened in the player:
the tombstone check's `abort(404)` was swallowed by its own error handler.
- A fresh database no longer logs a bogus `users.role` migration warning.

## [0.1.0] - 2026-11-09
Expand Down
113 changes: 63 additions & 50 deletions WEBUI/webui.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import bisect
import glob
import hashlib
import http.client
import ipaddress
import json
import logging
Expand Down Expand Up @@ -1721,8 +1722,8 @@ def current_user_bypasses_maintenance() -> bool:
username = session.get("username")
if username and site_maintenance.is_user_exempt(username, current_user_role()):
return True
except Exception:
pass
except (OSError, ValueError) as exc:
logging.warning("Maintenance bypass check failed, treating user as not exempt: %s", exc)
return False


Expand Down Expand Up @@ -2580,16 +2581,16 @@ def list_all_streamer_folders():
folders.add(name)
if arch and not is_system_backup_slug(arch):
folders.add(arch)
except Exception:
pass
except sqlite3.Error as exc:
logging.warning("Folder list without channel configs (database error): %s", exc)

rows = []
profiles = load_profiles()
configs = {}
try:
configs = {c.get("name"): c for c in database.list_channel_configs()}
except Exception:
pass
except sqlite3.Error as exc:
logging.warning("Folder list cannot mark stream channels (database error): %s", exc)

for f in sorted(folders, key=lambda s: s.lower()):
p_data = profiles.get(f) or {}
Expand Down Expand Up @@ -2649,6 +2650,13 @@ def list_system_backups():
return sorted(items.values(), key=lambda x: x["mtime"], reverse=True)


def _twitch_gql_user(data):
"""The `data.user` object of a Twitch GQL answer, or None for any other shape."""
inner = data.get("data") if isinstance(data, dict) else None
user = inner.get("user") if isinstance(inner, dict) else None
return user if isinstance(user, dict) and user else None


def get_twitch_profile_and_banner(channel):
query = {
"query": """
Expand All @@ -2674,11 +2682,12 @@ def get_twitch_profile_and_banner(channel):
)
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode('utf-8'))
if data and "data" in data and data["data"].get("user"):
u = data["data"]["user"]
return u.get("profileImageURL"), u.get("bannerImageURL")
except Exception:
pass
except (OSError, ValueError, http.client.HTTPException) as exc:
logging.warning("Twitch profile lookup for %s failed: %s", channel, exc)
return None, None
user = _twitch_gql_user(data)
if user:
return user.get("profileImageURL"), user.get("bannerImageURL")
return None, None

def get_twitch_profile_image(channel):
Expand Down Expand Up @@ -2747,12 +2756,14 @@ def get_twitch_user_id(channel):
)
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8"))
user_id = data.get("data", {}).get("user", {}).get("id") if isinstance(data, dict) else None
if user_id:
TWITCH_USER_ID_CACHE[login] = {"id": str(user_id), "ts": now}
return str(user_id)
except Exception:
pass
except (OSError, ValueError, http.client.HTTPException) as exc:
logging.warning("Twitch user id lookup for %s failed: %s", login, exc)
data = None
user = _twitch_gql_user(data)
user_id = user.get("id") if user else None
if user_id:
TWITCH_USER_ID_CACHE[login] = {"id": str(user_id), "ts": now}
return str(user_id)
TWITCH_USER_ID_CACHE[login] = {"id": None, "ts": now}
return None

Expand Down Expand Up @@ -4756,23 +4767,20 @@ def can_view_streamer(streamer):
return False

candidates = {str(streamer).strip().lower()}
try:
norm = normalize_channel_name(streamer)
if norm:
candidates.add(norm.lower())
except Exception:
pass
norm = normalize_channel_name(streamer)
if norm:
candidates.add(norm.lower())
try:
arch = archive_name_for(streamer)
if arch:
candidates.add(arch.lower())
except Exception:
pass
except sqlite3.Error as exc:
logging.warning("Access check for %s without archive name (database error): %s", streamer, exc)
try:
for s in related_archive_slugs(streamer) or []:
candidates.add(str(s).strip().lower())
except Exception:
pass
except sqlite3.Error as exc:
logging.warning("Access check for %s without archive aliases (database error): %s", streamer, exc)

# 1. Check disabled_channels (Folder explicitly shut off / disabled)
disabled_set = ctx.get("disabled_channels") or set()
Expand Down Expand Up @@ -4827,18 +4835,22 @@ def abort_deleted_media(streamer, filename, file_id=None):
slugs = set()
try:
slugs.update(related_archive_slugs(streamer) or [])
except Exception:
pass
except sqlite3.Error as exc:
logging.warning("Tombstone check for %s/%s without aliases (database error): %s", streamer, name, exc)
primary = archive_name_for(streamer) or str(streamer or "").strip()
if primary:
slugs.add(primary)
if streamer:
slugs.add(str(streamer).strip())
try:
if database.is_deleted_media(filename=name, file_id=file_id, streamers=slugs):
abort(404)
except Exception:
pass
deleted = database.is_deleted_media(filename=name, file_id=file_id, streamers=slugs)
except sqlite3.Error as exc:
# Same outcome as before: without the tombstone table the file
# itself decides (resolve_media_path still 404s a missing file).
logging.warning("Tombstone check for %s/%s failed, serving: %s", streamer, name, exc)
return
if deleted:
abort(404)


def visible_streamers(streamers=None):
Expand Down Expand Up @@ -5776,8 +5788,8 @@ def read_media_sidecar_metadata(media_path):
m_w = re.search(r"(?:Neuer Status|Stream-Titel):\s*(.+)$", msg, re.IGNORECASE)
if m_w and not title:
title = m_w.group(1).strip()
except Exception:
pass
except OSError as exc:
logging.warning("Title from chat log of %s unavailable: %s", media_path, exc)

return {
"title": clean_metadata_label(title, 180),
Expand Down Expand Up @@ -6224,8 +6236,8 @@ def resolve_media_path(streamer, filename, route):
candidates.append(streamer)
try:
candidates.extend(related_archive_slugs(streamer))
except Exception:
pass
except sqlite3.Error as exc:
logging.warning("Media lookup for %s without archive aliases (database error): %s", streamer, exc)
seen = set()
files_dir = getattr(config, "FILES_DIR", None) or os.path.join(BASE_DIR, "files")
for cand in candidates:
Expand Down Expand Up @@ -7070,8 +7082,8 @@ def delete_video_file(streamer, filename, is_clip):
tombstone_slugs = set()
try:
tombstone_slugs.update(related_archive_slugs(streamer) or [])
except Exception:
pass
except sqlite3.Error as exc:
logging.warning("Tombstone for %s/%s without archive aliases (database error): %s", streamer, filename, exc)
primary = archive_name_for(streamer) or str(streamer or "").strip()
if primary:
tombstone_slugs.add(primary)
Expand Down Expand Up @@ -7102,8 +7114,8 @@ def delete_video_file(streamer, filename, is_clip):
_td, alt_path, alt_lock = thumb_paths(slug, filename)
paths_to_clear.add(alt_path)
paths_to_clear.add(alt_lock)
except Exception:
pass
except sqlite3.Error as exc:
logging.warning("Thumbnails of %s/%s under alias folders kept (database error): %s", streamer, filename, exc)
for path in paths_to_clear:
if not path:
continue
Expand Down Expand Up @@ -8947,13 +8959,13 @@ def _vod_access_candidate_slugs():
name = str((item or {}).get("name") or "").strip()
if name:
found.add(database._normalize_vod_access_slug(name) or name.rstrip("_").lower())
except Exception:
pass
except (OSError, sqlite3.Error) as exc:
logging.warning("VOD access candidates without archive folders: %s", exc)
try:
for slug in database.list_vod_access_defaults():
found.add(slug)
except Exception:
pass
except sqlite3.Error as exc:
logging.warning("VOD access candidates without stored defaults: %s", exc)
return sorted(s for s in found if s)


Expand Down Expand Up @@ -11858,8 +11870,8 @@ def _recording_logins_set():
login = twitch_api.normalize_login(conf.get("name"))
if login and current_recording_info(login) is not None:
logins.add(login)
except Exception:
pass
except (sqlite3.Error, OSError) as exc:
logging.warning("Recording channel list unavailable: %s", exc)
return logins


Expand Down Expand Up @@ -11993,16 +12005,17 @@ def twitch_watch(channel):
conf = database.get_channel_config(login)
if conf:
archive_name = normalize_channel_name(conf.get("archive_name")) or login
except Exception:
except sqlite3.Error as exc:
logging.warning("Live watch %s: channel config unavailable: %s", login, exc)
archive_name = ""
if not archive_name:
try:
for conf in database.list_channel_configs():
if twitch_api.normalize_login(conf.get("name")) == login:
archive_name = normalize_channel_name(conf.get("archive_name")) or login
break
except Exception:
pass
except sqlite3.Error as exc:
logging.warning("Live watch %s: channel list unavailable: %s", login, exc)

# Default: Twitch embed without account cookies (iframe credentialless ≈ Incognito).
# Optional ?proxy=1 for server HLS when embed is blocked or unsupported.
Expand Down
4 changes: 2 additions & 2 deletions core/chat_chunker.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ def ensure_chat_chunks(
if os.path.getmtime(meta_path) >= os.path.getmtime(log_path):
with open(meta_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
except (OSError, ValueError) as exc:
logger.warning("chat chunk cache %s unreadable, rebuilding: %s", meta_path, exc)

if not os.path.isfile(log_path) or os.path.getsize(log_path) == 0:
return {"ok": False, "error": "Log file not found or empty", "total_chunks": 0}
Expand Down
12 changes: 8 additions & 4 deletions core/live_watch_diag.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@

from __future__ import annotations

import logging
import os
import re
import sqlite3
import subprocess
import time
from pathlib import Path
from typing import Any

from core import config

logger = logging.getLogger(__name__)

LIVE_HLS_TTL_SEC = int(os.environ.get("LIVE_HLS_TTL_SEC", "24"))
LIVE_WATCH_DEMAND_SEC = int(os.environ.get("LIVE_WATCH_DEMAND_SEC", "120"))
LIVE_STALL_RESTART_SEC = int(os.environ.get("LIVE_STALL_RESTART_SEC", "12"))
Expand Down Expand Up @@ -84,8 +88,8 @@ def list_candidate_channels(extra: list[str] | None = None) -> list[str]:
found.add(name)
if int(conf.get("live") or 0) == 1 and name:
found.add(name)
except Exception:
pass
except (sqlite3.Error, ValueError) as exc:
logger.warning("live diag: channel configs unavailable: %s", exc)
for name in extra or []:
if name:
found.add(str(name).strip().lower())
Expand Down Expand Up @@ -173,8 +177,8 @@ def diagnose_channel(channel: str, *, logs: int = 30, helix: dict | None = None)
if conf:
db_live = int(conf.get("live") or 0)
db_record = int(conf.get("record") or 0)
except Exception:
pass
except (sqlite3.Error, ValueError) as exc:
logger.warning("live diag %s: channel config unavailable: %s", channel, exc)

helix_info = (helix or {}).get(channel) if helix is not None else None
if helix_info is None:
Expand Down
4 changes: 2 additions & 2 deletions core/redis_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,8 @@ def invalidate_dashboard_cache() -> bool:
shm_path = "/dev/shm/streamrecorder_dashboard_media.json"
if os.path.exists(shm_path):
os.remove(shm_path)
except Exception:
pass
except OSError as exc:
logger.warning("dashboard shm cache %s not removed: %s", shm_path, exc)
return True


Expand Down
16 changes: 13 additions & 3 deletions core/storage/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,19 @@

from __future__ import annotations

import logging
import mimetypes
import os
import posixpath
import re
import sqlite3
from dataclasses import dataclass
from typing import Callable, Iterable, Iterator, Optional

from flask_babel import gettext, lazy_gettext

logger = logging.getLogger("storage.base")


class StorageError(RuntimeError):
"""A provider/configuration error with optional retry semantics."""
Expand Down Expand Up @@ -210,10 +214,16 @@ def _config_changed(self) -> None:
return
try:
callback(dict(self.config))
except Exception:
except (StorageError, sqlite3.Error, OSError, ValueError) as exc:
# Token rotation must never make a request fail just because the
# optional persistence callback is unavailable.
pass
# optional persistence callback is unavailable. The rotated token
# stays valid in memory; after a restart the stored one is used,
# which a provider with single-use refresh tokens may reject.
logger.warning(
"%s: rotated credentials not persisted: %s",
self.type_id or "storage",
mask_error(exc),
)

# ------------------------------------------------------------------
# Configuration helpers
Expand Down
Loading
Loading