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 = (
- "
|
- {{ torrent.name }}
-
- {{ torrent.name }}
-
+
+ {{ torrent.name }}
+
|
{{ torrent.size }} | {{ torrent.file_count }} | 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 %} -