diff --git a/buzz/Dockerfile b/buzz/Dockerfile index c5240d8..3c66df7 100644 --- a/buzz/Dockerfile +++ b/buzz/Dockerfile @@ -13,8 +13,8 @@ RUN apk add --no-cache \ # Install dependencies first to improve Docker layer caching COPY pyproject.toml README.md buzz.dist.yml /app/ -RUN mkdir -p /app/buzz/core && touch /app/buzz/__init__.py /app/buzz/core/__init__.py -RUN uv pip install --system . +RUN mkdir -p /app/buzz/core && touch /app/buzz/__init__.py /app/buzz/core/__init__.py && \ + uv pip install --system . # Copy application code and scripts COPY buzz /app/buzz diff --git a/buzz/core/curator.py b/buzz/core/curator.py index b4ebe6d..4716d6d 100644 --- a/buzz/core/curator.py +++ b/buzz/core/curator.py @@ -287,6 +287,63 @@ def build_library(config: CuratorConfig) -> dict: return report +def _process_movie_file( + path: Path, + source_root: Path, + target_root: Path, + all_source_root: Path, + overrides: dict, + used_targets: set[str], + report: dict, + mapping: list[dict], +) -> bool: + rel_path = source_relpath(all_source_root, path) + source_rel = path.relative_to(source_root) + folder = source_rel.parts[0] if len(source_rel.parts) > 1 else "" + + parsed = parse_movie(path.stem, folder=folder) + override = overrides.get(rel_path, {}) + if parsed is None and not override: + report["skipped_movies"].append( + {"source": rel_path, "reason": "unable to parse movie title/year"} + ) + return False + if parsed is None: + parsed = {"title": "", "year": 0} + apply_movie_override(parsed, override) + if not parsed.get("title") or not parsed.get("year"): + report["skipped_movies"].append( + {"source": rel_path, "reason": "movie override missing title/year"} + ) + return False + + folder_name = movie_folder_name(parsed) + target_file = target_root / folder_name / f"{folder_name}{path.suffix.lower()}" + target_key = target_file.as_posix() + if target_key in used_targets: + report["skipped_movies"].append( + {"source": rel_path, "reason": "duplicate canonical movie target"} + ) + return False + + ensure_symlink(path, target_file) + used_targets.add(target_key) + mapping.append( + { + "source": rel_path, + "target": target_file.relative_to(target_root.parent).as_posix(), + "type": "movie", + } + ) + report["movies"] += 1 + + for companion in find_companion_files(path): + extra = companion.name[len(path.stem) :] + companion_target = target_root / folder_name / f"{folder_name}{extra}" + ensure_symlink(companion, companion_target) + return True + + def build_movies( source_root: Path, target_root: Path, @@ -299,70 +356,119 @@ def build_movies( target_root.mkdir(parents=True, exist_ok=True) if not source_root.exists(): return - used_targets = set() + used_targets: set[str] = set() for path in iter_files(source_root): if not is_video_file(path): continue - rel_path = source_relpath(all_source_root, path) + _process_movie_file( + path, source_root, target_root, all_source_root, + overrides, used_targets, report, mapping, + ) - # Determine torrent folder name if file is in a subdirectory - source_rel = path.relative_to(source_root) - folder = source_rel.parts[0] if len(source_rel.parts) > 1 else "" - parsed = parse_movie(path.stem, folder=folder) +def _plan_show_group( + files: list[Path], + source_root: Path, + target_root: Path, + all_source_root: Path, + overrides: dict, + global_targets: set[str], +) -> tuple[list[dict], list[dict]]: + planned = [] + group_errors = [] + group_series = None + used_targets: set[str] = set() + for path in sorted(files): + rel_path = source_relpath(all_source_root, path) + parsed = parse_show(path.stem) override = overrides.get(rel_path, {}) if parsed is None and not override: - report["skipped_movies"].append( + group_errors.append( + {"source": rel_path, "reason": "unable to parse show season/episode"} + ) + continue + if parsed is None: + parsed = {"series": "", "season": 0, "episode": 0} + apply_show_override(parsed, override) + if ( + not parsed.get("series") + or parsed.get("season") is None + or parsed.get("episode") is None + ): + group_errors.append( { "source": rel_path, - "reason": "unable to parse movie title/year", + "reason": "show override missing series/season/episode", } ) continue - if parsed is None: - parsed = {"title": "", "year": 0} - apply_movie_override(parsed, override) - if not parsed.get("title") or not parsed.get("year"): - report["skipped_movies"].append( + series_name = show_series_name(parsed) + if group_series is None: + group_series = series_name + elif group_series != series_name: + group_errors.append( { "source": rel_path, - "reason": "movie override missing title/year", + "reason": "inconsistent parsed show name within torrent", } ) continue - folder_name = movie_folder_name(parsed) + season_dir = f"Season {int(parsed['season']):02d}" + base_name = ( + f"{series_name} S{int(parsed['season']):02d}" + f"E{int(parsed['episode']):02d}" + ) target_file = ( target_root - / folder_name - / f"{folder_name}{path.suffix.lower()}" + / series_name + / season_dir + / f"{base_name}{path.suffix.lower()}" ) target_key = target_file.as_posix() - if target_key in used_targets: - report["skipped_movies"].append( - { - "source": rel_path, - "reason": "duplicate canonical movie target", - } + if target_key in used_targets or target_key in global_targets: + group_errors.append( + {"source": rel_path, "reason": "duplicate season/episode target"} ) continue - ensure_symlink(path, target_file) used_targets.add(target_key) + planned.append( + { + "path": path, + "rel_path": rel_path, + "target_file": target_file, + "base_name": base_name, + } + ) + return planned, group_errors + + +def _apply_show_planned( + planned: list[dict], + target_root: Path, + global_targets: set[str], + mapping: list[dict], + report: dict, +) -> None: + for item in planned: + path = item["path"] + rel_path = item["rel_path"] + target_file = item["target_file"] + base_name = item["base_name"] + ensure_symlink(path, target_file) + global_targets.add(target_file.as_posix()) mapping.append( { "source": rel_path, "target": target_file.relative_to( target_root.parent ).as_posix(), - "type": "movie", + "type": "show", } ) - report["movies"] += 1 - + report["show_files"] += 1 for companion in find_companion_files(path): extra = companion.name[len(path.stem) :] - companion_target = ( - target_root / folder_name / f"{folder_name}{extra}" - ) + companion_target = target_file.parent / f"{base_name}{extra}" ensure_symlink(companion, companion_target) @@ -378,8 +484,8 @@ def build_shows( target_root.mkdir(parents=True, exist_ok=True) if not source_root.exists(): return - grouped = {} - global_targets = set() + grouped: dict[str, list[Path]] = {} + global_targets: set[str] = set() for path in iter_files(source_root): if not is_video_file(path): continue @@ -388,99 +494,16 @@ def build_shows( grouped.setdefault(group_key, []).append(path) for group_name, files in sorted(grouped.items()): - planned = [] - group_errors = [] - group_series = None - used_targets = set() - for path in sorted(files): - rel_path = source_relpath(all_source_root, path) - parsed = parse_show(path.stem) - override = overrides.get(rel_path, {}) - if parsed is None and not override: - group_errors.append( - { - "source": rel_path, - "reason": "unable to parse show season/episode", - } - ) - continue - if parsed is None: - parsed = {"series": "", "season": 0, "episode": 0} - apply_show_override(parsed, override) - if ( - not parsed.get("series") - or parsed.get("season") is None - or parsed.get("episode") is None - ): - group_errors.append( - { - "source": rel_path, - "reason": ( - "show override missing series/season/episode" - ), - } - ) - continue - if group_series is None: - group_series = show_series_name(parsed) - elif group_series != show_series_name(parsed): - group_errors.append( - { - "source": rel_path, - "reason": ( - "inconsistent parsed show name within torrent" - ), - } - ) - continue - season_dir = f"Season {int(parsed['season']):02d}" - base_name = ( - f"{show_series_name(parsed)} " - f"S{int(parsed['season']):02d}" - f"E{int(parsed['episode']):02d}" - ) - target_file = ( - target_root - / show_series_name(parsed) - / season_dir - / f"{base_name}{path.suffix.lower()}" - ) - target_key = target_file.as_posix() - if target_key in used_targets or target_key in global_targets: - group_errors.append( - { - "source": rel_path, - "reason": "duplicate season/episode target", - } - ) - continue - used_targets.add(target_key) - planned.append((path, rel_path, target_file)) - + planned, group_errors = _plan_show_group( + files, source_root, target_root, all_source_root, + overrides, global_targets, + ) if group_errors: report["skipped_shows"].append( {"group": group_name, "errors": group_errors} ) continue - - for path, rel_path, target_file in planned: - ensure_symlink(path, target_file) - global_targets.add(target_file.as_posix()) - mapping.append( - { - "source": rel_path, - "target": target_file.relative_to( - target_root.parent - ).as_posix(), - "type": "show", - } - ) - report["show_files"] += 1 - base_name = target_file.stem - for companion in find_companion_files(path): - extra = companion.name[len(path.stem) :] - companion_target = target_file.parent / f"{base_name}{extra}" - ensure_symlink(companion, companion_target) + _apply_show_planned(planned, target_root, global_targets, mapping, report) def build_anime( diff --git a/buzz/core/media_server.py b/buzz/core/media_server.py index 7124398..04dcb51 100644 --- a/buzz/core/media_server.py +++ b/buzz/core/media_server.py @@ -160,8 +160,8 @@ def trigger_jellyfin_selective_refresh( }, ) try: - with request.urlopen(req, timeout=30): - pass + with request.urlopen(req, timeout=30) as resp: + _ = resp.read() except Exception as exc: record_event( f"Failed to refresh Jellyfin library '{name}': {exc}", diff --git a/buzz/core/state.py b/buzz/core/state.py index 189ff6b..eff7a1a 100644 --- a/buzz/core/state.py +++ b/buzz/core/state.py @@ -127,27 +127,13 @@ def build( if item.get("url") and info.get("status") == "downloaded" ] if linked_playable: - category = self._category_for(linked_playable) - self._add_tree(files, dirs, category, torrent_name, linked_playable) - if self.config.enable_all_dir: - self._add_tree( - files, dirs, "__all__", torrent_name, linked_playable - ) - current_roots.add(f"{category}/{torrent_name}") - if category == "movies": - report["movies"] += len(linked_playable) - elif category == "shows": - report["show_files"] += len(linked_playable) - else: - report["anime_files"] += len(linked_playable) + self._add_playable_tree( + files, dirs, torrent_name, linked_playable, report, current_roots + ) elif self.config.enable_unplayable_dir: - reason = self._unplayable_reason(info, selected) - count = self._add_unplayable_tree( - files, dirs, torrent_name, selected, reason + self._add_unplayable_entry( + files, dirs, torrent_name, selected, info, report, current_roots ) - if count: - current_roots.add(f"__unplayable__/{torrent_name}") - report["unplayable_files"] += count snapshot = { "generated_at": report["generated_at"], @@ -157,6 +143,45 @@ def build( } return snapshot, sorted(current_roots) + def _add_playable_tree( + self, + files: dict[str, SnapshotNode], + dirs: set[str], + torrent_name: str, + linked_playable: list[dict], + report: dict, + current_roots: set[str], + ) -> None: + category = self._category_for(linked_playable) + self._add_tree(files, dirs, category, torrent_name, linked_playable) + if self.config.enable_all_dir: + self._add_tree(files, dirs, "__all__", torrent_name, linked_playable) + current_roots.add(f"{category}/{torrent_name}") + if category == "movies": + report["movies"] += len(linked_playable) + elif category == "shows": + report["show_files"] += len(linked_playable) + else: + report["anime_files"] += len(linked_playable) + + def _add_unplayable_entry( + self, + files: dict[str, SnapshotNode], + dirs: set[str], + torrent_name: str, + selected: list[dict], + info: TorrentInfo, + report: dict, + current_roots: set[str], + ) -> None: + reason = self._unplayable_reason(info, selected) + count = self._add_unplayable_tree( + files, dirs, torrent_name, selected, reason + ) + if count: + current_roots.add(f"__unplayable__/{torrent_name}") + report["unplayable_files"] += count + def _selected_files(self, info: TorrentInfo) -> list[TorrentInfo]: selected = [item for item in info.get("files", []) if item.get("selected")] links = list(info.get("links") or []) @@ -503,44 +528,12 @@ def sync(self, *, trigger_hook: bool = True) -> SyncReport: self.sync_in_progress = True try: summaries = self.client.torrents.get().json() - new_cache: dict[str, TorrentInfo] = {} - infos: list[TorrentInfo] = [] - for summary in summaries: - torrent_id = str(summary.get("id", "")).strip() - if not torrent_id: - continue - signature = self._summary_signature(summary) - with self.lock: - cached = self.cache.get(torrent_id) - if ( - cached - and cached.get("signature") == signature - and isinstance(cached.get("info"), dict) - ): - info = cached["info"] - else: - info = self.client.torrents.info(torrent_id).json() - cached_magnet = cached.get("magnet") if cached else None - new_cache[torrent_id] = { - "signature": signature, - "info": info, - "magnet": cached_magnet, - } - infos.append(info) - + new_cache, infos = self._build_torrent_cache(summaries) snapshot, _current_roots = self.builder.build(infos) digest = stable_json(canonical_snapshot(snapshot)) with self.lock: - removed_torrent_ids = set(self.cache) - set(new_cache) - for torrent_id in removed_torrent_ids: - cached = self.cache.get(torrent_id) - if not isinstance(cached, dict): - continue - info = cached.get("info") - if isinstance(info, dict) and info.get("hash"): - self._add_to_archive(info, magnet=cached.get("magnet")) - + self._archive_removed_torrents(new_cache) changed = digest != self.snapshot_digest classified_changes = ( self._classified_changed_roots(self.snapshot, snapshot) @@ -595,6 +588,47 @@ def sync(self, *, trigger_hook: bool = True) -> SyncReport: with self.lock: self.sync_in_progress = False + def _build_torrent_cache( + self, summaries: list[dict] + ) -> tuple[dict[str, TorrentInfo], list[TorrentInfo]]: + new_cache: dict[str, TorrentInfo] = {} + infos: list[TorrentInfo] = [] + for summary in summaries: + torrent_id = str(summary.get("id", "")).strip() + if not torrent_id: + continue + signature = self._summary_signature(summary) + with self.lock: + cached = self.cache.get(torrent_id) + if ( + cached + and cached.get("signature") == signature + and isinstance(cached.get("info"), dict) + ): + info = cached["info"] + else: + info = self.client.torrents.info(torrent_id).json() + cached_magnet = cached.get("magnet") if cached else None + new_cache[torrent_id] = { + "signature": signature, + "info": info, + "magnet": cached_magnet, + } + infos.append(info) + return new_cache, infos + + def _archive_removed_torrents( + self, new_cache: dict[str, TorrentInfo] + ) -> None: + removed_torrent_ids = set(self.cache) - set(new_cache) + for torrent_id in removed_torrent_ids: + cached = self.cache.get(torrent_id) + if not isinstance(cached, dict): + continue + info = cached.get("info") + if isinstance(info, dict) and info.get("hash"): + self._add_to_archive(info, magnet=cached.get("magnet")) + def _enqueue_hook(self, changed_roots: list[str]) -> None: pending = set(changed_roots) if not pending: @@ -681,7 +715,6 @@ def _wait_for_vfs_visibility(self, roots: list[str]) -> None: timeout = self.config.vfs_wait_timeout_secs start_time = time.time() - # Determine current state of each root in our internal snapshot with self.lock: snapshot_roots = set() for path in self.snapshot.get("files", {}): @@ -689,14 +722,11 @@ def _wait_for_vfs_visibility(self, roots: list[str]) -> None: if root: snapshot_roots.add(root) - to_check = [] - for root in roots: - # We only care about visibility of media roots - if not any(root.startswith(p) for p in ["movies/", "shows/", "anime/"]): - continue - expected = root in snapshot_roots - to_check.append((root, expected)) - + to_check = [ + (root, root in snapshot_roots) + for root in roots + if any(root.startswith(p) for p in ("movies/", "shows/", "anime/")) + ] if not to_check: return @@ -705,37 +735,35 @@ def _wait_for_vfs_visibility(self, roots: list[str]) -> None: ) while time.time() - start_time < timeout: - all_visible = True - missing = [] - stale = [] - - for root, expected in to_check: - path = os.path.join(mount, root) - exists = os.path.exists(path) - if expected and not exists: - all_visible = False - missing.append(root) - elif not expected and exists: - all_visible = False - stale.append(root) - + all_visible, missing, stale = self._check_vfs_roots(mount, to_check) if all_visible: elapsed = int(time.time() - start_time) self.verbose_log(f"VFS visibility confirmed after {elapsed}s") return - - # Periodically log progress if there are many items or we've waited a bit if int(time.time() - start_time) % 30 == 0: self.verbose_log( f"VFS still syncing... (missing: {len(missing)}, stale: {len(stale)})" ) - time.sleep(2) self.verbose_log( f"VFS visibility timeout reached after {timeout}s. Proceeding with sync." ) + def _check_vfs_roots( + self, mount: str, to_check: list[tuple[str, bool]] + ) -> tuple[bool, list[str], list[str]]: + missing: list[str] = [] + stale: list[str] = [] + for root, expected in to_check: + path = os.path.join(mount, root) + exists = os.path.exists(path) + if expected and not exists: + missing.append(root) + elif not expected and exists: + stale.append(root) + return not (missing or stale), missing, stale + def _trigger_curator(self, changed_roots: list[str]) -> None: if not self.config.curator_url: return @@ -759,7 +787,6 @@ def _trigger_curator(self, changed_roots: list[str]) -> None: def _run_hook(self, changed_roots: list[str]) -> None: if not self.config.hook_command: return - # Filter out internal/virtual categories like __unplayable__ and __all__. filtered_roots = [ r for r in changed_roots if not is_internal_category(r.split("/", 1)[0]) ] @@ -779,28 +806,32 @@ def _run_hook(self, changed_roots: list[str]) -> None: ) self.verbose_log("Library update hook completed successfully") except subprocess.TimeoutExpired as exc: - details = [f"Library update hook timed out after {exc.timeout}s: {exc.cmd}"] - stdout = (exc.stdout or "").strip() - stderr = (exc.stderr or "").strip() - if stdout: - details.append(f"stdout:\n{stdout}") - if stderr: - details.append(f"stderr:\n{stderr}") - record_event("\n".join(details), level="error") + self._log_hook_error( + f"Library update hook timed out after {exc.timeout}s: {exc.cmd}", + exc.stdout, + exc.stderr, + ) except subprocess.CalledProcessError as exc: - details = [ - f"Library update hook failed with exit code {exc.returncode}: {exc.cmd}" - ] - stdout = (exc.stdout or "").strip() - stderr = (exc.stderr or "").strip() - if stdout: - details.append(f"stdout:\n{stdout}") - if stderr: - details.append(f"stderr:\n{stderr}") - record_event("\n".join(details), level="error") + self._log_hook_error( + f"Library update hook failed with exit code {exc.returncode}: {exc.cmd}", + exc.stdout, + exc.stderr, + ) except Exception as exc: record_event(f"Library update hook failed: {exc}", level="error") + def _log_hook_error( + self, message: str, stdout: str | bytes | None, stderr: str | bytes | None + ) -> None: + details = [message] + out = stdout.strip() if stdout else "" + err = stderr.strip() if stderr else "" + if out: + details.append(f"stdout:\n{out}") + if err: + details.append(f"stderr:\n{err}") + record_event("\n".join(details), level="error") + def mark_startup_sync_complete(self) -> None: """Flag that the initial startup sync has finished.""" with self.lock: diff --git a/buzz/core/subtitles.py b/buzz/core/subtitles.py index f90d295..7a68879 100644 --- a/buzz/core/subtitles.py +++ b/buzz/core/subtitles.py @@ -431,24 +431,19 @@ def _write_subtitle_meta( conn.close() -def fetch_subtitles_for_library( +def _prepare_mapping( config: CuratorConfig, - mapping: list[dict] | None = None, - torrent_name: str | None = None, -) -> None: - """Fetch subtitles for the entire library or a single torrent.""" - if not config.subtitles.enabled: - return - + mapping: list[dict] | None, + torrent_name: str | None, +) -> list[dict]: if mapping is None: conn = _open_state_db(config) try: mapping = db.load_curator_mapping(conn) finally: conn.close() - if not mapping: - return - + if not mapping: + return [] if torrent_name: mapping = [ e for e in mapping @@ -457,7 +452,6 @@ def fetch_subtitles_for_library( record_event(f"Subtitle fetch triggered for torrent: {torrent_name}") else: record_event("Subtitle fetch triggered for full library") - if not mapping: if torrent_name: record_event( @@ -471,260 +465,244 @@ def fetch_subtitles_for_library( "Try RESYNC LIB first.", level="error", ) + return mapping + + +def _search_desc(params: dict) -> str: + desc = f"query='{params['query']}'" + if params.get("year"): + desc += f", year={params['year']}" + if params.get("season"): + desc += f", S{params['season']:02d}E{params.get('episode', 0):02d}" + return desc + + +def _search_with_fallbacks( + client: OpenSubtitlesClient, + results: list, + strategy: str, + filters: Any, + source_filename: str, + params: dict, +) -> Any: + best = rank_subtitles( + results, strategy, filters, source_filename, + query=params["query"], year=params.get("year"), + ) + if not best and strategy != "most-downloaded": + print( + f"[SUBS] No match with strategy '{strategy}', " + "falling back to most-downloaded", + flush=True, + ) + best = rank_subtitles( + results, "most-downloaded", filters, source_filename, + query=params["query"], year=params.get("year"), + ) + if not best and strategy != "best-match": + print( + "[SUBS] No match with fallback, trying best-match", + flush=True, + ) + best = rank_subtitles( + results, "best-match", filters, source_filename, + query=params["query"], year=params.get("year"), + ) + return best + + +def _install_subtitle( + config: CuratorConfig, + client: OpenSubtitlesClient, + overlay_path: Path, + target_path: Path, + best: dict, + params: dict, + lang: str, +) -> bool | None: + """Download and install a subtitle. Returns True for new, False for replacement, None if already up-to-date.""" + attr = best.get("attributes", {}) + file_id = attr.get("files", [{}])[0].get("file_id") + release = attr.get("release", "unknown") + + if not file_id: + print( + f"[SUBS] WARNING: No file_id in result for '{release}'", + flush=True, + ) + record_event( + f"No file ID in subtitle result for: {params['query']} ({lang})", + level="warning", + ) + return False + + if overlay_path.exists(): + meta = _read_subtitle_meta(config, overlay_path) + if meta and meta.get("file_id") == file_id: + print( + f"[SUBS] Subtitle already up-to-date: '{release}' ({lang})", + flush=True, + ) + return None + + downloads = attr.get("download_count", 0) + ratings = attr.get("ratings", 0) + hi = attr.get("hearing_impaired", False) + print( + f"[SUBS] Selected: '{release}' (lang={lang}, " + f"downloads={downloads}, rating={ratings}, hearing_impaired={hi})", + flush=True, + ) + + is_replacement = overlay_path.exists() + action = "Replacing" if is_replacement else "Downloading" + record_event( + f"{action} subtitle '{release}' ({lang}) for: {params['query']}" + ) + download_link = client.download(file_id) + content = client.fetch_content(download_link) + + overlay_path.parent.mkdir(parents=True, exist_ok=True) + overlay_path.write_bytes(content) + _write_subtitle_meta( + config, overlay_path, {"file_id": file_id, "release": release} + ) + + curated_sub = config.target_root / target_path.parent / f"{target_path.stem}.{lang}.srt" + curated_sub.parent.mkdir(parents=True, exist_ok=True) + if curated_sub.exists() or curated_sub.is_symlink(): + curated_sub.unlink() + os.symlink(overlay_path, curated_sub) + return not is_replacement + + +def _fetch_entry_subtitles( + config: CuratorConfig, + client: OpenSubtitlesClient, + entry: dict, + counters: dict, + fetched_targets: list[str], +) -> None: + target_path = Path(entry["target"]) + if target_path.suffix.lower() not in VIDEO_EXTENSIONS: + return + + source_filename = Path(entry["source"]).name + params = get_search_params(entry) + desc = _search_desc(params) + feature_type = "movie" if entry["type"] == "movie" else "episode" + + for lang in config.subtitles.languages: + overlay_path = ( + config.subtitle_root + / target_path.parent + / f"{target_path.stem}.{lang}.srt" + ) + state.set_current(f"{target_path.stem} ({lang})") + print( + f"[SUBS] Searching OpenSubtitles: {desc}, " + f"lang={lang}, strategy={config.subtitles.strategy}", + flush=True, + ) + + try: + results = client.search( + query=params["query"], + year=params.get("year"), + languages=lang, + season=params.get("season"), + episode=params.get("episode"), + type=feature_type, + ) + print( + f"[SUBS] Search returned {len(results)} results for: {desc}", + flush=True, + ) + + best = _search_with_fallbacks( + client, results, config.subtitles.strategy, + config.subtitles.filters, source_filename, params, + ) + if not best: + print( + f"[SUBS] No suitable subtitle found for: {desc} ({lang})", + flush=True, + ) + counters["skipped"] += 1 + time.sleep(config.subtitles.search_delay_secs) + continue + + is_new = _install_subtitle( + config, client, overlay_path, target_path, best, params, lang + ) + if is_new is None: + counters["already_exists"] += 1 + time.sleep(config.subtitles.search_delay_secs) + continue + if is_new: + counters["fetched"] += 1 + else: + counters["replaced"] += 1 + fetched_targets.append(entry["target"]) + time.sleep(config.subtitles.download_delay_secs) + time.sleep(config.subtitles.search_delay_secs) + + except Exception as e: + print(f"[SUBS] ERROR: {params['query']} ({lang}): {e}", flush=True) + record_event( + f"Subtitle error for {params['query']} ({lang}): {e}", + level="error", + ) + state.error_count += 1 + counters["errors"] += 1 + + +def _subtitle_summary(counters: dict) -> str: + parts = [] + if counters["fetched"] > 0: + parts.append(f"{counters['fetched']} downloaded") + if counters["replaced"] > 0: + parts.append(f"{counters['replaced']} replaced") + if counters["skipped"] > 0: + parts.append(f"{counters['skipped']} no match") + if counters["errors"] > 0: + parts.append(f"{counters['errors']} errors") + if counters["already_exists"] > 0: + parts.append(f"{counters['already_exists']} already up-to-date") + if not parts: + return "Subtitle fetch complete: nothing to do" + return "Subtitle fetch complete: " + ", ".join(parts) + + +def fetch_subtitles_for_library( + config: CuratorConfig, + mapping: list[dict] | None = None, + torrent_name: str | None = None, +) -> None: + """Fetch subtitles for the entire library or a single torrent.""" + if not config.subtitles.enabled: + return + + mapping = _prepare_mapping(config, mapping, torrent_name) + if not mapping: return state.start() - fetched_count = 0 - replaced_count = 0 - skipped_count = 0 - error_count = 0 - already_exists_count = 0 - fetched_targets = [] + counters = { + "fetched": 0, + "replaced": 0, + "skipped": 0, + "errors": 0, + "already_exists": 0, + } + fetched_targets: list[str] = [] try: with OpenSubtitlesClient(config.subtitles) as client: for entry in mapping: - target_path = Path(entry["target"]) - # Only process video files - if target_path.suffix.lower() not in VIDEO_EXTENSIONS: - continue - - source_filename = Path(entry["source"]).name - params = get_search_params(entry) - - for lang in config.subtitles.languages: - overlay_path = ( - config.subtitle_root - / target_path.parent - / f"{target_path.stem}.{lang}.srt" - ) - state.set_current( - f"{target_path.stem} ({lang})" - ) - - # Detailed stdout logging of search parameters - search_desc = f"query='{params['query']}'" - if params.get("year"): - search_desc += f", year={params['year']}" - if params.get("season"): - season = params["season"] - episode = params.get("episode", 0) - search_desc += ( - f", S{season:02d}E{episode:02d}" - ) - strategy = config.subtitles.strategy - print( - f"[SUBS] Searching OpenSubtitles: {search_desc}, " - f"lang={lang}, strategy={strategy}", - flush=True, - ) - - try: - feature_type = ( - "movie" if entry["type"] == "movie" - else "episode" - ) - results = client.search( - query=params["query"], - year=params.get("year"), - languages=lang, - season=params.get("season"), - episode=params.get("episode"), - type=feature_type, - ) - - print( - f"[SUBS] Search returned {len(results)} " - f"results for: {search_desc}", - flush=True, - ) - - best = rank_subtitles( - results, - config.subtitles.strategy, - config.subtitles.filters, - source_filename, - query=params["query"], - year=params.get("year"), - ) - - # Fallback chain - if not best and strategy != "most-downloaded": - print( - f"[SUBS] No match with strategy " - f"'{strategy}', falling back to " - "most-downloaded", - flush=True, - ) - best = rank_subtitles( - results, - "most-downloaded", - config.subtitles.filters, - source_filename, - query=params["query"], - year=params.get("year"), - ) - - if not best and strategy != "best-match": - print( - "[SUBS] No match with fallback, " - "trying best-match", - flush=True, - ) - best = rank_subtitles( - results, - "best-match", - config.subtitles.filters, - source_filename, - query=params["query"], - year=params.get("year"), - ) - - if not best: - print( - f"[SUBS] No suitable subtitle found for: " - f"{search_desc} ({lang})", - flush=True, - ) - skipped_count += 1 - time.sleep(config.subtitles.search_delay_secs) - continue - - attr = best.get("attributes", {}) - file_id = attr.get("files", [{}])[0].get( - "file_id" - ) - release = attr.get("release", "unknown") - - if not file_id: - print( - f"[SUBS] WARNING: No file_id in result " - f"for '{release}'", - flush=True, - ) - record_event( - f"No file ID in subtitle result for: " - f"{params['query']} ({lang})", - level="warning", - ) - skipped_count += 1 - time.sleep( - config.subtitles.search_delay_secs - ) - continue - - # Check if we already have this exact subtitle - if overlay_path.exists(): - meta = _read_subtitle_meta(config, overlay_path) - if meta and meta.get("file_id") == file_id: - print( - f"[SUBS] Subtitle already " - f"up-to-date: '{release}' ({lang})", - flush=True, - ) - already_exists_count += 1 - time.sleep( - config.subtitles.search_delay_secs - ) - continue - - downloads = attr.get("download_count", 0) - ratings = attr.get("ratings", 0) - hi = attr.get("hearing_impaired", False) - print( - f"[SUBS] Selected: '{release}' " - f"(lang={lang}, downloads={downloads}, " - f"rating={ratings}, hearing_impaired={hi})", - flush=True, - ) - - is_replacement = overlay_path.exists() - action = ( - "Replacing" if is_replacement - else "Downloading" - ) - record_event( - f"{action} subtitle '{release}' ({lang}) " - f"for: {params['query']}" - ) - download_link = client.download(file_id) - content = client.fetch_content(download_link) - - overlay_path.parent.mkdir( - parents=True, exist_ok=True - ) - overlay_path.write_bytes(content) - _write_subtitle_meta( - config, - overlay_path, - {"file_id": file_id, "release": release}, - ) - - # Create symlink in curated dir immediately - curated_sub = ( - config.target_root - / target_path.parent - / f"{target_path.stem}.{lang}.srt" - ) - curated_sub.parent.mkdir( - parents=True, exist_ok=True - ) - if ( - curated_sub.exists() - or curated_sub.is_symlink() - ): - curated_sub.unlink() - os.symlink(overlay_path, curated_sub) - - if is_replacement: - replaced_count += 1 - else: - fetched_count += 1 - fetched_targets.append(entry["target"]) - time.sleep( - config.subtitles.download_delay_secs - ) - time.sleep( - config.subtitles.search_delay_secs - ) - - except Exception as e: - print( - f"[SUBS] ERROR: {params['query']} " - f"({lang}): {e}", - flush=True, - ) - record_event( - f"Subtitle error for {params['query']} " - f"({lang}): {e}", - level="error", - ) - state.error_count += 1 - error_count += 1 + _fetch_entry_subtitles(config, client, entry, counters, fetched_targets) state.stop() - - summary_parts = [] - if fetched_count > 0: - summary_parts.append(f"{fetched_count} downloaded") - if replaced_count > 0: - summary_parts.append(f"{replaced_count} replaced") - if skipped_count > 0: - summary_parts.append(f"{skipped_count} no match") - if error_count > 0: - summary_parts.append(f"{error_count} errors") - if already_exists_count > 0: - summary_parts.append( - f"{already_exists_count} already up-to-date" - ) - - if not summary_parts: - summary = "Subtitle fetch complete: nothing to do" - else: - summary = ( - "Subtitle fetch complete: " - f"{', '.join(summary_parts)}" - ) - + summary = _subtitle_summary(counters) print(f"[SUBS] {summary}", flush=True) record_event(summary) @@ -735,13 +713,8 @@ def fetch_subtitles_for_library( ): trigger_jellyfin_selective_refresh(config, fetched_targets) except Exception as e: - print( - f"[SUBS] FATAL: Subtitle fetcher failed: {e}", - flush=True, - ) - record_event( - f"Subtitle fetcher failed: {e}", level="error" - ) + print(f"[SUBS] FATAL: Subtitle fetcher failed: {e}", flush=True) + record_event(f"Subtitle fetcher failed: {e}", level="error") state.stop(error=True) diff --git a/buzz/dav_app.py b/buzz/dav_app.py index c70b442..1b91740 100644 --- a/buzz/dav_app.py +++ b/buzz/dav_app.py @@ -9,7 +9,7 @@ from datetime import datetime, timedelta, timezone from http import HTTPStatus from urllib import error -from typing import cast +from typing import Any, cast from fastapi import FastAPI, Request, Response from fastapi.exceptions import RequestValidationError @@ -39,6 +39,7 @@ DEFAULT_DAV_CONFIG_PATH, DeleteTorrentRequest, DeleteTrashRequest, + FIELD_ANIME_PATTERNS, HOT_RELOADABLE_FIELDS, RESTART_REQUIRED_FIELDS, ErrorResponse, @@ -57,8 +58,14 @@ ) from .ui_live import build_ui +PATH_REBUILD = "/rebuild" +MSG_NO_CURATOR = "No curator configured" +MSG_INVALID_REQUEST = "Invalid request" +TOPIC_LOGS = "buzz:logs" +TOPIC_CONFIG = "buzz:config" + SNAPSHOT_RELOAD_FIELDS = ( - "directories.anime.patterns", + FIELD_ANIME_PATTERNS, "compat.enable_all_dir", "compat.enable_unplayable_dir", "version_label", @@ -431,11 +438,11 @@ def fetch_subs_for_torrent(payload: dict): if not self.config.curator_url: return JSONResponse( status_code=400, - content={"error": "No curator configured"}, + content={"error": MSG_NO_CURATOR}, ) subs_url = self.config.curator_url.replace( - "/rebuild", "/api/subtitles/fetch" + PATH_REBUILD, "/api/subtitles/fetch" ) try: with httpx.Client(timeout=5.0) as client: @@ -496,169 +503,183 @@ def serve_dav(path: str, request: Request): return Response(status_code=404) if node["type"] == "dir": - return Response( - status_code=200, - headers={ - "Content-Type": "text/plain; charset=utf-8", - "Content-Length": "0", - }, - ) + return self._dav_dir_response() if node["type"] == "memory": - content = node["content"].encode("utf-8") - size = len(content) - range_header = read_range_header(request.headers.get("Range"), size) - - headers = { - "Accept-Ranges": "bytes", - "Content-Type": node["mime_type"], - "ETag": node["etag"], - "Last-Modified": http_date(node.get("modified")), - } - - if range_header: - start, end = range_header - payload = content[start : end + 1] - headers["Content-Range"] = f"bytes {start}-{end}/{size}" - status_code = 206 - else: - payload = content - status_code = 200 - - headers["Content-Length"] = str(len(payload)) - return Response( - content=payload if send_body else None, - status_code=status_code, - headers=headers, + return self._dav_memory_response( + node, send_body, request.headers.get("Range") ) - # Remote media - size = int(node["size"]) - range_header = read_range_header(request.headers.get("Range"), size) + return self._dav_remote_response( + node, send_body, request.headers.get("Range"), rel + ) - headers = { - "Accept-Ranges": "bytes", - "Content-Type": node["mime_type"], - "ETag": node["etag"], - "Last-Modified": http_date(node.get("modified")), - } + def _dav_dir_response(self) -> Response: + return Response( + status_code=200, + headers={ + "Content-Type": "text/plain; charset=utf-8", + "Content-Length": "0", + }, + ) + + def _dav_memory_response( + self, node: dict, send_body: bool, range_str: str | None + ) -> Response: + content = node["content"].encode("utf-8") + size = len(content) + range_header = read_range_header(range_str, size) + + headers = { + "Accept-Ranges": "bytes", + "Content-Type": node["mime_type"], + "ETag": node["etag"], + "Last-Modified": http_date(node.get("modified")), + } + + if range_header: + start, end = range_header + payload = content[start : end + 1] + headers["Content-Range"] = f"bytes {start}-{end}/{size}" + status_code = 206 + else: + payload = content + status_code = 200 + + headers["Content-Length"] = str(len(payload)) + return Response( + content=payload if send_body else None, + status_code=status_code, + headers=headers, + ) + + def _dav_remote_response( + self, node: dict, send_body: bool, range_str: str | None, rel: str + ) -> Response: + size = int(node["size"]) + range_header = read_range_header(range_str, size) + + headers = { + "Accept-Ranges": "bytes", + "Content-Type": node["mime_type"], + "ETag": node["etag"], + "Last-Modified": http_date(node.get("modified")), + } - if range_header: - start, end = range_header - headers["Content-Range"] = f"bytes {start}-{end}/{size}" - headers["Content-Length"] = str(end - start + 1) - status_code = 206 - else: - headers["Content-Length"] = str(size) - status_code = 200 + if range_header: + start, end = range_header + headers["Content-Range"] = f"bytes {start}-{end}/{size}" + headers["Content-Length"] = str(end - start + 1) + status_code = 206 + else: + headers["Content-Length"] = str(size) + status_code = 200 + + if not send_body: + return Response(status_code=status_code, headers=headers) + + try: + response, first_chunk = open_remote_media( + self.state, node, range_header + ) + return StreamingResponse( + self._stream_remote(response, first_chunk, self.config.stream_buffer_size), + status_code=status_code, + headers=headers, + ) + except error.HTTPError as exc: + return Response(status_code=exc.code, content=str(exc)) + except ValueError as exc: + record_event( + f"Real-Debrid stream failed: {exc}", + event="rd_stream_failed", + path=rel, + level="error", + ) + return Response(status_code=502, content=str(exc)) - if not send_body: - return Response(status_code=status_code, headers=headers) + def _stream_remote( + self, + response: Any, + first_chunk: bytes, + buffer_size: int, + ): + chunk_size = 64 * 1024 + if buffer_size < chunk_size: try: - response, first_chunk = open_remote_media( - self.state, node, range_header - ) + if first_chunk: + yield first_chunk + while True: + chunk = response.read(chunk_size) + if not chunk: + break + yield chunk + finally: + response.close() + return - def stream_generator(): - chunk_size = 64 * 1024 - buffer_size = self.config.stream_buffer_size + q = queue.Queue(maxsize=max(1, buffer_size // chunk_size)) + stop_event = threading.Event() - if buffer_size < chunk_size: - try: - if first_chunk: - yield first_chunk - while True: - chunk = response.read(chunk_size) - if not chunk: - break - yield chunk - finally: - response.close() - return - - # Buffered path: background thread reads ahead into a bounded queue. - q = queue.Queue(maxsize=max(1, buffer_size // chunk_size)) - stop_event = threading.Event() - - def buffer_reader(): + def buffer_reader(): + try: + while not stop_event.is_set(): + chunk = response.read(chunk_size) + if not chunk: + break + while not stop_event.is_set(): try: - while not stop_event.is_set(): - chunk = response.read(chunk_size) - if not chunk: - break - while not stop_event.is_set(): - try: - q.put(chunk, timeout=1) - break - except queue.Full: - continue - except Exception as exc: - print( - json.dumps( - { - "event": "buffer_reader_error", - "error": str(exc), - }, - sort_keys=True, - ), - flush=True, - ) - finally: - # Signal end-of-stream; use timeout to avoid hanging - # if the queue is full and the consumer is gone. - while not stop_event.is_set(): - try: - q.put(None, timeout=1) - break - except queue.Full: - continue - - t = threading.Thread(target=buffer_reader, daemon=True) - t.start() - - try: - if first_chunk: - yield first_chunk - - while True: - try: - item = q.get(timeout=1) - except queue.Empty: - if not t.is_alive(): - break - continue - if item is None: - break - yield item - finally: - stop_event.set() - t.join(timeout=5) - response.close() - - return StreamingResponse( - stream_generator(), status_code=status_code, headers=headers - ) - except error.HTTPError as exc: - return Response(status_code=exc.code, content=str(exc)) - except ValueError as exc: - record_event( - f"Real-Debrid stream failed: {exc}", - event="rd_stream_failed", - path=rel, - level="error", + q.put(chunk, timeout=1) + break + except queue.Full: + continue + except Exception as exc: + print( + json.dumps( + {"event": "buffer_reader_error", "error": str(exc)}, + sort_keys=True, + ), + flush=True, ) - return Response(status_code=502, content=str(exc)) + finally: + while not stop_event.is_set(): + try: + q.put(None, timeout=1) + break + except queue.Full: + continue + + t = threading.Thread(target=buffer_reader, daemon=True) + t.start() + + try: + if first_chunk: + yield first_chunk + while True: + try: + item = q.get(timeout=1) + except queue.Empty: + if not t.is_alive(): + break + continue + if item is None: + break + yield item + finally: + stop_event.set() + t.join(timeout=5) + response.close() def fetch_subtitles(self, torrent_name: str) -> dict: """Request subtitle fetch for a torrent from the curator.""" import httpx if not self.config.curator_url: - return {"error": "No curator configured"} + return {"error": MSG_NO_CURATOR} subs_url = self.config.curator_url.replace( - "/rebuild", "/api/subtitles/fetch" + PATH_REBUILD, "/api/subtitles/fetch" ) try: with httpx.Client(timeout=self.config.request_timeout_secs) as client: @@ -695,7 +716,7 @@ def restart_required_fields(self) -> list[str]: def _curator_reload_url(self) -> str: if not self.config.curator_url: return "" - return self.config.curator_url.replace("/rebuild", "/api/config/reload") + return self.config.curator_url.replace(PATH_REBUILD, "/api/config/reload") def _notify_curator_config_reload(self) -> None: import httpx @@ -767,17 +788,17 @@ def persist_overrides(self, overrides: dict) -> dict: "hot_reloaded_fields": hot_fields, } - async def _handle_validation_error( + def _handle_validation_error( self, request: Request, exc: Exception ) -> JSONResponse: - first_error = {"msg": "Invalid request"} + first_error = {"msg": MSG_INVALID_REQUEST} if isinstance(exc, RequestValidationError): validation_error = cast(RequestValidationError, exc) errors = validation_error.errors() - first_error = errors[0] if errors else {"msg": "Invalid request"} + first_error = errors[0] if errors else {"msg": MSG_INVALID_REQUEST} return JSONResponse( status_code=400, - content={"error": str(first_error.get("msg", "Invalid request"))}, + content={"error": str(first_error.get("msg", MSG_INVALID_REQUEST))}, ) def get_logs(self, limit: int = 100) -> list[dict]: @@ -821,7 +842,7 @@ def log_count(self) -> int: return len(registry.events) def _handle_recorded_event(self, event: dict) -> None: - self._notify_ui_topic("buzz:logs", event) + self._notify_ui_topic(TOPIC_LOGS, event) self._notify_ui_topic("buzz:status", event) def _notify_ui_change( @@ -835,9 +856,9 @@ def _notify_ui_change( self._notify_ui_topic("buzz:archive", message) elif topic == "sync": self._notify_ui_topic("buzz:archive", message) - self._notify_ui_topic("buzz:logs", message) + self._notify_ui_topic(TOPIC_LOGS, message) elif topic == "config": - self._notify_ui_topic("buzz:config", message) + self._notify_ui_topic(TOPIC_CONFIG, message) def _notify_ui_topic(self, topic: str, message: dict) -> None: if self.ui_loop is None: diff --git a/buzz/dav_protocol.py b/buzz/dav_protocol.py index 4703889..527473f 100644 --- a/buzz/dav_protocol.py +++ b/buzz/dav_protocol.py @@ -23,22 +23,18 @@ def propfind_body(state: BuzzState, paths: list[str]) -> str: if rel: href_path += "/" + parse.quote(rel) if node["type"] == "dir": - prop = ( - "" - "0" - ) + prop = "" \ + "0" else: size = str(int(node.get("size", 0))) mime = escape(node.get("mime_type", "application/octet-stream")) etag = escape(node.get("etag", "")) modified = escape(http_date(node.get("modified"))) - prop = ( - "" - f"{size}" - f"{mime}" - f"{etag}" - f"{modified}" - ) + prop = "" \ + f"{size}" \ + f"{mime}" \ + f"{etag}" \ + f"{modified}" responses.append( "" f"{escape(href_path)}" @@ -48,12 +44,81 @@ def propfind_body(state: BuzzState, paths: list[str]) -> str: "" "" ) - return ( - '' - '' - + "".join(responses) - + "" - ) + return '' \ + '' \ + + "".join(responses) \ + + "" + + +def _try_resolve_download_url( + state: BuzzState, + source_url: str, + attempt: int, + max_attempts: int, +) -> str: + try: + return state.resolve_download_url(source_url, force_refresh=attempt > 0) + except Exception as exc: + state.verbose_log(f"Failed to resolve download URL: {exc}") + if attempt < max_attempts - 1: + record_event( + f"Retrying Real-Debrid stream resolution after failure: {exc}", + level="warning", + event="rd_stream_retry", + path=source_url, + attempt=attempt + 1, + ) + time.sleep(0.5 * (attempt + 1)) + raise + + +def _try_open_stream( + state: BuzzState, + download_url: str, + source_url: str, + range_header: tuple[int, int] | None, + attempt: int, + max_attempts: int, +) -> Any: + req = request.Request(download_url, method="GET") + if range_header: + start, end = range_header + req.add_header("Range", f"bytes={start}-{end}") + try: + return request.urlopen( + req, + timeout=max(1, int(state.config.request_timeout_secs)), + ) + except error.HTTPError as exc: + state.invalidate_download_url(source_url) + state.verbose_log( + f"HTTP Error {exc.code} on attempt {attempt + 1}: {exc.reason}" + ) + if attempt < max_attempts - 1: + record_event( + f"Retrying Real-Debrid stream after upstream HTTP {exc.code}", + level="warning", + event="rd_stream_retry", + path=source_url, + attempt=attempt + 1, + ) + time.sleep(0.5 * (attempt + 1)) + raise ValueError( + f"upstream returned HTTP {exc.code} for {download_url}" + ) from exc + except Exception as exc: + state.invalidate_download_url(source_url) + state.verbose_log(f"Connection error on attempt {attempt + 1}: {exc}") + if attempt < max_attempts - 1: + record_event( + f"Retrying Real-Debrid stream after connection error: {exc}", + level="warning", + event="rd_stream_retry", + path=source_url, + attempt=attempt + 1, + ) + time.sleep(0.5 * (attempt + 1)) + raise ValueError(f"failed to connect to upstream: {exc}") from exc def open_remote_media( @@ -61,14 +126,8 @@ def open_remote_media( node: dict[str, Any], range_header: tuple[int, int] | None, ) -> tuple[Any, bytes]: - """Resolve and open a remote media stream with retry logic. - - Attempts to resolve the download URL, then validates the response - headers and payload before returning the stream and first chunk. - """ - source_url = str( - node.get("source_url") or node.get("url") or "" - ).strip() + """Resolve and open a remote media stream with retry logic.""" + source_url = str(node.get("source_url") or node.get("url") or "").strip() if not source_url: raise ValueError("missing Real-Debrid source URL") last_error = "unable to resolve upstream media" @@ -77,90 +136,39 @@ def open_remote_media( max_attempts = 3 for attempt in range(max_attempts): try: - download_url = state.resolve_download_url( - source_url, force_refresh=attempt > 0 + download_url = _try_resolve_download_url( + state, source_url, attempt, max_attempts ) except Exception as exc: last_error = str(exc) last_exception = exc - state.verbose_log(f"Failed to resolve download URL: {exc}") - if attempt < max_attempts - 1: - record_event( - f"Retrying Real-Debrid stream resolution after failure: {exc}", - level="warning", - event="rd_stream_retry", - path=source_url, - attempt=attempt + 1, - ) - time.sleep(0.5 * (attempt + 1)) - continue - raise + if attempt == max_attempts - 1: + raise + continue state.verbose_log( f"Resolved to {download_url!r} (attempt {attempt + 1}/{max_attempts})" ) - req = request.Request(download_url, method="GET") - if range_header: - start, end = range_header - req.add_header("Range", f"bytes={start}-{end}") try: - response = request.urlopen( - req, - timeout=max(1, int(state.config.request_timeout_secs)), + response = _try_open_stream( + state, download_url, source_url, range_header, attempt, max_attempts ) - except error.HTTPError as exc: - state.invalidate_download_url(source_url) - last_error = ( - f"upstream returned HTTP {exc.code} for {download_url}" - ) - last_exception = exc - state.verbose_log( - f"HTTP Error {exc.code} on attempt {attempt + 1}: " - f"{exc.reason}" - ) - if attempt < max_attempts - 1: - record_event( - f"Retrying Real-Debrid stream after upstream HTTP {exc.code}", - level="warning", - event="rd_stream_retry", - path=source_url, - attempt=attempt + 1, - ) - time.sleep(0.5 * (attempt + 1)) - continue - raise ValueError(last_error) from exc - except Exception as exc: - state.invalidate_download_url(source_url) - last_error = f"failed to connect to upstream: {exc}" + except ValueError as exc: + last_error = str(exc) last_exception = exc - state.verbose_log( - f"Connection error on attempt {attempt + 1}: {exc}" - ) - if attempt < max_attempts - 1: - record_event( - f"Retrying Real-Debrid stream after connection error: {exc}", - level="warning", - event="rd_stream_retry", - path=source_url, - attempt=attempt + 1, - ) - time.sleep(0.5 * (attempt + 1)) - continue - raise ValueError(last_error) from exc + if attempt == max_attempts - 1: + raise + continue try: - first_chunk = validate_remote_media_response( - response, range_header - ) + first_chunk = validate_remote_media_response(response, range_header) return response, first_chunk except ValueError as exc: response.close() state.invalidate_download_url(source_url) last_error = str(exc) last_exception = exc - state.verbose_log( - f"Validation failed on attempt {attempt + 1}: {exc}" - ) + state.verbose_log(f"Validation failed on attempt {attempt + 1}: {exc}") if attempt < max_attempts - 1: record_event( f"Retrying Real-Debrid stream after validation error: {exc}", diff --git a/buzz/models.py b/buzz/models.py index 2c91d5f..09c57d5 100644 --- a/buzz/models.py +++ b/buzz/models.py @@ -12,6 +12,10 @@ DEFAULT_DAV_CONFIG_PATH = os.environ.get("BUZZ_CONFIG", "/app/buzz.yml") DEFAULT_DIST_CONFIG_NAME = "buzz.dist.yml" +DEFAULT_STATE_DIR = "/app/data" +DEFAULT_APP_VERSION = "buzz/0.1" +FIELD_ANIME_PATTERNS = "directories.anime.patterns" +FIELD_SUBTITLES_LANGUAGES = "subtitles.languages" RESTART_REQUIRED_FIELDS = ( "server.bind", "server.port", @@ -24,7 +28,7 @@ "hooks.on_library_change", "hooks.curator_url", "hooks.rd_update_delay_secs", - "directories.anime.patterns", + FIELD_ANIME_PATTERNS, "compat.enable_all_dir", "compat.enable_unplayable_dir", "request_timeout_secs", @@ -32,7 +36,7 @@ "logging.verbose", "subtitles.enabled", "subtitles.fetch_on_resync", - "subtitles.languages", + FIELD_SUBTITLES_LANGUAGES, "subtitles.strategy", "subtitles.filters.hearing_impaired", "subtitles.filters.exclude_ai", @@ -195,6 +199,28 @@ def mask_secrets(d: dict) -> dict: return result +def _strip_provider_token(d: dict) -> None: + provider = d.get("provider") + if isinstance(provider, dict): + provider.pop("token", None) + if not provider: + del d["provider"] + + +def _strip_opensubtitles_secrets(d: dict) -> None: + subtitles = d.get("subtitles") + if not isinstance(subtitles, dict): + return + opensubs = subtitles.get("opensubtitles") + if isinstance(opensubs, dict): + for k in ("api_key", "username", "password"): + opensubs.pop(k, None) + if not opensubs: + subtitles.pop("opensubtitles", None) + if not subtitles: + del d["subtitles"] + + def _strip_secrets(d: dict) -> dict: result = {} for key, value in d.items(): @@ -204,19 +230,8 @@ def _strip_secrets(d: dict) -> dict: result[key] = nested else: result[key] = value - if "provider" in result and isinstance(result["provider"], dict): - result["provider"].pop("token", None) - if not result["provider"]: - del result["provider"] - if "subtitles" in result and isinstance(result["subtitles"], dict): - opensubs = result["subtitles"].get("opensubtitles") - if isinstance(opensubs, dict): - for k in ("api_key", "username", "password"): - opensubs.pop(k, None) - if not opensubs: - result["subtitles"].pop("opensubtitles", None) - if not result["subtitles"]: - del result["subtitles"] + _strip_provider_token(result) + _strip_opensubtitles_secrets(result) return result @@ -303,7 +318,7 @@ def load_base_and_overrides( file_base = yaml.safe_load(handle) or {} base = deep_merge(default_dist, file_base) - state_dir = str(file_base.get("state_dir", base.get("state_dir", "/app/data"))) + state_dir = str(file_base.get("state_dir", base.get("state_dir", DEFAULT_STATE_DIR))) overrides_env = os.environ.get("BUZZ_OVERRIDES", "") overrides_path = ( Path(overrides_env) @@ -458,14 +473,14 @@ class DavConfig(BaseModel): bind: str = "0.0.0.0" port: int = 9999 stream_buffer_size: int = 0 - state_dir: str = "/app/data" + state_dir: str = DEFAULT_STATE_DIR hook_command: str = "" anime_patterns: tuple[str, ...] = (DEFAULT_ANIME_PATTERN,) enable_all_dir: bool = True enable_unplayable_dir: bool = True request_timeout_secs: int = 30 - user_agent: str = "buzz/0.1" - version_label: str = "buzz/0.1" + user_agent: str = DEFAULT_APP_VERSION + version_label: str = DEFAULT_APP_VERSION curator_url: str = "http://buzz-curator:8400/rebuild" rd_update_delay_secs: int = 15 vfs_wait_timeout_secs: int = 300 @@ -505,7 +520,7 @@ def _from_merged_dict(cls, raw: dict) -> DavConfig: bind=str(server.get("bind", "0.0.0.0")), port=int(server.get("port", 9999)), stream_buffer_size=int(server.get("stream_buffer_size", 0)), - state_dir=str(raw.get("state_dir", "/app/data")), + state_dir=str(raw.get("state_dir", DEFAULT_STATE_DIR)), hook_command=str(hooks.get("on_library_change", "")).strip(), curator_url=str( hooks.get("curator_url", "http://buzz-curator:8400/rebuild") @@ -523,8 +538,8 @@ def _from_merged_dict(cls, raw: dict) -> DavConfig: compat.get("enable_unplayable_dir", True) ), request_timeout_secs=int(raw.get("request_timeout_secs", 30)), - user_agent=str(raw.get("user_agent", "buzz/0.1")), - version_label=str(raw.get("version_label", "buzz/0.1")), + user_agent=str(raw.get("user_agent", DEFAULT_APP_VERSION)), + version_label=str(raw.get("version_label", DEFAULT_APP_VERSION)), verbose=bool(logging_raw.get("verbose", False)), log_max_entries=int(logging_raw.get("max_entries", 1000)), ui_poll_interval_secs=int( @@ -586,7 +601,7 @@ class CuratorConfig(BaseModel): "CURATOR_STATE_DIR", os.environ.get( "PRESENTATION_STATE_DIR", - os.environ.get("PRESENTATION_STATE_ROOT", "/app/data"), + os.environ.get("PRESENTATION_STATE_ROOT", DEFAULT_STATE_DIR), ), ) ) diff --git a/buzz/pyview_templates/archive_live.html b/buzz/pyview_templates/archive_live.html index bf4a4aa..33cff50 100644 --- a/buzz/pyview_templates/archive_live.html +++ b/buzz/pyview_templates/archive_live.html @@ -39,7 +39,7 @@
[ERROR] {{ last_error }}
{% endif %} -
+
@@ -55,10 +55,9 @@ {% for torrent in archive_items %} diff --git a/buzz/pyview_templates/cache_live.html b/buzz/pyview_templates/cache_live.html index 8157242..d3c75af 100644 --- a/buzz/pyview_templates/cache_live.html +++ b/buzz/pyview_templates/cache_live.html @@ -142,7 +142,7 @@ {% endif %} -
+
-
{{ torrent.name }}
-
- {{ torrent.name }} -
+ + {{ torrent.name }} +
{{ torrent.size }} {{ torrent.file_count }} -
{{ torrent.name }}
-
- {{ torrent.name }} -
+ + {{ torrent.name }} +
[{{ torrent.status }}] diff --git a/buzz/static/buzz.css b/buzz/static/buzz.css index a3e6c53..d907fe1 100644 --- a/buzz/static/buzz.css +++ b/buzz/static/buzz.css @@ -241,10 +241,6 @@ button.secondary { min-height: 0; } -.table-container table { - border-collapse: collapse; -} - .table-container td:first-of-type { min-width: 200px; } @@ -317,61 +313,45 @@ td:last-of-type { position: relative; max-width: 0; overflow: hidden; -} - -/* normal state: real ellipsis */ -.trunc-idle { - display: block; - width: 100%; - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -/* hover state: hidden by default */ -.trunc-scroll-wrap { - display: none; - width: 100%; +.name { + color: var(--fg); +} + +.marquee-clip { + display: block; overflow: hidden; white-space: nowrap; + text-overflow: ellipsis; + width: 100%; } -.trunc-scroll { +.marquee-label { display: inline-block; white-space: nowrap; + will-change: transform; } -/* swap on hover */ -/* swap on hover — only when text is actually clipped */ -.trunc-cell.is-truncated:hover .trunc-idle { - display: none; +tr:hover .marquee-clip[data-overflowing] .marquee-label, +tr:focus-within .marquee-clip[data-overflowing] .marquee-label { + animation: buzz-marquee var(--marquee-duration, 6s) linear infinite; } -.trunc-cell.is-truncated:hover .trunc-scroll-wrap { - display: block; +@keyframes buzz-marquee { + 0%, 15% { transform: translateX(0); } + 85%, 100% { transform: translateX(calc(var(--marquee-distance, 0px) * -1)); } } -.trunc-cell.is-truncated:hover .trunc-scroll { - animation: scroll-text 4s linear infinite; -} - -@keyframes scroll-text { - - 0%, - 10% { - transform: translateX(0); - } - - 90%, - 100% { - transform: translateX(calc(-100% + 200px)); +@media (prefers-reduced-motion: reduce) { + tr:hover .marquee-clip[data-overflowing] .marquee-label, + tr:focus-within .marquee-clip[data-overflowing] .marquee-label { + animation: none; } } -.name { - color: var(--fg); -} - .comment { color: var(--comment); } @@ -605,11 +585,6 @@ code { border-radius: 4px; } -.logs-header-btns { - display: flex; - gap: 10px; -} - .bulk-magnet-remove { padding: 0 10px; font-size: 1rem; @@ -670,13 +645,6 @@ input[type="text"]:focus { border-color: var(--purple); } -#file-selection-area { - margin-top: 15px; - border-top: 1px solid var(--comment); - padding-top: 15px; - display: none; -} - .file-list { max-height: 300px; overflow-y: auto; diff --git a/buzz/static/buzz.js b/buzz/static/buzz.js index 2067f79..da66c68 100644 --- a/buzz/static/buzz.js +++ b/buzz/static/buzz.js @@ -1,36 +1,5 @@ -const buzzTableId = "torrent-table"; -let _truncObserver = null; let _buzzSocketStatusMonitor = null; -function markTruncatedCells() { - document.querySelectorAll(".trunc-cell").forEach((cell) => { - const idle = cell.querySelector(".trunc-idle"); - if (!idle) return; - if (idle.scrollWidth > idle.clientWidth) { - cell.classList.add("is-truncated"); - } else { - cell.classList.remove("is-truncated"); - } - }); -} - -function initTruncCells() { - markTruncatedCells(); - const table = document.getElementById(buzzTableId); - if (!table || typeof ResizeObserver === "undefined") return; - if (_truncObserver) { - _truncObserver.disconnect(); - } - _truncObserver = new ResizeObserver(markTruncatedCells); - _truncObserver.observe(table); -} - -function initTableIfPresent() { - if (document.getElementById(buzzTableId)) { - initTruncCells(); - } -} - function setBuzzStatus(label, className) { const element = document.getElementById("status-ready-label"); if (!element) return; @@ -88,7 +57,5 @@ function initBuzzSocketStatusMonitor() { _buzzSocketStatusMonitor.start(); } -document.addEventListener("DOMContentLoaded", initTableIfPresent); document.addEventListener("DOMContentLoaded", initBuzzSocketStatusMonitor); -window.addEventListener("phx:navigate", initTableIfPresent); window.addEventListener("phx:navigate", initBuzzSocketStatusMonitor); diff --git a/buzz/static/pyview_helpers.js b/buzz/static/pyview_helpers.js index c67e231..abe93c6 100644 --- a/buzz/static/pyview_helpers.js +++ b/buzz/static/pyview_helpers.js @@ -146,5 +146,42 @@ if (typeof window !== "undefined") { }, }; + hooks.BuzzOverflowMarquee = { + mounted() { + this._reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + this._onReducedMotionChange = () => this._measureAll(); + this._reducedMotion.addEventListener("change", this._onReducedMotionChange); + this._resizeObserver = new ResizeObserver(() => this._measureAll()); + this._resizeObserver.observe(this.el); + this._measureAll(); + }, + updated() { + this._measureAll(); + }, + destroyed() { + this._resizeObserver?.disconnect(); + this._reducedMotion?.removeEventListener("change", this._onReducedMotionChange); + }, + _measureAll() { + const clips = this.el.querySelectorAll("[data-marquee-clip]"); + const reduced = this._reducedMotion.matches; + clips.forEach((clip) => { + const label = clip.querySelector("[data-marquee-label]"); + if (!label) return; + const overflow = label.scrollWidth - clip.clientWidth; + if (!reduced && overflow > 0) { + clip.dataset.overflowing = "true"; + clip.style.setProperty("--marquee-distance", `${overflow}px`); + const duration = Math.min(12, Math.max(3, overflow / 60)); + clip.style.setProperty("--marquee-duration", `${duration}s`); + } else { + delete clip.dataset.overflowing; + clip.style.removeProperty("--marquee-distance"); + clip.style.removeProperty("--marquee-duration"); + } + }); + }, + }; + window.Hooks = hooks; } diff --git a/buzz/ui_live.py b/buzz/ui_live.py index 26b9e7f..09ea384 100644 --- a/buzz/ui_live.py +++ b/buzz/ui_live.py @@ -18,7 +18,18 @@ from pyview.template import LiveRender, RenderedContent, template_file from .core.utils import format_bytes + +CSS_STATUS_GREEN = "service-status-green" +CSS_STATUS_RED = "service-status-red" +CSS_STATUS_YELLOW = "service-status-yellow" +TOPIC_STATUS = "buzz:status" +TOPIC_ARCHIVE = "buzz:archive" +TOPIC_LOGS = "buzz:logs" +TOPIC_CONFIG = "buzz:config" +EVENT_NAVIGATE = "navigate" from .models import ( + FIELD_ANIME_PATTERNS, + FIELD_SUBTITLES_LANGUAGES, HOT_RELOADABLE_FIELDS, RESTART_REQUIRED_FIELDS, UI_MANAGED_CONFIG_FIELDS, @@ -60,13 +71,13 @@ "hooks.rd_update_delay_secs", "compat.enable_all_dir", "compat.enable_unplayable_dir", - "directories.anime.patterns", + FIELD_ANIME_PATTERNS, "request_timeout_secs", "logging.verbose", "version_label", "subtitles.enabled", "subtitles.fetch_on_resync", - "subtitles.languages", + FIELD_SUBTITLES_LANGUAGES, "subtitles.strategy", "subtitles.filters.hearing_impaired", "subtitles.filters.exclude_ai", @@ -374,10 +385,10 @@ def _base_context( status = self.owner.state.status() restart_required = bool(getattr(self.owner, "restart_required", False)) status_label = "[ready]" - status_class = "service-status-green" + status_class = CSS_STATUS_GREEN if restart_required: status_label = "[restart required]" - status_class = "service-status-yellow" + status_class = CSS_STATUS_YELLOW elif not self.owner.is_ready(): status_label = "[starting]" status_class = "service-status-orange" @@ -401,9 +412,9 @@ async def mount( # pyright: ignore[reportIncompatibleMethodOverride] ) -> None: socket.live_title = self.page_title if is_connected(socket): - await socket.subscribe("buzz:status") + await socket.subscribe(TOPIC_STATUS) - @info("buzz:status") + @info(TOPIC_STATUS) async def handle_status(self, _event: InfoEvent, _socket: LiveViewSocket[PageContext]) -> None: """Re-render nav when curator sends a status update.""" pass @@ -421,8 +432,8 @@ async def mount( # pyright: ignore[reportIncompatibleMethodOverride, reportArgu await super().mount(socket, session) # pyright: ignore[reportArgumentType] socket.context = self._context() if is_connected(socket): - await socket.subscribe("buzz:status") - await socket.subscribe("buzz:archive") + await socket.subscribe(TOPIC_STATUS) + await socket.subscribe(TOPIC_ARCHIVE) async def handle_event( self, @@ -438,7 +449,7 @@ async def handle_event( mode: str = "", col: str = "", ) -> None: - if event == "navigate": + if event == EVENT_NAVIGATE: await socket.push_navigate(to) return if event == "prompt_delete": @@ -448,47 +459,13 @@ async def handle_event( socket.context["confirm_delete_id"] = None return if event == "delete": - try: - self.owner.state.delete_torrent(hash) - socket.context = self._context( - console_msg="item moved to archive", - console_class="service-status-green", - confirm_delete_id=None, - magnet_inputs=socket.context["magnet_inputs"], - analysis_results=socket.context["analysis_results"], - analysis_error=socket.context["analysis_error"], - analyzing=socket.context["analyzing"], - caching=socket.context["caching"], - sort_col=socket.context["sort_col"], - sort_dir=socket.context["sort_dir"], - ) - except Exception as exc: - socket.context["console_msg"] = f"delete failed: {exc}" - socket.context["console_class"] = "service-status-red" + self._handle_delete(socket, hash) return if event == "fetch_subs": - result = self.owner.fetch_subtitles(torrent_name) - if result.get("error"): - socket.context["console_msg"] = ( - f"subs fetch failed: {result['error']}" - ) - socket.context["console_class"] = "service-status-red" - else: - socket.context["console_msg"] = ( - f"subs fetch triggered for: {torrent_name}" - ) - socket.context["console_class"] = "service-status-green" + self._handle_fetch_subs(socket, torrent_name) return if event == "resync": - socket.context["console_msg"] = "resyncing library..." - socket.context["console_class"] = "service-status-orange" - try: - self.owner.state.manual_rebuild() - socket.context["console_msg"] = "library resynced!" - socket.context["console_class"] = "service-status-green" - except Exception as exc: - socket.context["console_msg"] = f"resync failed: {exc}" - socket.context["console_class"] = "service-status-red" + self._handle_resync(socket) return if event == "add_magnet_input": socket.context["magnet_inputs"].append("") @@ -508,62 +485,7 @@ async def handle_event( socket.context["magnet_inputs"] = [str(v) for v in raw] return if event == "analyze": - raw = (payload or {}).get("magnet", []) - if isinstance(raw, str): - raw = [raw] - magnets = [str(m).strip() for m in raw if str(m).strip()] - if not magnets: - return - socket.context["analyzing"] = True - socket.context["analysis_error"] = "" - results: list[CacheAnalysisResult] = [] - errors: list[str] = [] - import re - - for magnet in magnets: - try: - info = self.owner.state.add_magnet(magnet) - files: list[CacheFileItem] = [] - for f in info.get("files", []): - path = str(f.get("path", "")) - is_video = bool( - re.search(r"\.(mkv|mp4|avi|m4v|mov)$", path, re.I) - ) - b = int(f.get("bytes", 0)) - files.append( - { - "id": str(f.get("id", "")), - "path": path, - "bytes": b, - "size": format_bytes(b), - "is_video": is_video, - "selected": is_video, - } - ) - results.append( - { - "torrent_id": str(info["id"]), - "filename": str(info.get("filename") or "Torrent Files"), - "files": files, - } - ) - except Exception as exc: - errors.append(str(exc)) - socket.context["analyzing"] = False - socket.context["analysis_results"] = results - if errors: - socket.context["analysis_error"] = f"Failed: {', '.join(errors)}" - socket.context["console_msg"] = ( - f"Resolved {len(results)} magnets, {len(errors)} failed." - ) - socket.context["console_class"] = "ready-label-orange" - else: - socket.context["console_msg"] = ( - f"Resolved {len(results)} magnet(s)." - if len(results) != 1 - else "Ready to cache." - ) - socket.context["console_class"] = "ready-label-green" + self._handle_analyze(socket, payload) return if event == "select_files": for result in socket.context["analysis_results"]: @@ -584,28 +506,7 @@ async def handle_event( break return if event == "confirm_cache": - socket.context["caching"] = True - try: - for result in socket.context["analysis_results"]: - selected = [ - f["id"] for f in result["files"] if f["selected"] - ] - if selected: - self.owner.state.select_files( - result["torrent_id"], selected - ) - self.owner.state.sync() - socket.context = self._context( - console_msg="Items added and synced.", - console_class="service-status-green", - confirm_delete_id=socket.context["confirm_delete_id"], - sort_col=socket.context["sort_col"], - sort_dir=socket.context["sort_dir"], - ) - except Exception as exc: - socket.context["caching"] = False - socket.context["console_msg"] = f"Error: {exc}" - socket.context["console_class"] = "service-status-red" + self._handle_confirm_cache(socket) return if event == "cancel_cache": socket.context = self._context( @@ -618,25 +519,158 @@ async def handle_event( ) return if event == "sort": + self._handle_sort(socket, col) + + def _handle_delete( + self, socket: ConnectedLiveViewSocket[CacheContext], hash: str + ) -> None: + try: + self.owner.state.delete_torrent(hash) + socket.context = self._context( + console_msg="item moved to archive", + console_class=CSS_STATUS_GREEN, + confirm_delete_id=None, + magnet_inputs=socket.context["magnet_inputs"], + analysis_results=socket.context["analysis_results"], + analysis_error=socket.context["analysis_error"], + analyzing=socket.context["analyzing"], + caching=socket.context["caching"], + sort_col=socket.context["sort_col"], + sort_dir=socket.context["sort_dir"], + ) + except Exception as exc: + socket.context["console_msg"] = f"delete failed: {exc}" + socket.context["console_class"] = CSS_STATUS_RED + + def _handle_fetch_subs( + self, socket: ConnectedLiveViewSocket[CacheContext], torrent_name: str + ) -> None: + result = self.owner.fetch_subtitles(torrent_name) + if result.get("error"): + socket.context["console_msg"] = f"subs fetch failed: {result['error']}" + socket.context["console_class"] = CSS_STATUS_RED + else: + socket.context["console_msg"] = f"subs fetch triggered for: {torrent_name}" + socket.context["console_class"] = CSS_STATUS_GREEN + + def _handle_resync( + self, socket: ConnectedLiveViewSocket[CacheContext] + ) -> None: + socket.context["console_msg"] = "resyncing library..." + socket.context["console_class"] = "service-status-orange" + try: + self.owner.state.manual_rebuild() + socket.context["console_msg"] = "library resynced!" + socket.context["console_class"] = CSS_STATUS_GREEN + except Exception as exc: + socket.context["console_msg"] = f"resync failed: {exc}" + socket.context["console_class"] = CSS_STATUS_RED + + def _handle_analyze( + self, + socket: ConnectedLiveViewSocket[CacheContext], + payload: dict[str, Any] | None, + ) -> None: + raw = (payload or {}).get("magnet", []) + if isinstance(raw, str): + raw = [raw] + magnets = [str(m).strip() for m in raw if str(m).strip()] + if not magnets: + return + socket.context["analyzing"] = True + socket.context["analysis_error"] = "" + results: list[CacheAnalysisResult] = [] + errors: list[str] = [] + import re + + for magnet in magnets: try: - new_col = int(col) - except ValueError: - return - if socket.context["sort_col"] == new_col: - socket.context["sort_dir"] = ( - "desc" if socket.context["sort_dir"] == "asc" else "asc" + info = self.owner.state.add_magnet(magnet) + files: list[CacheFileItem] = [] + for f in info.get("files", []): + path = str(f.get("path", "")) + is_video = bool( + re.search(r"\.(mkv|mp4|avi|m4v|mov)$", path, re.I) + ) + b = int(f.get("bytes", 0)) + files.append( + { + "id": str(f.get("id", "")), + "path": path, + "bytes": b, + "size": format_bytes(b), + "is_video": is_video, + "selected": is_video, + } + ) + results.append( + { + "torrent_id": str(info["id"]), + "filename": str(info.get("filename") or "Torrent Files"), + "files": files, + } ) - else: - socket.context["sort_col"] = new_col - socket.context["sort_dir"] = "asc" + except Exception as exc: + errors.append(str(exc)) + socket.context["analyzing"] = False + socket.context["analysis_results"] = results + if errors: + socket.context["analysis_error"] = f"Failed: {', '.join(errors)}" + socket.context["console_msg"] = ( + f"Resolved {len(results)} magnets, {len(errors)} failed." + ) + socket.context["console_class"] = "service-status-orange" + else: + socket.context["console_msg"] = ( + f"Resolved {len(results)} magnet(s)." + if len(results) != 1 + else "Ready to cache." + ) + socket.context["console_class"] = "service-status-green" + + def _handle_confirm_cache( + self, socket: ConnectedLiveViewSocket[CacheContext] + ) -> None: + socket.context["caching"] = True + try: + for result in socket.context["analysis_results"]: + selected = [f["id"] for f in result["files"] if f["selected"]] + if selected: + self.owner.state.select_files(result["torrent_id"], selected) + self.owner.state.sync() + socket.context = self._context( + console_msg="Items added and synced.", + console_class=CSS_STATUS_GREEN, + confirm_delete_id=socket.context["confirm_delete_id"], + sort_col=socket.context["sort_col"], + sort_dir=socket.context["sort_dir"], + ) + except Exception as exc: + socket.context["caching"] = False + socket.context["console_msg"] = f"Error: {exc}" + socket.context["console_class"] = CSS_STATUS_RED + + def _handle_sort( + self, socket: ConnectedLiveViewSocket[CacheContext], col: str + ) -> None: + try: + new_col = int(col) + except ValueError: return + if socket.context["sort_col"] == new_col: + socket.context["sort_dir"] = ( + "desc" if socket.context["sort_dir"] == "asc" else "asc" + ) + else: + socket.context["sort_col"] = new_col + socket.context["sort_dir"] = "asc" async def handle_info( self, event: InfoEvent, socket: ConnectedLiveViewSocket[CacheContext], ) -> None: - if event.name not in {"buzz:status", "buzz:archive"}: + if event.name not in {TOPIC_STATUS, TOPIC_ARCHIVE}: return socket.context = self._context( console_msg=socket.context["console_msg"], @@ -723,8 +757,8 @@ async def mount( # pyright: ignore[reportIncompatibleMethodOverride, reportArgu await super().mount(socket, session) # pyright: ignore[reportArgumentType] socket.context = self._context() if is_connected(socket): - await socket.subscribe("buzz:archive") - await socket.subscribe("buzz:status") + await socket.subscribe(TOPIC_ARCHIVE) + await socket.subscribe(TOPIC_STATUS) async def handle_event( self, @@ -733,7 +767,7 @@ async def handle_event( to: str = "", hash: str = "", ) -> None: - if event == "navigate": + if event == EVENT_NAVIGATE: await socket.push_navigate(to) return if event == "prompt_restore": @@ -770,7 +804,7 @@ async def handle_info( event: InfoEvent, socket: ConnectedLiveViewSocket[ArchiveContext], ) -> None: - if event.name not in {"buzz:archive", "buzz:status"}: + if event.name not in {TOPIC_ARCHIVE, TOPIC_STATUS}: return socket.context = self._context( console_msg=socket.context["console_msg"], @@ -832,9 +866,9 @@ async def mount( # pyright: ignore[reportIncompatibleMethodOverride, reportArgu self.owner._curator_log_level = "info" socket.context = self._context() if is_connected(socket): - await socket.subscribe("buzz:status") + await socket.subscribe(TOPIC_STATUS) if socket.context["auto_refresh"]: - await socket.subscribe("buzz:logs") + await socket.subscribe(TOPIC_LOGS) async def handle_event( self, @@ -842,25 +876,24 @@ async def handle_event( socket: ConnectedLiveViewSocket[LogsContext], to: str = "", ) -> None: - if event == "navigate": + if event == EVENT_NAVIGATE: await socket.push_navigate(to) return if event == "toggle_auto_refresh": socket.context["auto_refresh"] = not socket.context["auto_refresh"] if socket.context["auto_refresh"]: - await socket.subscribe("buzz:logs") + await socket.subscribe(TOPIC_LOGS) else: - await socket.pub_sub.unsubscribe_topic_async("buzz:logs") - return + await socket.pub_sub.unsubscribe_topic_async(TOPIC_LOGS) async def handle_info( self, event: InfoEvent, socket: ConnectedLiveViewSocket[LogsContext], ) -> None: - if event.name not in {"buzz:logs", "buzz:status"}: + if event.name not in {TOPIC_LOGS, TOPIC_STATUS}: return - if event.name == "buzz:status" and not socket.context["auto_refresh"]: + if event.name == TOPIC_STATUS and not socket.context["auto_refresh"]: base = self._base_context( socket.context["console_msg"], socket.context["console_class"], @@ -914,8 +947,8 @@ async def mount( # pyright: ignore[reportIncompatibleMethodOverride, reportArgu await super().mount(socket, session) # pyright: ignore[reportArgumentType] socket.context = self._context() if is_connected(socket): - await socket.subscribe("buzz:status") - await socket.subscribe("buzz:config") + await socket.subscribe(TOPIC_STATUS) + await socket.subscribe(TOPIC_CONFIG) async def handle_event( self, @@ -925,7 +958,7 @@ async def handle_event( to: str = "", language_query: str = "", ) -> None: - if event == "navigate": + if event == EVENT_NAVIGATE: await socket.push_navigate(to) return if event == "edit": @@ -956,13 +989,13 @@ async def handle_event( "cannot refresh languages: set subtitles.opensubtitles" " api_key, username, and password in buzz.yml" ) - console_class = "service-status-red" + console_class = CSS_STATUS_RED elif not self.owner.trigger_language_refresh(force=True): console_msg = "language refresh already in progress" - console_class = "service-status-yellow" + console_class = CSS_STATUS_YELLOW else: console_msg = "refreshing languages from opensubtitles..." - console_class = "service-status-green" + console_class = CSS_STATUS_GREEN socket.context = self._context( is_editing=socket.context["is_editing"], draft_payload=socket.context["draft_payload"], @@ -991,13 +1024,13 @@ async def handle_event( overrides = _config_overrides_from_payload(payload or {}) result = self.owner.persist_overrides(overrides) console_msg = "saved." - console_class = "service-status-green" + console_class = CSS_STATUS_GREEN if result["restart_required"]: console_msg = ( "saved. restart required for " + ", ".join(result["restart_required_fields"]) ) - console_class = "service-status-yellow" + console_class = CSS_STATUS_YELLOW socket.context = self._context( is_editing=False, console_msg=console_msg, @@ -1009,17 +1042,17 @@ async def handle_info( event: InfoEvent, socket: ConnectedLiveViewSocket[ConfigContext], ) -> None: - if event.name not in {"buzz:status", "buzz:config"}: + if event.name not in {TOPIC_STATUS, TOPIC_CONFIG}: return console_msg = socket.context["console_msg"] console_class = socket.context["console_class"] if ( - event.name == "buzz:config" + event.name == TOPIC_CONFIG and isinstance(event.payload, dict) and event.payload.get("languages_refresh_complete") ): console_msg = "languages updated" - console_class = "service-status-green" + console_class = CSS_STATUS_GREEN socket.context = self._context( is_editing=socket.context["is_editing"], draft_payload=socket.context["draft_payload"], @@ -1158,6 +1191,25 @@ def _config_values( } +def _parse_number_value(raw_value: Any) -> int | float: + value = str(raw_value).strip() + if "." in value: + return float(value) + return int(value) + + +def _extract_pattern_lines(patterns: list[Any]) -> list[str]: + return [ + line.strip() + for line in str(patterns[0]).splitlines() + if line.strip() + ] + + +def _extract_language_values(values: list[Any]) -> list[str]: + return [str(v) for v in values if str(v).strip()] + + def _config_overrides_from_payload( payload: dict[str, Any], ) -> dict[str, Any]: @@ -1171,14 +1223,9 @@ def _config_overrides_from_payload( for field in _CONFIG_NUMBER_FIELDS: if field in normalized and normalized[field]: - raw_value = normalized[field][0] - value = str(raw_value).strip() - parsed: int | float - if "." in value: - parsed = float(value) - else: - parsed = int(value) - _set_nested_value(overrides, field, parsed) + _set_nested_value( + overrides, field, _parse_number_value(normalized[field][0]) + ) for field in _CONFIG_BOOL_FIELDS: _set_nested_value(overrides, field, field in normalized) @@ -1195,23 +1242,13 @@ def _config_overrides_from_payload( if field in normalized and normalized[field]: _set_nested_value(overrides, field, str(normalized[field][0])) - patterns = normalized.get("directories.anime.patterns", [""]) - _set_nested_value( - overrides, - "directories.anime.patterns", - [ - line.strip() - for line in str(patterns[0]).splitlines() - if line.strip() - ], - ) + patterns = normalized.get(FIELD_ANIME_PATTERNS, [""]) + _set_nested_value(overrides, FIELD_ANIME_PATTERNS, _extract_pattern_lines(patterns)) - languages = [ - str(value) - for value in normalized.get("subtitles.languages", []) - if str(value).strip() - ] - _set_nested_value(overrides, "subtitles.languages", languages) + languages = _extract_language_values( + normalized.get(FIELD_SUBTITLES_LANGUAGES, []) + ) + _set_nested_value(overrides, FIELD_SUBTITLES_LANGUAGES, languages) return overrides @@ -1272,6 +1309,61 @@ def _render_effective_yaml( return "\n".join(lines) + "\n" +def _yaml_dict_lines( + value: dict, + *, + indent: int, + path: str, + override_paths: set[str], +) -> list[str]: + prefix = " " * indent + lines: list[str] = [] + for key, child in value.items(): + child_path = f"{path}.{key}" if path else key + if child_path in override_paths: + lines.append(f"{prefix}# Overriden via UI") + if isinstance(child, dict): + lines.append(f"{prefix}{key}:") + lines.extend( + _yaml_lines( + child, + indent=indent + 2, + path=child_path, + override_paths=override_paths, + ) + ) + elif isinstance(child, list): + lines.append(f"{prefix}{key}:") + if not child: + lines.append(f"{prefix} []") + else: + for item in child: + if isinstance(item, (dict, list)): + lines.append(f"{prefix} -") + lines.extend( + _yaml_lines( + item, + indent=indent + 4, + path=child_path, + override_paths=override_paths, + ) + ) + else: + rendered = _render_yaml_scalar(item) + lines.append(f"{prefix} - {rendered}") + else: + rendered = _render_yaml_scalar(child) + lines.append(f"{prefix}{key}: {rendered}") + return lines + + +def _yaml_list_lines(value: list, *, indent: int) -> list[str]: + prefix = " " * indent + if not value: + return [f"{prefix}[]"] + return [f"{prefix}- {_render_yaml_scalar(item)}" for item in value] + + def _yaml_lines( value: Any, *, @@ -1279,54 +1371,13 @@ def _yaml_lines( path: str = "", override_paths: set[str], ) -> list[str]: - prefix = " " * indent if isinstance(value, dict): - lines: list[str] = [] - for key, child in value.items(): - child_path = f"{path}.{key}" if path else key - if child_path in override_paths: - lines.append(f"{prefix}# Overriden via UI") - if isinstance(child, dict): - lines.append(f"{prefix}{key}:") - lines.extend( - _yaml_lines( - child, - indent=indent + 2, - path=child_path, - override_paths=override_paths, - ) - ) - elif isinstance(child, list): - lines.append(f"{prefix}{key}:") - if not child: - lines.append(f"{prefix} []") - else: - for item in child: - if isinstance(item, (dict, list)): - lines.append(f"{prefix} -") - lines.extend( - _yaml_lines( - item, - indent=indent + 4, - path=child_path, - override_paths=override_paths, - ) - ) - else: - rendered = _render_yaml_scalar(item) - lines.append(f"{prefix} - {rendered}") - else: - rendered = _render_yaml_scalar(child) - lines.append(f"{prefix}{key}: {rendered}") - return lines + return _yaml_dict_lines( + value, indent=indent, path=path, override_paths=override_paths + ) if isinstance(value, list): - if not value: - return [f"{prefix}[]"] - lines = [] - for item in value: - rendered = _render_yaml_scalar(item) - lines.append(f"{prefix}- {rendered}") - return lines + return _yaml_list_lines(value, indent=indent) + prefix = " " * indent return [f"{prefix}{_render_yaml_scalar(value)}"] diff --git a/docs/work-items/hover-marquee-for-truncated-table-titles.md b/docs/work-items/hover-marquee-for-truncated-table-titles.md new file mode 100644 index 0000000..cfba2bf --- /dev/null +++ b/docs/work-items/hover-marquee-for-truncated-table-titles.md @@ -0,0 +1,96 @@ +# Hover Marquee For Truncated Table Titles + +## Status + +todo + +## Outcome + +An operator scanning the cache and archive tables should be able to read long +torrent titles without opening dev tools, resizing the browser, or leaving the +table context. When a title is truncated with ellipsis, hovering the row or +moving keyboard focus into the row should reveal the hidden suffix through a +lightweight marquee motion. Short titles should remain static. + +The idle state should stay visually identical to the current UI: single-line +cells with ellipsis. The marquee is a progressive enhancement for genuinely +truncated titles in the cache and archive name columns only. + +## Decision Changes + +- **Use a shared PyView hook, not CSS-only overflow heuristics.** Pure CSS + cannot reliably distinguish truncated titles from titles that already fit, + and these tables are updated by PyView after initial page load. A shared + front-end hook can remeasure overflow on mount, update, and resize without + adding backend complexity. +- **Trigger on row hover and row focus-within.** Pointer users should see the + effect on hover, and keyboard users should get the same reveal behavior when + they tab to row actions such as `[X]`, `[S]`, `[R]`, or `[D]`. +- **Animate only overflowing titles.** Cells that fit in the available width + must keep the current static rendering and never enter the marquee state. +- **One-way loop with an initial pause.** On activation, the title should hold + briefly at the start, then scroll left to reveal the clipped suffix, then + restart from the beginning while the hover/focus trigger remains active. +- **Respect reduced-motion preferences.** When the browser reports + `prefers-reduced-motion: reduce`, the UI should keep plain ellipsis and skip + marquee animation entirely. +- **Keep a non-animated fallback.** The rendered title element should expose + the full title through a standard `title` attribute so the full value is + still inspectable when animation is disabled or unsupported. + +## Main Quests + +- **Template structure** (`buzz/pyview_templates/cache_live.html`, + `buzz/pyview_templates/archive_live.html`): + - Wrap the existing title text in a dedicated marquee structure with an + outer clipping element and an inner label element. + - Add stable DOM markers for marquee measurement, and attach a shared + `phx-hook="BuzzOverflowMarquee"` at the table-container level. + - Preserve the current table semantics and visible copy; only the inner cell + structure changes. +- **Hook logic** (`buzz/static/pyview_helpers.js`): + - Add a `BuzzOverflowMarquee` hook that scans marked title cells within the + hooked container. + - On mount and update, measure `scrollWidth` against the visible clip width + to determine whether each title is truly overflowing. + - Mark overflowing cells with a data attribute and write CSS custom + properties for scroll distance and duration. + - Recompute measurements on container resize using `ResizeObserver`, and + clean up observers when the hook is destroyed. +- **Styling and motion** (`buzz/static/buzz.css`): + - Keep the default state as the current single-line ellipsis presentation. + - Add marquee-specific selectors that activate only when a cell is marked as + overflowing and its row is hovered or `:focus-within`. + - Define a keyframe animation that pauses at `translateX(0)` for the start + of the cycle, then scrolls left by the measured overflow distance. + - Add reduced-motion handling that disables the marquee and preserves the + static ellipsis state. +- **Tests** (`tests/test_buzz.py`): + - Extend the cache and archive page rendering tests to assert the new + marquee wrapper structure is present. + - Assert the shared hook is rendered on the relevant table containers. + - Assert title cells expose the fallback `title` attribute with the full + torrent name. + +## Acceptance Criteria + +- Long cache and archive titles remain ellipsized when idle. +- Hovering a row with a truncated title starts a one-way marquee after a short + initial pause. +- Moving keyboard focus into the row actions triggers the same reveal behavior + through `:focus-within`. +- Titles that fit within the available width never animate. +- Resizing the table container recomputes which titles overflow. +- Reduced-motion users see static ellipsis with no marquee animation. +- Updated rendering tests pass under `uv run python -m unittest tests.test_buzz`. +- Type checking passes under `uvx pyright buzz tests`. + +## Metadata + +### id + +hover-marquee-for-truncated-table-titles + +### type + +Issue diff --git a/tests/test_buzz.py b/tests/test_buzz.py index 6365033..effe05b 100644 --- a/tests/test_buzz.py +++ b/tests/test_buzz.py @@ -1128,6 +1128,10 @@ def test_cache_page_renders_pyview_shell(self): self.assertIn('href="/static/buzz.css"', body) self.assertIn('phx-click="prompt_delete"', body) self.assertIn('phx-click="fetch_subs"', body) + self.assertIn('phx-hook="BuzzOverflowMarquee"', body) + self.assertIn("data-marquee-clip", body) + self.assertIn("data-marquee-label", body) + self.assertIn('title="Movie & Stuff"', body) def test_archive_page_renders_pyview_shell(self): self.state.trashcan = { @@ -1155,6 +1159,10 @@ def test_archive_page_renders_pyview_shell(self): self.assertIn("Old & Gone", body) self.assertIn('href="/static/buzz.css"', body) self.assertIn('phx-click="prompt_restore"', body) + self.assertIn('phx-hook="BuzzOverflowMarquee"', body) + self.assertIn("data-marquee-clip", body) + self.assertIn("data-marquee-label", body) + self.assertIn('title="Old & Gone"', body) def test_cache_page_renders_empty_state_and_error_banner(self): self.state.last_error = "Boom & stuff" @@ -1873,7 +1881,7 @@ def test_refresh_logs_start_and_finish_events(self): break time.sleep(0.05) after = len(registry.events) - self.assertTrue(after > before) + self.assertGreater(after, before) messages = [e["message"] for e in registry.events] self.assertIn("OpenSubtitles language refresh started", messages) self.assertIn("OpenSubtitles language refresh finished", messages)