e45465d52d
Destination resolution
- Replace the single ResolveDestinationUseCase with four dedicated
functions, one per release type:
resolve_season_destination (pack season, folder move)
resolve_episode_destination (single episode, file move)
resolve_movie_destination (movie, file move)
resolve_series_destination (multi-season pack, folder move)
- Each returns a dedicated DTO carrying only the fields relevant to
that release type — no more polymorphic ResolvedDestination with
half the fields unused depending on the case.
- Looser series folder matching: exact computed-name match is reused
silently; any deviation (different group, multiple candidates) now
prompts the user with all options including the computed name.
Agent tools
- Four new tools wrapping the use cases above; old resolve_destination
removed from the registry.
- New move_to_destination tool: create_folder + move, chained — used
after a resolve_* call to perform the actual relocation.
- Low-level filesystem_operations module (create_folder, move via mv)
for instant same-FS renames (ZFS).
Prompt & persona
- New PromptBuilder (alfred/agent/prompt.py) replacing prompts.py:
identity + personality block, situational expressions, memory
schema, episodic/STM/config context, tool catalogue.
- Per-user expression system: knowledge/users/common.yaml +
{username}.yaml are merged at runtime; one phrase per situation
(greeting/success/error/...) is sampled into the system prompt.
qBittorrent integration
- Credentials now come from settings (qbittorrent_url/username/password)
instead of hardcoded defaults.
- New client methods: find_by_name, set_location, recheck — the trio
needed to update a torrent's save path and re-verify after a move.
- Host→container path translation settings (qbittorrent_host_path /
qbittorrent_container_path) for docker-mounted setups.
Subtitles
- Identifier: strip parenthesized qualifiers (simplified, brazil…) at
tokenization; new _tokenize_suffix used for the episode_subfolder
pattern so episode-stem tokens no longer pollute language detection.
- Placer: extract _build_dest_name so it can be reused by the new
dry_run path in ManageSubtitlesUseCase.
- Knowledge: add yue, ell, ind, msa, rus, vie, heb, tam, tel, tha,
hin, ukr; add 'fre' to fra; add 'simplified'/'traditional' to zho.
Misc
- LTM workspace: add 'trash' folder slot.
- Default LLM provider switched to deepseek.
- testing/debug_release.py: CLI to parse a release, hit TMDB, and
dry-run the destination resolution end-to-end.
165 lines
4.9 KiB
Python
165 lines
4.9 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
probe_video.py — Display MediaInfo extracted by ffprobe for a video file.
|
||
|
||
Usage:
|
||
uv run testing/probe_video.py /path/to/video.mkv
|
||
uv run testing/probe_video.py /path/to/video.mkv --no-color
|
||
"""
|
||
|
||
import argparse
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
_PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||
if str(_PROJECT_ROOT) not in sys.path:
|
||
sys.path.insert(0, str(_PROJECT_ROOT))
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Colours
|
||
# ---------------------------------------------------------------------------
|
||
|
||
RESET = "\033[0m"
|
||
BOLD = "\033[1m"
|
||
DIM = "\033[2m"
|
||
GREEN = "\033[32m"
|
||
YELLOW = "\033[33m"
|
||
RED = "\033[31m"
|
||
CYAN = "\033[36m"
|
||
BLUE = "\033[34m"
|
||
|
||
USE_COLOR = True
|
||
|
||
|
||
def c(text: str, *codes: str) -> str:
|
||
if not USE_COLOR:
|
||
return str(text)
|
||
return "".join(codes) + str(text) + RESET
|
||
|
||
|
||
def kv(key: str, val: str, indent: int = 4, color: str = CYAN) -> None:
|
||
print(f"{' ' * indent}{c(key + ':', BOLD)} {c(val, color)}")
|
||
|
||
|
||
def section(title: str) -> None:
|
||
print()
|
||
print(f" {c('▸ ' + title, BOLD, BLUE)}")
|
||
|
||
|
||
def hr() -> None:
|
||
print(c("─" * 70, DIM))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Formatting helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def fmt_duration(seconds: float) -> str:
|
||
h = int(seconds // 3600)
|
||
m = int((seconds % 3600) // 60)
|
||
s = int(seconds % 60)
|
||
if h:
|
||
return f"{h}h {m:02d}m {s:02d}s"
|
||
return f"{m}m {s:02d}s"
|
||
|
||
|
||
def fmt_channels(channels: int | None, layout: str | None) -> str:
|
||
parts = []
|
||
if channels is not None:
|
||
parts.append(str(channels) + "ch")
|
||
if layout:
|
||
parts.append(f"({layout})")
|
||
return " ".join(parts) if parts else "—"
|
||
|
||
|
||
def flag(val: bool) -> str:
|
||
return c("yes", GREEN) if val else c("no", DIM)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Main
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def main() -> None:
|
||
global USE_COLOR
|
||
|
||
parser = argparse.ArgumentParser(description="Probe a video file with ffprobe")
|
||
parser.add_argument("file", help="Path to the video file")
|
||
parser.add_argument("--no-color", action="store_true")
|
||
args = parser.parse_args()
|
||
|
||
if args.no_color or not sys.stdout.isatty():
|
||
USE_COLOR = False
|
||
|
||
path = Path(args.file)
|
||
if not path.exists():
|
||
print(c(f"Error: {path} does not exist", RED), file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
from alfred.infrastructure.filesystem.ffprobe import probe
|
||
|
||
info = probe(path)
|
||
if info is None:
|
||
print(c("Error: ffprobe failed to probe the file", RED), file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
print()
|
||
print(c("━" * 70, BOLD))
|
||
print(c(f" {path.name}", BOLD, CYAN))
|
||
print(c(f" {path}", DIM))
|
||
print(c("━" * 70, BOLD))
|
||
|
||
# --- Video ---
|
||
section("Video")
|
||
kv("codec", info.video_codec or c("—", DIM))
|
||
kv("resolution", info.resolution or c("—", DIM))
|
||
if info.width and info.height:
|
||
kv("dimensions", f"{info.width} × {info.height}")
|
||
if info.duration_seconds is not None:
|
||
kv("duration", fmt_duration(info.duration_seconds))
|
||
if info.bitrate_kbps is not None:
|
||
kv("bitrate", f"{info.bitrate_kbps} kbps")
|
||
|
||
# --- Audio ---
|
||
section(f"Audio {c(str(len(info.audio_tracks)) + ' track(s)', DIM)}")
|
||
if not info.audio_tracks:
|
||
print(f" {c('no audio tracks found', DIM)}")
|
||
for track in info.audio_tracks:
|
||
lang = track.language or "und"
|
||
default_marker = f" {c('default', GREEN, DIM)}" if track.is_default else ""
|
||
print(f" {c(f'[{track.index}]', BOLD)} {c(lang, YELLOW)}{default_marker}")
|
||
kv("codec", track.codec or c("—", DIM), indent=8)
|
||
kv("channels", fmt_channels(track.channels, track.channel_layout), indent=8)
|
||
|
||
# --- Subtitles ---
|
||
section(f"Subtitles {c(str(len(info.subtitle_tracks)) + ' track(s)', DIM)}")
|
||
if not info.subtitle_tracks:
|
||
print(f" {c('no embedded subtitle tracks', DIM)}")
|
||
for track in info.subtitle_tracks:
|
||
lang = track.language or "und"
|
||
markers = []
|
||
if track.is_default:
|
||
markers.append(c("default", GREEN, DIM))
|
||
if track.is_forced:
|
||
markers.append(c("forced", YELLOW, DIM))
|
||
marker_str = (" " + " ".join(markers)) if markers else ""
|
||
print(f" {c(f'[{track.index}]', BOLD)} {c(lang, YELLOW)}{marker_str}")
|
||
kv("codec", track.codec or c("—", DIM), indent=8)
|
||
|
||
# --- Summary ---
|
||
print()
|
||
hr()
|
||
multi = c("yes", GREEN) if info.is_multi_audio else c("no", DIM)
|
||
langs = ", ".join(info.audio_languages) if info.audio_languages else c("—", DIM)
|
||
print(
|
||
f" {c('multi-audio:', BOLD)} {multi} {c('languages:', BOLD)} {c(langs, CYAN)}"
|
||
)
|
||
hr()
|
||
print()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|