50 Commits

Author SHA1 Message Date
francwa 9f10f4e0ad Merge branch 'refactor/domain-release-knowledge'
Final DDD purification of the release parser. Domain layer no longer
imports anything from infrastructure, no YAML at import time, and
ParsedRelease's filesystem-builders are pure (Option B).

- ReleaseKnowledge Protocol port + YamlReleaseKnowledge adapter
- parse_release(name, kb) explicit injection
- ParsedRelease.title_sanitized field; builders accept already-safe strings
- Callers (resolve_destination, detect_media_type, find_video,
  analyze_release) thread the kb through
- 987 tests pass
2026-05-19 22:05:36 +02:00
francwa cd814c7922 docs(changelog): log refactor/domain-release-knowledge work block 2026-05-19 22:05:29 +02:00
francwa 6802933acd test(release): adapt suite to explicit ReleaseKnowledge injection
- test_release.py / test_release_fixtures.py: module-level
  _KB = YamlReleaseKnowledge() + thin _parse(name) helper threading it
  into parse_release. test_show_folder_name_strips_windows_chars renamed
  to test_show_folder_name_uses_already_safe_title to reflect the
  Option B contract (caller sanitizes via kb.sanitize_for_fs).
- test_detect_media_type.py: same _KB pattern, all
  detect_media_type(parsed, path) calls now pass kb.
- test_filesystem_extras.py: find_video_file(path) calls now pass kb.
- test_enrich_from_probe.py: _bare() helper adds the new
  title_sanitized field.
- test_resolve_destination.py: drop _sanitize import + TestSanitize
  class (helper deleted), add tmdb_title_safe arg to
  _resolve_series_folder calls.

987 passed, 8 skipped.
2026-05-19 22:05:26 +02:00
francwa bf37a9d09e refactor(release): thread ReleaseKnowledge through callers
Wires the new explicit-kb signatures into every caller of the release
parser and the filesystem-extension helpers.

- application/filesystem/resolve_destination.py: module-level singleton
  _KB: ReleaseKnowledge = YamlReleaseKnowledge(); each use case now calls
  parse_release(release_name, _KB) and sanitizes TMDB strings via
  _KB.sanitize_for_fs(...) before passing them to the pure ParsedRelease
  builders. Local _sanitize helper + _WIN_FORBIDDEN regex dropped.
- application/filesystem/detect_media_type.py: signature is now
  detect_media_type(parsed, source_path, kb); uses kb.metadata_extensions,
  kb.video_extensions, kb.non_video_extensions.
- infrastructure/filesystem/find_video.py: find_video_file(path, kb) uses
  kb.video_extensions instead of an imported constant.
- agent/tools/filesystem.py::analyze_release imports the application _KB
  singleton and passes it through to parse_release / detect_media_type /
  find_video_file.
2026-05-19 22:05:19 +02:00
francwa 4a74fff9cc refactor(release): purify domain — parse_release(name, kb) + ParsedRelease Option B
Removes the last domain → infrastructure leak in the release parser.

services.py:
- parse_release(name, kb) takes the knowledge as an explicit parameter.
- Every helper (_tokenize, _is_well_formed, _extract_tech,
  _extract_languages, _extract_audio, _extract_video_meta,
  _extract_edition, _extract_title, _infer_media_type) takes kb.
- No more module-level YAML loading.

value_objects.py — Option B:
- Sanitization happens once at parse time; ParsedRelease now carries
  a title_sanitized: str field alongside title.
- Builder methods (show_folder_name, episode_filename, movie_folder_name,
  movie_filename) become pure: they accept already-sanitized
  tmdb_title_safe / tmdb_episode_title_safe arguments. Callers at the
  use-case boundary sanitize via kb.sanitize_for_fs(...) before passing in.
- All domain-knowledge constants removed (_RESOLUTIONS, _SOURCES, _CODECS,
  _AUDIO, _VIDEO_META, _EDITIONS, _HDR_EXTRA, _MEDIA_TYPE_TOKENS,
  _LANGUAGE_TOKENS, _FORBIDDEN_CHARS, _*_EXTENSIONS, _WIN_FORBIDDEN_TABLE,
  _sanitize_for_fs). The module is now pure DDD.
2026-05-19 22:05:10 +02:00
francwa c3a3cb50c9 refactor(release): introduce ReleaseKnowledge Protocol port + YamlReleaseKnowledge adapter
Adds the port/adapter pair that lets the release domain consume parsing
knowledge without importing infrastructure or loading YAML at import time.

- alfred/domain/release/ports/knowledge.py declares the read-only query
  surface: token sets (resolutions, sources, codecs, language_tokens,
  forbidden_chars, hdr_extra), structured dicts (audio, video_meta,
  editions, media_type_tokens), separators list, file-extension sets,
  and sanitize_for_fs(text).
- alfred/infrastructure/knowledge/release_kb.py loads every YAML once
  at construction and exposes them as attributes, with an immutable
  str.maketrans table backing sanitize_for_fs.

No domain code is wired to the port yet — that lands in the next commit.
2026-05-19 22:05:01 +02:00
francwa 14941d47c0 Merge branch 'refactor/domain-io-extraction'
Extract all I/O (subprocess, filesystem, YAML loading) from the domain
layer via ports/adapters. domain/subtitles/ now has zero imports from
infrastructure/. The remaining domain → infra leak (release knowledge
loaded at import time) is documented in tech-debt for a dedicated branch.
2026-05-19 15:16:59 +02:00
francwa df798f55cc refactor(subtitles): introduce SubtitleKnowledge Protocol port
Domain services (SubtitleIdentifier, PatternDetector) used to import the
concrete SubtitleKnowledgeBase class directly from infrastructure for
their type hint. With this commit they depend on a structural Protocol
in alfred/domain/subtitles/ports/knowledge.py declaring just the 7
read-only query methods the domain actually consumes.

The concrete YAML-backed SubtitleKnowledgeBase in infrastructure remains
the sole adapter — no rename, no shim. With this change
alfred/domain/subtitles/ has zero imports from alfred/infrastructure/.

Also extend the changelog entry covering the full domain-io-extraction
branch.
2026-05-19 15:15:43 +02:00
francwa 535935cc73 docs(changelog): summarize refactor/domain-io-extraction work block 2026-05-19 15:11:17 +02:00
francwa 6e252d1e81 refactor(subtitles): inject default rules into SubtitleRuleSet.resolve()
aggregates.py used to call SubtitleKnowledgeBase().default_rules() via a
DEFAULT_RULES() helper, which silently pulled the infrastructure layer
(YAML loader) into the domain on every resolve.

Make the dependency explicit: resolve() now takes the default rules as
a parameter, and the caller (the ManageSubtitles use case) loads them
from the KB once and passes them in. Domain stays I/O-free.

- Drop DEFAULT_RULES helper and the SubtitleKnowledgeBase import from
  alfred/domain/subtitles/aggregates.py
- SubtitleRuleSet.resolve(default_rules: SubtitleMatchingRules)
- manage_subtitles use case passes kb.default_rules() at the call site
- Tests use a local SubtitleMatchingRules stand-in instead of relying
  on KB defaults
2026-05-19 15:10:06 +02:00
francwa 903e9e7117 refactor(subtitles): move SubtitlePlacer to application layer
The placer performs filesystem I/O (os.link) — it belongs in the
application layer, not the domain. Domain services should be pure.

- Move alfred/domain/subtitles/services/placer.py to
  alfred/application/subtitles/placer.py
- Move tests/domain/test_subtitle_placer.py to
  tests/application/test_subtitle_placer.py
- Update all callers (manage_subtitles use case, metadata store, tests)
- Drop placer re-exports from domain.subtitles.services.__init__
2026-05-19 15:07:39 +02:00
francwa 9556bf9e08 refactor(domain): strip live filesystem I/O from VOs and entities
DDD-pure cleanup — entities and value objects no longer query the world
at read time.

  FilePath: drop .exists() / .is_file() / .is_dir(). The VO is now a
    pure address; ask the injected FilesystemScanner for live state.
  Movie:    drop .has_file() / .is_downloaded(). Invariant: when the
    application sets file_path, it has already constated the file
    exists; downstream readers trust the snapshot.
  Episode:  same — drop .has_file() / .is_downloaded().
  SubtitlePlacer: drop the pre-check .exists() calls. The placer now
    attempts os.link() and reports FileNotFoundError / FileExistsError
    as skip reasons. Removes a TOCTOU race as a bonus.

Tests adjusted: the FilePath VO method tests are gone (the methods are
gone), test_has_file_false_when_no_path replaced by a plain assertion
on file_path is None. Placer tests are unchanged — the skip-reason
strings ('not found', 'already exists') match the new try/except paths.

The 'snapshot value objects' pattern (ProbedMediaInfo, TmdbMovieInfo)
that this cleanup enables is documented in refactor_domain_io.md, to
be applied when a future use case actually needs richer metadata —
not now, no speculative VOs.
2026-05-19 14:58:59 +02:00
francwa e6ee700825 refactor(subtitles): inject MediaProber/FilesystemScanner ports into domain services
Domain services no longer call subprocess or pathlib directly. Introduces
two Protocol ports in domain/shared/ports/:

  MediaProber.list_subtitle_streams(video) -> list[SubtitleStreamInfo]
  FilesystemScanner.scan_dir / stat / read_text  -> list[FileEntry] | ...

Concrete adapters live in infrastructure/:

  FfprobeMediaProber          (wraps subprocess + ffprobe + JSON)
  PathlibFilesystemScanner    (wraps pathlib + os reads)

SubtitleIdentifier and PatternDetector now take (kb, prober, scanner) at
construction time. Their internals work over FileEntry snapshots and
SubtitleStreamInfo records — no more ad-hoc Path.is_file/iterdir/stat or
embedded subprocess.run loops. _count_entries now takes raw SRT text
(returned by scanner.read_text) so SRT-only entry counting stays out of
the FS layer.

manage_subtitles use case instantiates the two adapters once and injects
them into both services. Tests pass real adapters and patch
`alfred.infrastructure.probe.ffprobe_prober.subprocess.run` for the
ffprobe-failure cases. _classify_single tests build FileEntry via a
small helper.

Domain is now free of subprocess / direct filesystem reads in the
subtitle pipeline. The only remaining I/O hooks are FilePath VO
convenience methods (exists/is_file/is_dir) which stay as a deliberate
affordance on the value object.
2026-05-19 14:52:24 +02:00
francwa ced72547f7 refactor(knowledge): extract YAML loaders from domain to infrastructure
The domain layer no longer reads YAML files. All knowledge loaders move
from `alfred/domain/*/knowledge/` to `alfred/infrastructure/knowledge/`:

  domain/release/knowledge.py
    → infrastructure/knowledge/release.py
  domain/shared/knowledge/language_registry.py
    → infrastructure/knowledge/language_registry.py
  domain/subtitles/knowledge/{loader,base}.py
    → infrastructure/knowledge/subtitles/{loader,base}.py

Callers in domain/release/{services,value_objects}.py,
domain/subtitles/{aggregates,services/*}.py, and
application/filesystem/manage_subtitles.py updated to absolute imports.
Re-exports of KnowledgeLoader/SubtitleKnowledgeBase from
domain/subtitles/__init__.py dropped (no shim per project convention).
Tests follow the moved targets.
2026-05-19 14:35:18 +02:00
francwa f338b08706 refactor(release): type media_type/parse_path as true enums
ParsedRelease.media_type is now MediaTypeToken (not str) and parse_path
is ParsePath (not str). __post_init__ keeps a tolerant constructor that
coerces raw strings via the enum, so callers passing 'movie'/'direct'
still work transparently. Since both enums inherit from str, existing
string comparisons and JSON serialization remain unchanged.
2026-05-19 14:21:27 +02:00
francwa da484d7474 refactor(release): typed enums + __post_init__ validation on ParsedRelease
ParsedRelease accepted any string for media_type/parse_path and had no
validation on numeric ranges (season=-5 was silently accepted). Tighten
both ends:

- New str-backed Enums MediaTypeToken and ParsePath. Inherit from str so
  every existing comparison ('== "movie"'), JSON serialization, and TMDB
  DTO interop keeps working unchanged.
- ParsedRelease.__post_init__ now validates: raw/group non-empty, year in
  1888-2100, season 0-100, episode 0-9999, episode_end >= episode,
  media_type/parse_path against the enum allowlist.
- services.py uses the enum .value members everywhere instead of bare
  string literals — kills the typo risk.
2026-05-19 14:17:56 +02:00
francwa 481eeb5afd refactor(domain): identity-based equality + dedup track helpers
Two related DDD fixes for Movie and Episode entities:

- Identity equality: @dataclass(eq=False) with custom __eq__/__hash__.
  Movie is identified by imdb_id, Episode by (season, episode) within
  the TVShow aggregate. Auto-generated field-by-field equality was
  incorrectly making two Movie instances with the same imdb_id but
  different audio_tracks compare unequal — breaks dedup/caching.

- MediaWithTracks mixin: the 5 audio/subtitle helpers
  (has_audio_in / audio_languages / has_subtitles_in / has_forced_subs /
  subtitle_languages) were duplicated verbatim between Movie and Episode.
  Extracted to shared/media/tracks_mixin.py; both entities now inherit.

Bonus: dropped the object.__setattr__ coercion dance in Movie.__post_init__
— the class isn't frozen so plain assignment is the right call.
2026-05-19 14:17:47 +02:00
francwa 7cd24f3a31 refactor(domain): freeze media track value objects
AudioTrack, VideoTrack, SubtitleTrack and MediaInfo are snapshots of a
single ffprobe run — model them as proper immutable value objects.

- @dataclass(frozen=True) on all four
- MediaInfo track collections become tuple[...] instead of list[...]
- ffprobe adapter rewritten to build tuples up-front instead of
  appending/setattr'ing on a constructed instance
2026-05-19 14:17:27 +02:00
francwa eb8995cfc3 refactor(subtitles): drop dead scanner module
SubtitleScanner was an earlier iteration superseded by SubtitleIdentifier
and never imported in production code (only by its own tests). Removing
both keeps the bounded context clean and shrinks the surface.
2026-05-19 14:17:15 +02:00
francwa f6eef59fca refactor: tech debt mini-pass (items 5, 6, 7, 20)
Low-risk cleanup items, no functional change to the parser. The
philosophy remains: keep the parser simple, the AI handles edge cases.

- Extract duplicated 'fs-safe title → dot-folder-name' regex into
  to_dot_folder_name() in domain/shared/value_objects.py. Used by both
  MovieTitle.normalized() and TVShow.get_folder_name() (item #5).
- ParsedRelease.languages now uses field(default_factory=list) instead
  of a manual __post_init__ assigning [] via object.__setattr__ (#6).
- tv_shows/entities.py module docstring: prepend ASCII ownership tree
  for quicker visual scan of the aggregate hierarchy (#7).
- file_extensions.yaml: split subtitle sidecars (.srt/.sub/.idx/.ass/.ssa)
  into a dedicated 'subtitle:' category instead of lumping them under
  'metadata:'. _METADATA_EXTENSIONS at the value_objects.py level remains
  the union of both — detect_media_type behavior unchanged. New loader
  load_subtitle_extensions() exposes the distinct subtitle set for future
  callers in the subtitles domain (#20).

Suite: 1020 passed, 8 skipped.
2026-05-18 16:24:28 +02:00
francwa 273510dff8 test(fixtures): seed PATH OF PAIN bucket with 10 worst-case fixtures
10 pathological release names mined from the real downloads folder.
Each fixture locks in the current parse_release output (including
its silent losses and false positives) so future parser improvements
are intentional, not silent drift.

Cases:
- Khruangbin yt-dlp slug (UTF-8 wide pipe '|', YT ID as group)
- Deutschland 83-86-89 franchise box (group=S03 misdetection)
- Chérie Le BéBé (accented chars preserved, VFF language)
- Jimmy Carr 8-word stand-up special title
- [ OxTorrent.vc ] prefix + XviD codec (site_tag prefix)
- Prodiges S12E01 with episode title + air-date silently lost
- The Prodigy: apostrophe + Blu-ray dash + 1080i + multi-word audio
  = full AI-path degeneration (everything UNKNOWN)
- Sleaford Mods yt-dlp slug (YT ID glued to year)
- Super Mario Bros [FR-EN] (bilingual tag mistaken for group)
- Gilmore Girls Complete S01-S07 (the well-behaved exception:
  COMPLETE token correctly drives tv_complete + REPACK + 10bit)

Also adds shitty + path_of_pain to the per-bucket sanity assertion.

Suite: 1020 passed, 8 skipped.
2026-05-18 15:57:56 +02:00
francwa c1831e3f46 test(fixtures): drop derry_duplicate_naming (was a copy-paste artifact)
The release name mixed two distinct releases — not a real-world case
worth anti-regression. SHITTY bucket now holds 14 fixtures (down from 15).
2026-05-18 15:51:11 +02:00
francwa aa182458b8 test(fixtures): seed SHITTY release bucket with 15 anti-regression cases
Add 15 expected.yaml fixtures under tests/fixtures/releases/shitty/
covering the awkward but real-world release names from the downloads
folder. Each fixture locks in the current parse_release behavior so
future parser changes are intentional, not silent drift.

Cases captured:
- Angel INTEGRALE 3-level hierarchy (tv_complete media_type)
- Buffy custom French title with dots preserved
- Archer S14E09E10E11 multi-episode (E11 lost — tech debt)
- Notre Planète lowercase s01e01
- Vinyl ' - 1x01 - FHD' (stray dash artifact — tech debt)
- Deutschland.83 (year-suffix as part of title)
- Tatortreiniger S01-06 range (falls to movie — tech debt)
- Derry Girls duplicated title
- Jurassic Park bare folder (media_type=unknown)
- La Nuit au Musée bilingual MULTI
- Chérie j'ai agrandi (ASCII-stripped apostrophe, parses fine)
- Honey Don't (unescaped apostrophe — full AI-path degeneration)
- Hook MULTi.SUBS movie with Subs/ folder
- Predator Badlands space separators (group=UNKNOWN — tech debt)
- Westworld S04 Subs.Only (no video file)

Each fixture also captures the future 3-flow routing (library /
torrents / seed_hardlinks) ahead of the organize_media refactor.

Suite: 1011 passed, 8 skipped.
2026-05-18 15:48:41 +02:00
francwa 774f71c8cc chore(gitignore): track CHANGELOG.md explicitly
The blanket *.md ignore was hiding CHANGELOG.md, forcing 'git add -f' on
every update. Allow-list it so the file lives under normal git tracking.
CLAUDE.md stays local (user keeps it personal until a dedicated repo).
2026-05-18 15:39:04 +02:00
francwa 7bc50fd5b8 test: add real-world release fixtures (EASY bucket)
Captures 5 canonical releases from /mnt/testipool/downloads as parametrized
fixtures under tests/fixtures/releases/easy/. Each fixture declares the
release name, expected ParsedRelease fields, original tree, and the future
routing (library / torrents / seed_hardlinks) for the upcoming organize_media
refactor.

Today only the 'parsed' section is asserted; tree is materialized into a
tmp_path to catch typos. Routing is captured ahead of the planner work — it
becomes verifiable once organize_media lands.

Cases: back_in_action (movie), slow_horses_single_ep (TV single),
foundation_season_pack (S02 + .nfo noise), long_walk_with_noise (movie +
KONTRAST.TOP.txt), sinners_yts (YTS bracket-heavy + Subs/ dir).

Also tracks CHANGELOG.md under [Unreleased] / Added.
2026-05-18 15:36:19 +02:00
francwa f17abdbaec chore: cleanup — remove shims, fix ruff warnings, ignore noisy rules
- Removed backward-compat shims _sanitise_for_fs / _strip_episode_from_normalised
  in domain/release/value_objects.py (zero callers).
- Fixed ruff warnings across the codebase:
    * PLW1510: explicit check=False on subprocess.run calls
    * PLC0415: promoted lazy imports to module top where no cycle exists
      (manage_subtitles, placer, qbittorrent/client, file_manager)
    * E402: fixed module-level import ordering in language_registry.py and
      subtitles/knowledge/loader.py
    * F841 / B007: removed unused locals (identifier.py)
    * C416: replaced unnecessary set comprehension with set() in
      release/knowledge.py
- Ruff config: ignore PLR0911/PLR0912 globally (noisy on mappers and
  orchestrator use-cases) and PLW0603 (intentional for the memory singleton).
- Updated tech debt memory: P1 done, ShowStatus actually complete (was a
  stale note).
2026-05-18 00:02:45 +02:00
francwa 1d50b63af2 Merge branch 'dev/sprint-cleanup'
Multi-week sprint: ISO 639-2/B language unification, release parser
unification + data-driven tokenizer, removal of fossil services
(movies/tv_shows/subtitles), subtitle services split into a package,
MediaInfo split, test suite expansion (990 passing).

See CHANGELOG.md [Unreleased] for the user-facing summary.
2026-05-17 23:42:05 +02:00
francwa 891ba502a2 chore: apply pre-commit auto-fixes (trim trailing whitespace, EOF) 2026-05-17 23:41:54 +02:00
francwa e07c9ec77b chore: sprint cleanup — language unification, parser unification, fossils removal
Several weeks of work accumulated without being committed. Grouped here for
clarity; see CHANGELOG.md [Unreleased] for the user-facing summary.

Highlights
----------

P1 #2 — ISO 639-2/B canonical migration
- New Language VO + LanguageRegistry (alfred/domain/shared/knowledge/).
- iso_languages.yaml as single source of truth for language codes.
- SubtitleKnowledgeBase now delegates lookup to LanguageRegistry; subtitles.yaml
  only declares subtitle-specific tokens (vostfr, vf, vff, …).
- SubtitlePreferences default → ["fre", "eng"]; subtitle filenames written as
  {iso639_2b}.srt (legacy fr.srt still read via alias).
- Scanner: dropped _LANG_KEYWORDS / _SDH_TOKENS / _FORCED_TOKENS /
  SUBTITLE_EXTENSIONS hardcoded dicts.
- Fixed: 'hi' token no longer marks SDH (conflicted with Hindi alias).
- Added settings.min_movie_size_bytes (was a module constant).

P1 #3 — Release parser unification + data-driven tokenizer
- parse_release() is now the single source of truth for release-name parsing.
- alfred/knowledge/release/separators.yaml declares the token separators used
  by the tokenizer (., space, [, ], (, ), _). New conventions can be added
  without code changes.
- Tokenizer now splits on any configured separator instead of name.split('.').
  Releases like 'The Father (2020) [1080p] [WEBRip] [5.1] [YTS.MX]' parse via
  the direct path without sanitization fallback.
- Site-tag extraction always runs first; well-formedness only rejects truly
  forbidden chars.
- _parse_season_episode() extended with NxNN / NxNNxNN alt forms.
- Removed dead helpers: _sanitize, _normalize.

Domain cleanup
- Deleted fossil services with zero production callers:
    alfred/domain/movies/services.py
    alfred/domain/tv_shows/services.py
    alfred/domain/subtitles/services.py (replaced by subtitles/services/ package)
    alfred/domain/subtitles/repositories.py
- Split monolithic subtitle services into a package (identifier, matcher,
  placer, pattern_detector, utils) + dedicated knowledge/ package.
- MediaInfo split into dedicated package (alfred/domain/shared/media/:
  audio, video, subtitle, info, matching).

Persistence cleanup
- Removed dead JSON repositories (movie/subtitle/tvshow_repository.py).

Tests
- Major expansion of the test suite organized to mirror the source tree.
- Removed obsolete *_edge_cases test files superseded by structured tests.
- Suite: 990 passed, 8 skipped.

Misc
- .gitignore: exclude env_backup/ and *.bak.
- Adjustments across agent/llm, app.py, application/filesystem, and
  infrastructure/filesystem to align with the new domain layout.
2026-05-17 23:38:00 +02:00
francwa ba6f016d49 feat: generic MetadataStore + read_release_metadata + query_library
- Extract MetadataStore from SubtitleMetadataStore (alfred/infrastructure/metadata/).
  Generic load/save + typed update helpers (update_parse, update_probe, update_tmdb)
  for the per-release .alfred/metadata.yaml.
- SubtitleMetadataStore becomes a thin facade — owns subtitle_history shape,
  delegates I/O to MetadataStore.
- Agent._execute_tool_call auto-persists successful analyze_release / probe_media /
  find_media_imdb_id results to the release's .alfred file. find_media_imdb_id
  follows release_focus when it has no path argument.
- New tools:
  · read_release_metadata(release_path) — cacheable, key=release_path.
    Returns the .alfred content or has_metadata=false.
  · query_library(name) — substring scan across configured library roots.
- Both new tools added to CORE_TOOLS (always visible).
2026-05-15 11:02:25 +02:00
francwa 3c7c6695f2 feat(memory): Phase 1 — STM ToolResultsCache + ReleaseFocus + cache flag in YAML specs
Adds two STM components and a transparent cache hook in the agent loop so
read-only tools don't re-do work the agent already did in this session.

New STM components:
  - ToolResultsCache  — {tool_name: {key: result}}, session-scoped.
    to_dict() exposes only the key inventory (not payloads) to keep the
    prompt cheap.
  - ReleaseFocus      — current_release_path + working_set list, updated
    automatically when a path-keyed inspector runs.

YAML spec layer:
  - New optional 'cache: { key: <param_name> }' block in ToolSpec.
  - Validated at load time: cache.key must be a declared parameter.
  - Surfaced on Tool dataclass as cache_key: str | None.

Agent._execute_tool_call:
  - Pre-exec cache lookup; hit short-circuits and adds _from_cache=true.
  - Post-exec: stores successful results, updates release_focus for
    path-keyed tools, refreshes episodic.last_search_results when
    find_torrent's hit served the response (so get_torrent_by_index
    keeps pointing at the right list).

Cacheable tools (5): analyze_release, probe_media, list_folder,
find_media_imdb_id, find_torrent.
2026-05-15 10:44:14 +02:00
francwa 2db3198ef2 feat(agent): migrate all remaining tools to YAML specs (21/21 covered)
Adds YAML specs for the 14 tools that were still description-from-docstring:

  filesystem:
    - set_path_for_folder, list_folder, analyze_release, probe_media,
      move_media, manage_subtitles, create_seed_links, learn
  api:
    - find_media_imdb_id, find_torrent, get_torrent_by_index,
      add_torrent_to_qbittorrent, add_torrent_by_index
  language:
    - set_language

Each spec follows the established shape (summary / description /
when_to_use / when_not_to_use / next_steps / parameters with
why_needed + example / returns) and the Python function docstring is
slimmed to a one-line pointer.

Registry now reports: 21 tools, 21 with YAML spec, 0 doc-only.
2026-05-14 21:18:43 +02:00
francwa 23a9dd7990 refactor(memory): rename workflow.target -> params, type -> name
The Workflow STM component stored an active workflow as
{type, target, stage, started_at}. Now that start_workflow takes a
workflow_name and a params dict, those keys match what they actually
hold:

  type   -> name    (the YAML workflow name, e.g. media.organize_media)
  target -> params  (the dict passed to start_workflow)

ShortTermMemory.start_workflow parameters renamed accordingly. All
consumers (prompt builder workflow scope + STM context, start/end
workflow tools) updated.
2026-05-14 21:11:23 +02:00
francwa 74a52ba6a3 feat(agent): workflow-scoped tool catalog + start/end_workflow meta-tools
Introduce a scope-aware agent so the LLM never sees the full 21-tool
catalog at once. The system prompt now describes either:
  - idle mode: core noyau (5 tools: set_language, set_path_for_folder,
    list_folder, start_workflow, end_workflow) + a list of available
    workflows with their goals;
  - active mode: the noyau plus the tools declared by the active
    workflow's YAML, with the step plan inlined into the prompt.

Pieces:
- alfred/agent/tools/workflow.py: start_workflow / end_workflow tools
  (with YAML specs under tools/specs/) that drive memory.stm.workflow.
- alfred/agent/prompt.py: CORE_TOOLS constant, visible_tool_names(),
  filtered build_tools_spec() / _format_tools_description(), and a new
  _format_workflow_scope() section in the system prompt.
- alfred/agent/agent.py: WorkflowLoader wired into Agent, defensive
  out-of-scope check in _execute_tool_call.
- alfred/agent/registry.py: registers the two new meta-tools (21 total,
  7 with YAML spec).
- workflows/media.organize_media.yaml: tools/steps list refreshed to
  match the current resolver split (analyze_release, probe_media,
  resolve_*_destination, move_to_destination).
2026-05-14 21:07:36 +02:00
francwa 97adfbda45 refactor(workflows): adopt media.* naming convention
Rename workflow files and their 'name' field with a 'media.' domain
prefix to anticipate future multi-domain expansion (mail.*, calendar.*, ...).

- organize_media -> media.organize_media
- manage_subtitles -> media.manage_subtitles

WorkflowLoader picks them up unchanged (uses data['name']).
2026-05-14 20:55:35 +02:00
francwa 239fce9e4e chore(agent): remove dead parameters.py
The ParameterSchema / REQUIRED_PARAMETERS / get_missing_required_parameters
machinery in alfred/agent/parameters.py was used in early prototypes for
the prompt-required-params check but has been unwired from production for
several refactors. The new YAML tool-spec layer (alfred/agent/tools/specs/)
covers the same need (rich, LLM-facing parameter descriptions) without
the parallel registration plumbing.

Tests in tests/test_config_edge_cases.py still reference the deleted
module — left untouched per the project policy of treating test sync
as a dedicated end-of-week task.
2026-05-14 18:06:34 +02:00
francwa 99c95af64e feat(agent): YAML tool specs as the LLM-facing semantic layer
Introduce a first-class semantic layer for tool descriptions, separated
from Python signatures (which stay the source of truth for types and
required-ness).

New
- alfred/agent/tools/spec.py — ToolSpec / ParameterSpec / ReturnsSpec
  dataclasses with strict YAML validation (ToolSpecError on malformed
  or inconsistent specs). compile_description() builds the rich text
  passed to the LLM as Tool.description, with sections for summary,
  description, when_to_use, when_not_to_use, next_steps, and returns.
  compile_parameter_description() injects the 'why_needed' field next
  to each parameter so the LLM sees the *intent* of each argument.
- alfred/agent/tools/spec_loader.py — discovers tools/specs/*.yaml,
  enforces filename ↔ spec.name match, rejects duplicates.
- alfred/agent/tools/specs/ — one YAML per tool:
    * resolve_season_destination.yaml
    * resolve_episode_destination.yaml
    * resolve_movie_destination.yaml
    * resolve_series_destination.yaml
    * move_to_destination.yaml

Refactor
- alfred/agent/registry.py
    * _create_tool_from_function now takes an optional ToolSpec.
      When provided, the long description + per-parameter descriptions
      come from the spec; types and required-ness still come from the
      Python signature.
    * Cross-validates spec.parameters against the function signature —
      crashes on missing or extra entries.
    * make_tools() loads all specs at startup and hands the right one
      to each tool. Tools without a spec fall back to the old
      docstring-only behaviour, so the 14 not-yet-migrated tools keep
      working unchanged.
    * Adds 'array' and 'object' to the Python→JSON type mapping and
      handles Optional[X] / X | None annotations.

- alfred/agent/tools/filesystem.py
    * Drops the '_tool' suffix on the 4 resolve_* wrappers (option 1:
      alias the use-case imports as _resolve_*). Tool names exposed to
      the LLM now match the underlying use case verbatim.
    * Wrapper docstrings shrink to a one-liner pointing to the YAML
      spec — no more duplicated when_to_use/Args/Returns in Python.

Verified
- make_tools() loads 19 tools (5 with YAML spec, 14 doc-only).
- Compiled descriptions render cleanly with all sections.
2026-05-14 18:06:27 +02:00
francwa b5025bb5f8 refactor(resolve_destination): factor shared series-folder resolution + DTO base
- New _Clarification sentinel and _resolve_series_folder() helper —
  the three TV use cases now share one matching/clarification path
  instead of triplicating the same if/elif/else block.
- New _ResolvedDestinationBase carrying status/question/options/error/
  message plus a _base_dict() helper; the four concrete DTOs only
  declare their own ok-state fields and a slim to_dict().
- No behaviour change: same outputs for ok/needs_clarification/error
  cases (verified by import + DTO smoke tests).
2026-05-14 16:09:33 +02:00
francwa e45465d52d feat: split resolve_destination, persona-driven prompts, qBittorrent relocation
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.
2026-05-14 05:01:59 +02:00
francwa 1723b9fa53 feat: release parser, media type detection, ffprobe integration
Replace the old domain/media release parser with a full rewrite under
domain/release/:
- ParsedRelease with media_type ("movie" | "tv_show" | "tv_complete" |
  "documentary" | "concert" | "other" | "unknown"), site_tag, parse_path,
  languages, audio_codec, audio_channels, bit_depth, hdr_format, edition
- Well-formedness check + sanitize pipeline (_is_well_formed, _sanitize,
  _strip_site_tag) before token-level parsing
- Multi-token sequence matching for audio (DTS-HD.MA, TrueHD.Atmos…),
  HDR (DV.HDR10…) and editions (DIRECTORS.CUT…)
- Knowledge YAML: file_extensions, release_format, languages, audio,
  video, editions, sites/c411

New infrastructure:
- ffprobe.py — single-pass probe returning MediaInfo (video, audio
  tracks, subtitle tracks)
- find_video.py — locate first video file in a release folder

New application helpers:
- detect_media_type — filesystem-based type refinement
- enrich_from_probe — fill missing ParsedRelease fields from MediaInfo

New agent tools:
- analyze_release — parse + detect type + ffprobe in one call
- probe_media — standalone ffprobe for a specific file

New domain value object:
- MediaInfo + AudioTrack + SubtitleTrack (domain/shared/media_info.py)

Testing CLIs:
- recognize_folders_in_downloads.py — full pipeline with colored output
- probe_video.py — display MediaInfo for a video file
2026-05-12 16:14:20 +02:00
francwa 249c5de76a feat: major architectural refactor
- Refactor memory system (episodic/STM/LTM with components)
- Implement complete subtitle domain (scanner, matcher, placer)
- Add YAML workflow infrastructure
- Externalize knowledge base (patterns, release groups)
- Add comprehensive testing suite
- Create manual testing CLIs
2026-05-11 21:55:06 +02:00
francwa 62b5d0b998 Settings + fix startup 2026-04-30 12:41:42 +02:00
francwa 610dee365c mess: UV + settings KISS + fixes 2026-04-24 18:10:55 +02:00
francwa 58408d0dbe fix: fixed vectordb loneliness 2026-01-06 04:39:42 +01:00
francwa 2f1ac3c758 infra: simplified mongodb healthcheck 2026-01-06 04:36:52 +01:00
francwa d3b69f7459 feat: enabled logging for alfred(-core) 2026-01-06 04:33:59 +01:00
francwa 50c8204fa0 infra: added granular logging configuration for mongodb 2026-01-06 02:50:49 +01:00
francwa 507fe0f40e chore: updated dependencies 2026-01-06 02:41:18 +01:00
francwa b7b40eada1 fix: set proper database name for mongodb 2026-01-06 02:33:35 +01:00
francwa 9765386405 feat: named docker image to avoid docker picking the wrong one 2026-01-06 02:19:00 +01:00
287 changed files with 32000 additions and 8712 deletions
+80
View File
@@ -0,0 +1,80 @@
# --- IMPORTANT ---
# Settings are split across multiple files for clarity.
# Files (loaded in this order, last wins):
# .env.alfred — app config and service addresses (safe to commit)
# .env.secrets — generated secrets, passwords, URIs and API keys (DO NOT COMMIT)
# .env.make — build metadata synced from pyproject.toml (safe to commit)
#
# To customize: edit .env.alfred for config, .env.secrets for secrets.
# --- Alfred ---
MAX_HISTORY_MESSAGES=10
MAX_TOOL_ITERATIONS=10
REQUEST_TIMEOUT=30
# LLM Settings
LLM_TEMPERATURE=0.2
# Persistence
DATA_STORAGE_DIR=data
# Network
HOST=0.0.0.0
PORT=3080
# --- DATABASES ---
# Passwords and connection URIs are auto-generated in .env.secrets.
# Edit host/port/user/dbname here if needed.
# MongoDB (Application Data)
MONGO_HOST=mongodb
MONGO_PORT=27017
MONGO_USER=alfred
MONGO_DB_NAME=alfred
# PostgreSQL (Vector Database / RAG)
POSTGRES_HOST=vectordb
POSTGRES_PORT=5432
POSTGRES_USER=alfred
POSTGRES_DB_NAME=alfred
# --- EXTERNAL SERVICES ---
# TMDB — Media metadata (required). Get your key at https://www.themoviedb.org/
# → TMDB_API_KEY goes in .env.secrets
TMDB_BASE_URL=https://api.themoviedb.org/3
# qBittorrent
# → QBITTORRENT_PASSWORD goes in .env.secrets
QBITTORRENT_URL=https://qb.lan.anustart.top
QBITTORRENT_USERNAME=letmein
QBITTORRENT_PORT=16140
# Path translation: host-side prefix → container-side prefix
QBITTORRENT_HOST_PATH=/mnt/testipool
QBITTORRENT_CONTAINER_PATH=/mnt/data
# Meilisearch
# → MEILI_MASTER_KEY goes in .env.secrets
# MEILI_ENABLED=false # KEY DOESN'T EXISTS => SEARCH IS THE PROPER KEY
SEARCH=false
MEILI_NO_ANALYTICS=true
MEILI_HOST=http://meilisearch:7700
# --- LLM CONFIGURATION ---
# Providers: local, openai, anthropic, deepseek, google, kimi
# → API keys go in .env.secrets
DEFAULT_LLM_PROVIDER=deepseek
# Local LLM (Ollama)
#OLLAMA_BASE_URL=http://ollama:11434
#OLLAMA_MODEL=llama3.3:latest
OLLAMA_BASE_URL=http://10.0.0.11:11434
OLLAMA_MODEL=glm-4.7-flash:latest
# --- RAG ENGINE ---
RAG_ENABLED=TRUE
RAG_API_URL=http://rag_api:8000
RAG_API_PORT=8000
EMBEDDINGS_PROVIDER=ollama
EMBEDDINGS_MODEL=nomic-embed-text
+23 -43
View File
@@ -1,3 +1,13 @@
# --- IMPORTANT ---
# Settings are split across multiple files for clarity.
# Files (loaded in this order, last wins):
# .env.alfred — app config and service addresses (safe to commit)
# .env.secrets — generated secrets, passwords, URIs and API keys (DO NOT COMMIT)
# .env.make — build metadata synced from pyproject.toml (safe to commit)
#
# To customize: edit .env.alfred for config, .env.secrets for secrets.
# --- Alfred ---
MAX_HISTORY_MESSAGES=10
MAX_TOOL_ITERATIONS=10
REQUEST_TIMEOUT=30
@@ -8,84 +18,54 @@ LLM_TEMPERATURE=0.2
# Persistence
DATA_STORAGE_DIR=data
# Network configuration
# Network
HOST=0.0.0.0
PORT=3080
# Build informations (Synced with pyproject.toml via bootstrap)
ALFRED_VERSION=
IMAGE_NAME=
LIBRECHAT_VERSION=
PYTHON_VERSION=
PYTHON_VERSION_SHORT=
RAG_VERSION=
RUNNER=
SERVICE_NAME=
# --- SECURITY KEYS (CRITICAL) ---
# These are used for session tokens and encrypting sensitive data in MongoDB.
# If you lose these, you lose access to encrypted stored credentials.
JWT_SECRET=
JWT_REFRESH_SECRET=
CREDS_KEY=
CREDS_IV=
# --- DATABASES (AUTO-SECURED) ---
# Alfred uses MongoDB for application state and PostgreSQL for Vector RAG.
# Passwords will be generated as 24-character secure tokens if left blank.
# --- DATABASES ---
# Passwords and connection URIs are auto-generated in .env.secrets.
# Edit host/port/user/dbname here if needed.
# MongoDB (Application Data)
MONGO_URI=
MONGO_HOST=mongodb
MONGO_PORT=27017
MONGO_USER=alfred
MONGO_PASSWORD=
MONGO_DB_NAME=alfred
MONGO_DB_NAME=LibreChat
# PostgreSQL (Vector Database / RAG)
POSTGRES_URI=
POSTGRES_HOST=vectordb
POSTGRES_PORT=5432
POSTGRES_USER=alfred
POSTGRES_PASSWORD=
POSTGRES_DB_NAME=alfred
# --- EXTERNAL SERVICES ---
# Media Metadata (Required)
# Get your key at https://www.themoviedb.org/
TMDB_API_KEY=
# TMDB — Media metadata (required). Get your key at https://www.themoviedb.org/
# → TMDB_API_KEY goes in .env.secrets
TMDB_BASE_URL=https://api.themoviedb.org/3
# qBittorrent integration
# qBittorrent
# → QBITTORRENT_PASSWORD goes in .env.secrets
QBITTORRENT_URL=http://qbittorrent:16140
QBITTORRENT_USERNAME=admin
QBITTORRENT_PASSWORD=
QBITTORRENT_PORT=16140
# Meilisearch
# → MEILI_MASTER_KEY goes in .env.secrets
MEILI_ENABLED=FALSE
MEILI_NO_ANALYTICS=TRUE
MEILI_HOST=http://meilisearch:7700
MEILI_MASTER_KEY=
# --- LLM CONFIGURATION ---
# Providers: 'local', 'openai', 'anthropic', 'deepseek', 'google', 'kimi'
# Providers: local, openai, anthropic, deepseek, google, kimi
# → API keys go in .env.secrets
DEFAULT_LLM_PROVIDER=local
# Local LLM (Ollama)
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_MODEL=llama3.3:latest
# --- API KEYS (OPTIONAL) ---
# Fill only the ones you intend to use.
ANTHROPIC_API_KEY=
DEEPSEEK_API_KEY=
GOOGLE_API_KEY=
KIMI_API_KEY=
OPENAI_API_KEY=
# --- RAG ENGINE ---
# Enable/Disable the Retrieval Augmented Generation system
RAG_ENABLED=TRUE
RAG_API_URL=http://rag_api:8000
RAG_API_PORT=8000
+878
View File
@@ -0,0 +1,878 @@
#=====================================================================#
# LibreChat Configuration #
#=====================================================================#
# Please refer to the reference documentation for assistance #
# with configuring your LibreChat environment. #
# #
# https://www.librechat.ai/docs/configuration/dotenv #
#=====================================================================#
#==================================================#
# Server Configuration #
#==================================================#
HOST=localhost
PORT=3080
MONGO_URI=mongodb://127.0.0.1:27017/LibreChat
#The maximum number of connections in the connection pool. */
MONGO_MAX_POOL_SIZE=
#The minimum number of connections in the connection pool. */
MONGO_MIN_POOL_SIZE=
#The maximum number of connections that may be in the process of being established concurrently by the connection pool. */
MONGO_MAX_CONNECTING=
#The maximum number of milliseconds that a connection can remain idle in the pool before being removed and closed. */
MONGO_MAX_IDLE_TIME_MS=
#The maximum time in milliseconds that a thread can wait for a connection to become available. */
MONGO_WAIT_QUEUE_TIMEOUT_MS=
# Set to false to disable automatic index creation for all models associated with this connection. */
MONGO_AUTO_INDEX=
# Set to `false` to disable Mongoose automatically calling `createCollection()` on every model created on this connection. */
MONGO_AUTO_CREATE=
DOMAIN_CLIENT=http://localhost:3080
DOMAIN_SERVER=http://localhost:3080
NO_INDEX=true
# Use the address that is at most n number of hops away from the Express application.
# req.socket.remoteAddress is the first hop, and the rest are looked for in the X-Forwarded-For header from right to left.
# A value of 0 means that the first untrusted address would be req.socket.remoteAddress, i.e. there is no reverse proxy.
# Defaulted to 1.
TRUST_PROXY=1
# Minimum password length for user authentication
# Default: 8
# Note: When using LDAP authentication, you may want to set this to 1
# to bypass local password validation, as LDAP servers handle their own
# password policies.
# MIN_PASSWORD_LENGTH=8
# When enabled, the app will continue running after encountering uncaught exceptions
# instead of exiting the process. Not recommended for production unless necessary.
# CONTINUE_ON_UNCAUGHT_EXCEPTION=false
#===============#
# JSON Logging #
#===============#
# Use when process console logs in cloud deployment like GCP/AWS
CONSOLE_JSON=false
#===============#
# Debug Logging #
#===============#
DEBUG_LOGGING=true
DEBUG_CONSOLE=false
# Set to true to enable agent debug logging
AGENT_DEBUG_LOGGING=false
# Enable memory diagnostics (logs heap/RSS snapshots every 60s, auto-enabled with --inspect)
# MEM_DIAG=true
#=============#
# Permissions #
#=============#
# UID=1000
# GID=1000
#==============#
# Node Options #
#==============#
# NOTE: NODE_MAX_OLD_SPACE_SIZE is NOT recognized by Node.js directly.
# This variable is used as a build argument for Docker or CI/CD workflows,
# and is NOT used by Node.js to set the heap size at runtime.
# To configure Node.js memory, use NODE_OPTIONS, e.g.:
# NODE_OPTIONS="--max-old-space-size=6144"
# See: https://nodejs.org/api/cli.html#--max-old-space-sizesize-in-mib
NODE_MAX_OLD_SPACE_SIZE=6144
#===============#
# Configuration #
#===============#
# Use an absolute path, a relative path, or a URL
# CONFIG_PATH="/alternative/path/to/librechat.yaml"
#==================#
# Langfuse Tracing #
#==================#
# Get Langfuse API keys for your project from the project settings page: https://cloud.langfuse.com
# LANGFUSE_PUBLIC_KEY=
# LANGFUSE_SECRET_KEY=
# LANGFUSE_BASE_URL=
#===================================================#
# Endpoints #
#===================================================#
# ENDPOINTS=openAI,assistants,azureOpenAI,google,anthropic
PROXY=
#===================================#
# Known Endpoints - librechat.yaml #
#===================================#
# https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints
# ANYSCALE_API_KEY=
# APIPIE_API_KEY=
# COHERE_API_KEY=
# DEEPSEEK_API_KEY=
# DATABRICKS_API_KEY=
# FIREWORKS_API_KEY=
# GROQ_API_KEY=
# HUGGINGFACE_TOKEN=
# MISTRAL_API_KEY=
# OPENROUTER_KEY=
# PERPLEXITY_API_KEY=
# SHUTTLEAI_API_KEY=
# TOGETHERAI_API_KEY=
# UNIFY_API_KEY=
# XAI_API_KEY=
#============#
# Anthropic #
#============#
ANTHROPIC_API_KEY=user_provided
# ANTHROPIC_MODELS=claude-sonnet-4-6,claude-opus-4-6,claude-opus-4-20250514,claude-sonnet-4-20250514,claude-3-7-sonnet-20250219,claude-3-5-sonnet-20241022,claude-3-5-haiku-20241022,claude-3-opus-20240229,claude-3-sonnet-20240229,claude-3-haiku-20240307
# ANTHROPIC_REVERSE_PROXY=
# Set to true to use Anthropic models through Google Vertex AI instead of direct API
# ANTHROPIC_USE_VERTEX=
# ANTHROPIC_VERTEX_REGION=us-east5
#============#
# Azure #
#============#
# Note: these variables are DEPRECATED
# Use the `librechat.yaml` configuration for `azureOpenAI` instead
# You may also continue to use them if you opt out of using the `librechat.yaml` configuration
# AZURE_OPENAI_DEFAULT_MODEL=gpt-3.5-turbo # Deprecated
# AZURE_OPENAI_MODELS=gpt-3.5-turbo,gpt-4 # Deprecated
# AZURE_USE_MODEL_AS_DEPLOYMENT_NAME=TRUE # Deprecated
# AZURE_API_KEY= # Deprecated
# AZURE_OPENAI_API_INSTANCE_NAME= # Deprecated
# AZURE_OPENAI_API_DEPLOYMENT_NAME= # Deprecated
# AZURE_OPENAI_API_VERSION= # Deprecated
# AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME= # Deprecated
# AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME= # Deprecated
#=================#
# AWS Bedrock #
#=================#
# BEDROCK_AWS_DEFAULT_REGION=us-east-1 # A default region must be provided
# BEDROCK_AWS_ACCESS_KEY_ID=someAccessKey
# BEDROCK_AWS_SECRET_ACCESS_KEY=someSecretAccessKey
# BEDROCK_AWS_SESSION_TOKEN=someSessionToken
# Note: This example list is not meant to be exhaustive. If omitted, all known, supported model IDs will be included for you.
# BEDROCK_AWS_MODELS=anthropic.claude-sonnet-4-6,anthropic.claude-opus-4-6-v1,anthropic.claude-3-5-sonnet-20240620-v1:0,meta.llama3-1-8b-instruct-v1:0
# Cross-region inference model IDs: us.anthropic.claude-sonnet-4-6,us.anthropic.claude-opus-4-6-v1,global.anthropic.claude-opus-4-6-v1
# See all Bedrock model IDs here: https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids.html#model-ids-arns
# Notes on specific models:
# The following models are not support due to not supporting streaming:
# ai21.j2-mid-v1
# The following models are not support due to not supporting conversation history:
# ai21.j2-ultra-v1, cohere.command-text-v14, cohere.command-light-text-v14
#============#
# Google #
#============#
GOOGLE_KEY=user_provided
# GOOGLE_REVERSE_PROXY=
# Some reverse proxies do not support the X-goog-api-key header, uncomment to pass the API key in Authorization header instead.
# GOOGLE_AUTH_HEADER=true
# Gemini API (AI Studio)
# GOOGLE_MODELS=gemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools,gemini-3.1-flash-lite-preview,gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash,gemini-2.0-flash-lite
# Vertex AI
# GOOGLE_MODELS=gemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools,gemini-3.1-flash-lite-preview,gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash-001,gemini-2.0-flash-lite-001
# GOOGLE_TITLE_MODEL=gemini-2.0-flash-lite-001
# Google Cloud region for Vertex AI (used by both chat and image generation)
# GOOGLE_LOC=us-central1
# Alternative region env var for Gemini Image Generation
# GOOGLE_CLOUD_LOCATION=global
# Vertex AI Service Account Configuration
# Path to your Google Cloud service account JSON file
# GOOGLE_SERVICE_KEY_FILE=/path/to/service-account.json
# Google Safety Settings
# NOTE: These settings apply to both Vertex AI and Gemini API (AI Studio)
#
# For Vertex AI:
# To use the BLOCK_NONE setting, you need either:
# (a) Access through an allowlist via your Google account team, or
# (b) Switch to monthly invoiced billing: https://cloud.google.com/billing/docs/how-to/invoiced-billing
#
# For Gemini API (AI Studio):
# BLOCK_NONE is available by default, no special account requirements.
#
# Available options: BLOCK_NONE, BLOCK_ONLY_HIGH, BLOCK_MEDIUM_AND_ABOVE, BLOCK_LOW_AND_ABOVE
#
# GOOGLE_SAFETY_SEXUALLY_EXPLICIT=BLOCK_ONLY_HIGH
# GOOGLE_SAFETY_HATE_SPEECH=BLOCK_ONLY_HIGH
# GOOGLE_SAFETY_HARASSMENT=BLOCK_ONLY_HIGH
# GOOGLE_SAFETY_DANGEROUS_CONTENT=BLOCK_ONLY_HIGH
# GOOGLE_SAFETY_CIVIC_INTEGRITY=BLOCK_ONLY_HIGH
#========================#
# Gemini Image Generation #
#========================#
# Gemini Image Generation Tool (for Agents)
# Supports multiple authentication methods in priority order:
# 1. User-provided API key (via GUI)
# 2. GEMINI_API_KEY env var (admin-configured)
# 3. GOOGLE_KEY env var (shared with Google chat endpoint)
# 4. Vertex AI service account (via GOOGLE_SERVICE_KEY_FILE)
# Option A: Use dedicated Gemini API key for image generation
# GEMINI_API_KEY=your-gemini-api-key
# Vertex AI model for image generation (defaults to gemini-2.5-flash-image)
# GEMINI_IMAGE_MODEL=gemini-2.5-flash-image
#============#
# OpenAI #
#============#
OPENAI_API_KEY=user_provided
# OPENAI_MODELS=gpt-5,gpt-5-codex,gpt-5-mini,gpt-5-nano,o3-pro,o3,o4-mini,gpt-4.1,gpt-4.1-mini,gpt-4.1-nano,o3-mini,o1-pro,o1,gpt-4o,gpt-4o-mini
DEBUG_OPENAI=false
# TITLE_CONVO=false
# OPENAI_TITLE_MODEL=gpt-4o-mini
# OPENAI_SUMMARIZE=true
# OPENAI_SUMMARY_MODEL=gpt-4o-mini
# OPENAI_FORCE_PROMPT=true
# OPENAI_REVERSE_PROXY=
# OPENAI_ORGANIZATION=
#====================#
# Assistants API #
#====================#
ASSISTANTS_API_KEY=user_provided
# ASSISTANTS_BASE_URL=
# ASSISTANTS_MODELS=gpt-4o,gpt-4o-mini,gpt-3.5-turbo-0125,gpt-3.5-turbo-16k-0613,gpt-3.5-turbo-16k,gpt-3.5-turbo,gpt-4,gpt-4-0314,gpt-4-32k-0314,gpt-4-0613,gpt-3.5-turbo-0613,gpt-3.5-turbo-1106,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview
#==========================#
# Azure Assistants API #
#==========================#
# Note: You should map your credentials with custom variables according to your Azure OpenAI Configuration
# The models for Azure Assistants are also determined by your Azure OpenAI configuration.
# More info, including how to enable use of Assistants with Azure here:
# https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints/azure#using-assistants-with-azure
CREDS_KEY=f34be427ebb29de8d88c107a71546019685ed8b241d8f2ed00c3df97ad2566f0
CREDS_IV=e2341419ec3dd3d19b13a1a87fafcbfb
# Azure AI Search
#-----------------
AZURE_AI_SEARCH_SERVICE_ENDPOINT=
AZURE_AI_SEARCH_INDEX_NAME=
AZURE_AI_SEARCH_API_KEY=
AZURE_AI_SEARCH_API_VERSION=
AZURE_AI_SEARCH_SEARCH_OPTION_QUERY_TYPE=
AZURE_AI_SEARCH_SEARCH_OPTION_TOP=
AZURE_AI_SEARCH_SEARCH_OPTION_SELECT=
# OpenAI Image Tools Customization
#----------------
# IMAGE_GEN_OAI_API_KEY= # Create or reuse OpenAI API key for image generation tool
# IMAGE_GEN_OAI_BASEURL= # Custom OpenAI base URL for image generation tool
# IMAGE_GEN_OAI_AZURE_API_VERSION= # Custom Azure OpenAI deployments
# IMAGE_GEN_OAI_MODEL=gpt-image-1 # OpenAI image model (e.g., gpt-image-1, gpt-image-1.5)
# IMAGE_GEN_OAI_DESCRIPTION=
# IMAGE_GEN_OAI_DESCRIPTION_WITH_FILES=Custom description for image generation tool when files are present
# IMAGE_GEN_OAI_DESCRIPTION_NO_FILES=Custom description for image generation tool when no files are present
# IMAGE_EDIT_OAI_DESCRIPTION=Custom description for image editing tool
# IMAGE_GEN_OAI_PROMPT_DESCRIPTION=Custom prompt description for image generation tool
# IMAGE_EDIT_OAI_PROMPT_DESCRIPTION=Custom prompt description for image editing tool
# DALL·E
#----------------
# DALLE_API_KEY=
# DALLE3_API_KEY=
# DALLE2_API_KEY=
# DALLE3_SYSTEM_PROMPT=
# DALLE2_SYSTEM_PROMPT=
# DALLE_REVERSE_PROXY=
# DALLE3_BASEURL=
# DALLE2_BASEURL=
# DALL·E (via Azure OpenAI)
# Note: requires some of the variables above to be set
#----------------
# DALLE3_AZURE_API_VERSION=
# DALLE2_AZURE_API_VERSION=
# Flux
#-----------------
FLUX_API_BASE_URL=https://api.us1.bfl.ai
# FLUX_API_BASE_URL = 'https://api.bfl.ml';
# Get your API key at https://api.us1.bfl.ai/auth/profile
# FLUX_API_KEY=
# Google
#-----------------
GOOGLE_SEARCH_API_KEY=
GOOGLE_CSE_ID=
# Stable Diffusion
#-----------------
SD_WEBUI_URL=http://host.docker.internal:7860
# Tavily
#-----------------
TAVILY_API_KEY=
# Traversaal
#-----------------
TRAVERSAAL_API_KEY=
# WolframAlpha
#-----------------
WOLFRAM_APP_ID=
# Zapier
#-----------------
ZAPIER_NLA_API_KEY=
#==================================================#
# Search #
#==================================================#
SEARCH=true
MEILI_NO_ANALYTICS=true
MEILI_HOST=http://0.0.0.0:7700
MEILI_MASTER_KEY=DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFCt
# Optional: Disable indexing, useful in a multi-node setup
# where only one instance should perform an index sync.
# MEILI_NO_SYNC=true
#==================================================#
# Speech to Text & Text to Speech #
#==================================================#
STT_API_KEY=
TTS_API_KEY=
#==================================================#
# RAG #
#==================================================#
# More info: https://www.librechat.ai/docs/configuration/rag_api
# RAG_OPENAI_BASEURL=
# RAG_OPENAI_API_KEY=
# RAG_USE_FULL_CONTEXT=
# EMBEDDINGS_PROVIDER=openai
# EMBEDDINGS_MODEL=text-embedding-3-small
#===================================================#
# User System #
#===================================================#
#========================#
# Moderation #
#========================#
OPENAI_MODERATION=false
OPENAI_MODERATION_API_KEY=
# OPENAI_MODERATION_REVERSE_PROXY=
BAN_VIOLATIONS=true
BAN_DURATION=1000 * 60 * 60 * 2
BAN_INTERVAL=20
LOGIN_VIOLATION_SCORE=1
REGISTRATION_VIOLATION_SCORE=1
CONCURRENT_VIOLATION_SCORE=1
MESSAGE_VIOLATION_SCORE=1
NON_BROWSER_VIOLATION_SCORE=20
TTS_VIOLATION_SCORE=0
STT_VIOLATION_SCORE=0
FORK_VIOLATION_SCORE=0
IMPORT_VIOLATION_SCORE=0
FILE_UPLOAD_VIOLATION_SCORE=0
LOGIN_MAX=7
LOGIN_WINDOW=5
REGISTER_MAX=5
REGISTER_WINDOW=60
LIMIT_CONCURRENT_MESSAGES=true
CONCURRENT_MESSAGE_MAX=2
LIMIT_MESSAGE_IP=true
MESSAGE_IP_MAX=40
MESSAGE_IP_WINDOW=1
LIMIT_MESSAGE_USER=false
MESSAGE_USER_MAX=40
MESSAGE_USER_WINDOW=1
ILLEGAL_MODEL_REQ_SCORE=5
#========================#
# Balance #
#========================#
# CHECK_BALANCE=false
# START_BALANCE=20000 # note: the number of tokens that will be credited after registration.
#========================#
# Registration and Login #
#========================#
ALLOW_EMAIL_LOGIN=true
ALLOW_REGISTRATION=true
ALLOW_SOCIAL_LOGIN=false
ALLOW_SOCIAL_REGISTRATION=false
ALLOW_PASSWORD_RESET=false
# ALLOW_ACCOUNT_DELETION=true # note: enabled by default if omitted/commented out
ALLOW_UNVERIFIED_EMAIL_LOGIN=true
SESSION_EXPIRY=1000 * 60 * 15
REFRESH_TOKEN_EXPIRY=(1000 * 60 * 60 * 24) * 7
JWT_SECRET=16f8c0ef4a5d391b26034086c628469d3f9f497f08163ab9b40137092f2909ef
JWT_REFRESH_SECRET=eaa5191f2914e30b9387fd84e254e4ba6fc51b4654968a9b0803b456a54b8418
# Discord
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_CALLBACK_URL=/oauth/discord/callback
# Facebook
FACEBOOK_CLIENT_ID=
FACEBOOK_CLIENT_SECRET=
FACEBOOK_CALLBACK_URL=/oauth/facebook/callback
# GitHub
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_CALLBACK_URL=/oauth/github/callback
# GitHub Enterprise
# GITHUB_ENTERPRISE_BASE_URL=
# GITHUB_ENTERPRISE_USER_AGENT=
# Google
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=/oauth/google/callback
# Apple
APPLE_CLIENT_ID=
APPLE_TEAM_ID=
APPLE_KEY_ID=
APPLE_PRIVATE_KEY_PATH=
APPLE_CALLBACK_URL=/oauth/apple/callback
# OpenID
OPENID_CLIENT_ID=
OPENID_CLIENT_SECRET=
OPENID_ISSUER=
OPENID_SESSION_SECRET=
OPENID_SCOPE="openid profile email"
OPENID_CALLBACK_URL=/oauth/openid/callback
OPENID_REQUIRED_ROLE=
OPENID_REQUIRED_ROLE_TOKEN_KIND=
OPENID_REQUIRED_ROLE_PARAMETER_PATH=
OPENID_ADMIN_ROLE=
OPENID_ADMIN_ROLE_PARAMETER_PATH=
OPENID_ADMIN_ROLE_TOKEN_KIND=
# Set to determine which user info property returned from OpenID Provider to store as the User's username
OPENID_USERNAME_CLAIM=
# Set to determine which user info property returned from OpenID Provider to store as the User's name
OPENID_NAME_CLAIM=
# Set to determine which user info claim to use as the email/identifier for user matching (e.g., "upn" for Entra ID)
# When not set, defaults to: email -> preferred_username -> upn
OPENID_EMAIL_CLAIM=
# Optional audience parameter for OpenID authorization requests
OPENID_AUDIENCE=
OPENID_BUTTON_LABEL=
OPENID_IMAGE_URL=
# Set to true to automatically redirect to the OpenID provider when a user visits the login page
# This will bypass the login form completely for users, only use this if OpenID is your only authentication method
OPENID_AUTO_REDIRECT=false
# Set to true to use PKCE (Proof Key for Code Exchange) for OpenID authentication
OPENID_USE_PKCE=false
#Set to true to reuse openid tokens for authentication management instead of using the mongodb session and the custom refresh token.
OPENID_REUSE_TOKENS=
#By default, signing key verification results are cached in order to prevent excessive HTTP requests to the JWKS endpoint.
#If a signing key matching the kid is found, this will be cached and the next time this kid is requested the signing key will be served from the cache.
#Default is true.
OPENID_JWKS_URL_CACHE_ENABLED=
OPENID_JWKS_URL_CACHE_TIME= # 600000 ms eq to 10 minutes leave empty to disable caching
#Set to true to trigger token exchange flow to acquire access token for the userinfo endpoint.
OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED=
OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE="user.read" # example for Scope Needed for Microsoft Graph API
# Set to true to use the OpenID Connect end session endpoint for logout
OPENID_USE_END_SESSION_ENDPOINT=
# URL to redirect to after OpenID logout (defaults to ${DOMAIN_CLIENT}/login)
OPENID_POST_LOGOUT_REDIRECT_URI=
# Maximum logout URL length before using logout_hint instead of id_token_hint (default: 2000)
OPENID_MAX_LOGOUT_URL_LENGTH=
#========================#
# SharePoint Integration #
#========================#
# Requires Entra ID (OpenID) authentication to be configured
# Enable SharePoint file picker in chat and agent panels
# ENABLE_SHAREPOINT_FILEPICKER=true
# SharePoint tenant base URL (e.g., https://yourtenant.sharepoint.com)
# SHAREPOINT_BASE_URL=https://yourtenant.sharepoint.com
# Microsoft Graph API And SharePoint scopes for file picker
# SHAREPOINT_PICKER_SHAREPOINT_SCOPE==https://yourtenant.sharepoint.com/AllSites.Read
# SHAREPOINT_PICKER_GRAPH_SCOPE=Files.Read.All
#========================#
# SAML
# Note: If OpenID is enabled, SAML authentication will be automatically disabled.
SAML_ENTRY_POINT=
SAML_ISSUER=
SAML_CERT=
SAML_CALLBACK_URL=/oauth/saml/callback
SAML_SESSION_SECRET=
# Attribute mappings (optional)
SAML_EMAIL_CLAIM=
SAML_USERNAME_CLAIM=
SAML_GIVEN_NAME_CLAIM=
SAML_FAMILY_NAME_CLAIM=
SAML_PICTURE_CLAIM=
SAML_NAME_CLAIM=
# Logint buttion settings (optional)
SAML_BUTTON_LABEL=
SAML_IMAGE_URL=
# Whether the SAML Response should be signed.
# - If "true", the entire `SAML Response` will be signed.
# - If "false" or unset, only the `SAML Assertion` will be signed (default behavior).
# SAML_USE_AUTHN_RESPONSE_SIGNED=
#===============================================#
# Microsoft Graph API / Entra ID Integration #
#===============================================#
# Enable Entra ID people search integration in permissions/sharing system
# When enabled, the people picker will search both local database and Entra ID
USE_ENTRA_ID_FOR_PEOPLE_SEARCH=false
# When enabled, entra id groups owners will be considered as members of the group
ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS=false
# Microsoft Graph API scopes needed for people/group search
# Default scopes provide access to user profiles and group memberships
OPENID_GRAPH_SCOPES=User.Read,People.Read,GroupMember.Read.All
# LDAP
LDAP_URL=
LDAP_BIND_DN=
LDAP_BIND_CREDENTIALS=
LDAP_USER_SEARCH_BASE=
#LDAP_SEARCH_FILTER="mail="
LDAP_CA_CERT_PATH=
# LDAP_TLS_REJECT_UNAUTHORIZED=
# LDAP_STARTTLS=
# LDAP_LOGIN_USES_USERNAME=true
# LDAP_ID=
# LDAP_USERNAME=
# LDAP_EMAIL=
# LDAP_FULL_NAME=
#========================#
# Email Password Reset #
#========================#
EMAIL_SERVICE=
EMAIL_HOST=
EMAIL_PORT=25
EMAIL_ENCRYPTION=
EMAIL_ENCRYPTION_HOSTNAME=
EMAIL_ALLOW_SELFSIGNED=
# Leave both empty for SMTP servers that do not require authentication
EMAIL_USERNAME=
EMAIL_PASSWORD=
EMAIL_FROM_NAME=
EMAIL_FROM=noreply@librechat.ai
#========================#
# Mailgun API #
#========================#
# MAILGUN_API_KEY=your-mailgun-api-key
# MAILGUN_DOMAIN=mg.yourdomain.com
# EMAIL_FROM=noreply@yourdomain.com
# EMAIL_FROM_NAME="LibreChat"
# # Optional: For EU region
# MAILGUN_HOST=https://api.eu.mailgun.net
#========================#
# Firebase CDN #
#========================#
FIREBASE_API_KEY=
FIREBASE_AUTH_DOMAIN=
FIREBASE_PROJECT_ID=
FIREBASE_STORAGE_BUCKET=
FIREBASE_MESSAGING_SENDER_ID=
FIREBASE_APP_ID=
#========================#
# S3 AWS Bucket #
#========================#
AWS_ENDPOINT_URL=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=
AWS_BUCKET_NAME=
# Required for path-style S3-compatible providers (MinIO, Hetzner, Backblaze B2, etc.)
# that don't support virtual-hosted-style URLs (bucket.endpoint). Not needed for AWS S3.
# AWS_FORCE_PATH_STYLE=false
#========================#
# Azure Blob Storage #
#========================#
AZURE_STORAGE_CONNECTION_STRING=
AZURE_STORAGE_PUBLIC_ACCESS=false
AZURE_CONTAINER_NAME=files
#========================#
# Shared Links #
#========================#
ALLOW_SHARED_LINKS=true
# Allows unauthenticated access to shared links. Defaults to false (auth required) if not set.
ALLOW_SHARED_LINKS_PUBLIC=false
#==============================#
# Static File Cache Control #
#==============================#
# Leave commented out to use defaults: 1 day (86400 seconds) for s-maxage and 2 days (172800 seconds) for max-age
# NODE_ENV must be set to production for these to take effect
# STATIC_CACHE_MAX_AGE=172800
# STATIC_CACHE_S_MAX_AGE=86400
# If you have another service in front of your LibreChat doing compression, disable express based compression here
# DISABLE_COMPRESSION=true
# If you have gzipped version of uploaded image images in the same folder, this will enable gzip scan and serving of these images
# Note: The images folder will be scanned on startup and a ma kept in memory. Be careful for large number of images.
# ENABLE_IMAGE_OUTPUT_GZIP_SCAN=true
#===================================================#
# UI #
#===================================================#
APP_TITLE=LibreChat
# CUSTOM_FOOTER="My custom footer"
HELP_AND_FAQ_URL=https://librechat.ai
# SHOW_BIRTHDAY_ICON=true
# Google tag manager id
#ANALYTICS_GTM_ID=user provided google tag manager id
# limit conversation file imports to a certain number of bytes in size to avoid the container
# maxing out memory limitations by unremarking this line and supplying a file size in bytes
# such as the below example of 250 mib
# CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES=262144000
#===============#
# REDIS Options #
#===============#
# Enable Redis for caching and session storage
# USE_REDIS=true
# Enable Redis for resumable LLM streams (defaults to USE_REDIS value if not set)
# Set to false to use in-memory storage for streams while keeping Redis for other caches
# USE_REDIS_STREAMS=true
# Single Redis instance
# REDIS_URI=redis://127.0.0.1:6379
# Redis cluster (multiple nodes)
# REDIS_URI=redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
# Redis with TLS/SSL encryption and CA certificate
# REDIS_URI=rediss://127.0.0.1:6380
# REDIS_CA=/path/to/ca-cert.pem
# Elasticache may need to use an alternate dnsLookup for TLS connections. see "Special Note: Aws Elasticache Clusters with TLS" on this webpage: https://www.npmjs.com/package/ioredis
# Enable alternative dnsLookup for redis
# REDIS_USE_ALTERNATIVE_DNS_LOOKUP=true
# Redis authentication (if required)
# REDIS_USERNAME=your_redis_username
# REDIS_PASSWORD=your_redis_password
# Redis key prefix configuration
# Use environment variable name for dynamic prefix (recommended for cloud deployments)
# REDIS_KEY_PREFIX_VAR=K_REVISION
# Or use static prefix directly
# REDIS_KEY_PREFIX=librechat
# Redis connection limits
# REDIS_MAX_LISTENERS=40
# Redis ping interval in seconds (0 = disabled, >0 = enabled)
# When set to a positive integer, Redis clients will ping the server at this interval to keep connections alive
# When unset or 0, no pinging is performed (recommended for most use cases)
# REDIS_PING_INTERVAL=300
# Force specific cache namespaces to use in-memory storage even when Redis is enabled
# Comma-separated list of CacheKeys
# Defaults to CONFIG_STORE,APP_CONFIG so YAML-derived config stays per-container (safe for blue/green deployments)
# Set to empty string to force all namespaces through Redis: FORCED_IN_MEMORY_CACHE_NAMESPACES=
# FORCED_IN_MEMORY_CACHE_NAMESPACES=CONFIG_STORE,APP_CONFIG
# Leader Election Configuration (for multi-instance deployments with Redis)
# Duration in seconds that the leader lease is valid before it expires (default: 25)
# LEADER_LEASE_DURATION=25
# Interval in seconds at which the leader renews its lease (default: 10)
# LEADER_RENEW_INTERVAL=10
# Maximum number of retry attempts when renewing the lease fails (default: 3)
# LEADER_RENEW_ATTEMPTS=3
# Delay in seconds between retry attempts when renewing the lease (default: 0.5)
# LEADER_RENEW_RETRY_DELAY=0.5
#==================================================#
# Others #
#==================================================#
# You should leave the following commented out #
# NODE_ENV=
# E2E_USER_EMAIL=
# E2E_USER_PASSWORD=
#=====================================================#
# Cache Headers #
#=====================================================#
# Headers that control caching of the index.html #
# Default configuration prevents caching to ensure #
# users always get the latest version. Customize #
# only if you understand caching implications. #
# INDEX_CACHE_CONTROL=no-cache, no-store, must-revalidate
# INDEX_PRAGMA=no-cache
# INDEX_EXPIRES=0
# no-cache: Forces validation with server before using cached version
# no-store: Prevents storing the response entirely
# must-revalidate: Prevents using stale content when offline
#=====================================================#
# OpenWeather #
#=====================================================#
OPENWEATHER_API_KEY=
#====================================#
# LibreChat Code Interpreter API #
#====================================#
# https://code.librechat.ai
# LIBRECHAT_CODE_API_KEY=your-key
#======================#
# Web Search #
#======================#
# Note: All of the following variable names can be customized.
# Omit values to allow user to provide them.
# For more information on configuration values, see:
# https://librechat.ai/docs/features/web_search
# Search Provider (Required)
# SERPER_API_KEY=your_serper_api_key
# Scraper (Required)
# FIRECRAWL_API_KEY=your_firecrawl_api_key
# Optional: Custom Firecrawl API URL
# FIRECRAWL_API_URL=your_firecrawl_api_url
# Reranker (Required)
# JINA_API_KEY=your_jina_api_key
# or
# COHERE_API_KEY=your_cohere_api_key
#======================#
# MCP Configuration #
#======================#
# Treat 401/403 responses as OAuth requirement when no oauth metadata found
# MCP_OAUTH_ON_AUTH_ERROR=true
# Timeout for OAuth detection requests in milliseconds
# MCP_OAUTH_DETECTION_TIMEOUT=5000
# Cache connection status checks for this many milliseconds to avoid expensive verification
# MCP_CONNECTION_CHECK_TTL=60000
# Skip code challenge method validation (e.g., for AWS Cognito that supports S256 but doesn't advertise it)
# When set to true, forces S256 code challenge even if not advertised in .well-known/openid-configuration
# MCP_SKIP_CODE_CHALLENGE_CHECK=false
# Circuit breaker: max connect/disconnect cycles before tripping (per server)
# MCP_CB_MAX_CYCLES=7
# Circuit breaker: sliding window (ms) for counting cycles
# MCP_CB_CYCLE_WINDOW_MS=45000
# Circuit breaker: cooldown (ms) after the cycle breaker trips
# MCP_CB_CYCLE_COOLDOWN_MS=15000
# Circuit breaker: max consecutive failed connection rounds before backoff
# MCP_CB_MAX_FAILED_ROUNDS=3
# Circuit breaker: sliding window (ms) for counting failed rounds
# MCP_CB_FAILED_WINDOW_MS=120000
# Circuit breaker: base backoff (ms) after failed round threshold is reached
# MCP_CB_BASE_BACKOFF_MS=30000
# Circuit breaker: max backoff cap (ms) for exponential backoff
# MCP_CB_MAX_BACKOFF_MS=300000
+8
View File
@@ -0,0 +1,8 @@
# Auto-generated from pyproject.toml — do not edit manually
ALFRED_VERSION=0.1.7
PYTHON_VERSION=3.14.3
IMAGE_NAME=alfred_media_organizer
SERVICE_NAME=alfred
LIBRECHAT_VERSION=v0.8.4
RAG_VERSION=v0.7.3
UV_VERSION=0.11.6
+12 -1
View File
@@ -55,13 +55,24 @@ coverage.xml
Thumbs.db
# Secrets
.env
.env.secrets
# Backup files
*.backup
*.bak
env_backup/
# Application data dir
data/*
# Application logs
logs/*
# Documentation folder
docs/
# .md files (project-level Markdown is brol-y; allow-list the ones we track)
*.md
!CHANGELOG.md
#
+344
View File
@@ -0,0 +1,344 @@
# Changelog
All notable changes to Alfred are documented here.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Alfred is not yet on SemVer — entries are grouped by **dated work blocks** instead
of release numbers. Granularity targets behavioral or API-visible changes; refer
to `git log` for commit-level detail.
Sections used per block: **Added** / **Changed** / **Deprecated** / **Removed** /
**Fixed** / **Internal** (for tech-debt and refactor noise that doesn't affect
callers).
---
## [Unreleased]
### Added
- **Real-world release fixtures** under `tests/fixtures/releases/{easy,shitty,path_of_pain}/`,
each documenting an expected `ParsedRelease` plus the future `routing`
(library / torrents / seed_hardlinks) for the upcoming `organize_media`
refactor. EASY bucket seeded with 5 cases (movie, single-episode, season
pack, movie + noise, YTS bracket-heavy). SHITTY bucket seeded with 15
anti-regression cases covering: 3-level INTEGRALE hierarchy (Angel),
French custom titles (Buffy, La Nuit au Musée, Chérie j'ai agrandi),
multi-episode chain `S14E09E10E11` (Archer, captures E11 loss),
lowercase `s01e01` (Notre Planète), `NxNN` with ` - ` separators
(Vinyl, captures dash artifact), title-with-year-suffix (Deutschland.83),
season-range `S01-06` (Tatortreiniger, captures movie misclassification),
bare folder name (Jurassic Park,
media_type=unknown), apostrophe-in-name (Honey Don't, captures full AI-path
degeneration), SUBS-tag movie (Hook), space separators (Predator Badlands,
captures group=UNKNOWN), subs-only release (Westworld S04).
PATH OF PAIN bucket seeded with 10 worst-case fixtures covering:
UTF-8 wide pipe yt-dlp slug (Khruangbin), 3-show franchise box-set
with double season range and parens-wrapped tech (Deutschland 83-86-89,
captures `group=S03` misdetection), accented chars in title (Chérie
BéBé with VFF), 8-word stand-up comedy title (Jimmy Carr), site-tag
prefix + XviD (OxTorrent), episode title + air-date silently lost
(Prodiges), full-chaos apostrophe + spaces + Blu-ray dash + 1080i +
multi-word audio codec (The Prodigy, full AI-path degeneration),
yt-dlp YouTube ID glued to year (Sleaford Mods), bilingual `[FR-EN]`
tag mistaken for group (Super Mario Bros), COMPLETE + S01-S07 range +
REPACK + HEVC (Gilmore Girls, the well-behaved exception).
Parametrized over `tests/domain/test_release_fixtures.py` for anti-regression.
- **`NxNN` alt season/episode form supported** by `parse_release`. Releases like
`Show.1x05.720p.HDTV.x264-GRP` and `Show.2x07x08.1080p.WEB.x265-GRP` (multi-ep
alt form) now parse as TV shows.
- **`alfred/knowledge/release/separators.yaml`** declares the token separators
used by the release-name tokenizer (`.`, ` `, `[`, `]`, `(`, `)`, `_`). New
conventions can be added without code changes. The canonical `.` is always
present even if missing from YAML.
### Changed
- **`parse_release` tokenizer is now data-driven**: it splits on any character
listed in `separators.yaml` (regex character class) instead of `name.split(".")`.
This makes YTS-style releases (`The Father (2020) [1080p] [WEBRip] [5.1] [YTS.MX]`),
space-separated names (`Inception 2010 1080p BluRay x264-GROUP`), and
underscore-separated names parse correctly via the direct path — no more
fallback through sanitization.
- **`parse_release` flow simplified**: site-tag extraction always runs first
(so `parse_path == "sanitized"` now reliably indicates a stripped `[tag]`),
then well-formedness is checked only against truly forbidden chars
(anything not in the configured separator set).
- **ISO 639-2/B is now the canonical language code project-wide** (was a mix of
639-1 and 639-2/T):
- `SubtitlePreferences.languages` default is now `["fre", "eng"]` (was
`["fr", "en"]`). Old LTM files are not auto-migrated — delete
`data/memory/ltm.json` to regenerate with the new defaults.
- Subtitle output filenames are now `{iso639_2b}.srt` (e.g. `fre.srt`,
`fre.sdh.srt`). Existing `fr.srt` files are still **read** correctly
(recognized as French via alias) but new files are written canonically.
- `Language` value object docstring corrected: it has always stored 639-2/B
(matching what ffprobe emits), not 639-2/T as previously documented.
- **`MovieService.validate_movie_file` minimum size is now configurable** via
`settings.min_movie_size_bytes` (default unchanged: 100 MB). Constructor
accepts an optional `min_movie_size_bytes` override for tests.
- **`SubtitleKnowledgeBase` delegates language lookup to `LanguageRegistry`**
rather than duplicating tokens. `subtitles.yaml` now only declares
subtitle-specific tokens (e.g. `vostfr`, `vf`, `vff`) under a new
`language_tokens` section.
### Removed
- **`alfred/domain/tv_shows/services.py`** and **`alfred/domain/movies/services.py`**
deleted entirely. They held fossil parsers (`parse_episode_filename`,
`extract_movie_metadata`, …) with zero production callers — superseded by
`parse_release` as the single source of truth for release-name parsing.
Associated tests (`tests/domain/test_movies.py`, `tests/domain/test_tv_shows_service.py`)
removed as well.
- `_sanitize` and `_normalize` helpers in `alfred/domain/release/services.py`
the new tokenizer makes them redundant.
- `_LANG_KEYWORDS`, `_SDH_TOKENS`, `_FORCED_TOKENS`, `SUBTITLE_EXTENSIONS`
hardcoded dicts in `alfred/domain/subtitles/scanner.py` — all knowledge now
lives in YAML (CLAUDE.md compliance).
- `_MIN_MOVIE_SIZE_BYTES` module-level constant in
`alfred/domain/movies/services.py` — replaced by the new setting.
- Top-level `languages:` block in `subtitles.yaml` — superseded by
`language_tokens:` (subtitle-specific only) since iso_languages.yaml is the
canonical source.
### Fixed
- **`hi` token no longer marks a subtitle as SDH** (it conflicted with the
ISO 639-1 alias for Hindi). SDH is now detected only via `sdh`, `cc`, and
`hearing` tokens.
- `SubtitleKnowledgeBase` default rules used `"fra"` while
`iso_languages.yaml` exposes French as `"fre"` — preferred languages
defaults now match the canonical form.
### Internal
- **Domain I/O extraction** (`refactor/domain-io-extraction`): the domain
layer no longer performs subprocess calls, filesystem scans, or YAML
loading. Achieved in a series of focused commits:
- **Knowledge YAML loaders moved to infrastructure**:
`alfred/domain/release/knowledge.py`,
`alfred/domain/shared/knowledge/language_registry.py`, and
`alfred/domain/subtitles/knowledge/{base,loader}.py` relocated to
`alfred/infrastructure/knowledge/`. Re-exports were dropped — callers
import directly from the new location.
- **`MediaProber` and `FilesystemScanner` Protocol ports** introduced at
`alfred/domain/shared/ports/` with frozen-dataclass DTOs
(`SubtitleStreamInfo`, `FileEntry`). `SubtitleIdentifier` and
`PatternDetector` are now constructor-injected with concrete adapters
(`FfprobeMediaProber` wrapping `subprocess.run(ffprobe)` and
`PathlibFilesystemScanner` wrapping `pathlib`). No more direct
`subprocess`/`pathlib` usage from the subtitle domain services.
- **Live filesystem methods removed from VOs and entities**:
`FilePath.exists()` / `.is_file()` / `.is_dir()` deleted —
`FilePath` is now a pure address VO. `Movie.has_file()` and
`Episode.is_downloaded()` dropped. Callers either rely on a prior
detection step or use try/except over pre-checks (eliminates
TOCTOU races).
- **`SubtitlePlacer` moved to the application layer** at
`alfred/application/subtitles/placer.py` — it performs `os.link`
I/O, which doesn't belong in the domain. Pre-checks replaced with
try/except for `FileNotFoundError`/`FileExistsError`.
- **`SubtitleRuleSet.resolve()` no longer reaches into the knowledge
base**: the implicit `DEFAULT_RULES()` helper is gone, replaced by
an explicit `default_rules: SubtitleMatchingRules` parameter. The
`ManageSubtitles` use case loads defaults from the KB once and
passes them in.
- **`SubtitleKnowledge` Protocol port** at
`alfred/domain/subtitles/ports/knowledge.py` declares the read-only
query surface domain services consume (7 methods:
`known_extensions`, `format_for_extension`, `language_for_token`,
`is_known_lang_token`, `type_for_token`, `is_known_type_token`,
`patterns`). `SubtitleIdentifier` and `PatternDetector` depend on
this Protocol instead of the concrete `SubtitleKnowledgeBase` from
infrastructure — `domain/subtitles/` now has zero imports from
`infrastructure/`. The remaining domain → infra leak
(`domain/release/` loading separator YAML at import-time) is
documented in tech-debt and scheduled for its own branch.
- **`to_dot_folder_name(title)` helper** in
`alfred/domain/shared/value_objects.py` — extracts the
`re.sub(r"[^\w\s\.\-]", "", title).replace(" ", ".")` pattern that was
duplicated between `MovieTitle.normalized()` and `TVShow.get_folder_name()`.
- **`ParsedRelease.languages` uses `field(default_factory=list)`** instead of
a manual `__post_init__` that assigned `[]` via `object.__setattr__`.
- **`file_extensions.yaml` splits subtitle sidecars (`.srt`, `.sub`, `.idx`,
`.ass`, `.ssa`) into a dedicated `subtitle:` category** instead of lumping
them under `metadata:`. The `_METADATA_EXTENSIONS` set used by
`detect_media_type` remains the union of both (same behavior — subtitles
are still ignored when deciding the media type of a folder), but a new
`load_subtitle_extensions()` loader is now available for the subtitles
domain. Sematic clarity, no functional change.
- **`tv_shows/entities.py` module docstring** now shows the aggregate
ownership as an ASCII tree before the rule text — quicker visual scan
of the DDD structure.
- Removed backward-compat shims `_sanitise_for_fs` /
`_strip_episode_from_normalised` from `domain/release/value_objects.py`
(zero callers).
- Cleaned ruff warnings across the codebase: `subprocess.run` calls now pass
explicit `check=False` (PLW1510); lazy imports promoted to module top where
there was no cycle (PLC0415 in `manage_subtitles.py`, `placer.py`,
`qbittorrent/client.py`, `file_manager.py`); fixed module-level import
ordering (E402) in `language_registry.py` and `subtitles/knowledge/loader.py`;
removed unused locals (F841 / B007); replaced unnecessary set comprehension
with `set()` in `release/knowledge.py` (C416).
- Ruff config: ignore `PLR0911` / `PLR0912` (too-many-returns / too-many-branches)
globally — noisy on parser mappers and orchestrator use-cases where early-return
validation is essential complexity. Ignore `PLW0603` for the documented memory
singleton (`infrastructure/persistence/context.py`).
- **Release-knowledge DDD purification** (`refactor/domain-release-knowledge`):
the last domain → infrastructure leak (`domain/release/value_objects.py`
loading YAML at import-time) is gone. Achieved via:
- **`ReleaseKnowledge` Protocol port** at
`alfred/domain/release/ports/knowledge.py` declares the read-only query
surface release parsing needs (token sets for resolutions, sources, codecs,
languages, hdr extras; structured dicts for audio, video_meta, editions,
media_type_tokens; separators list; file-extension sets used by
application/infra callers; `sanitize_for_fs(text)` method).
- **`YamlReleaseKnowledge` adapter** at
`alfred/infrastructure/knowledge/release_kb.py` loads every YAML constant
once at construction. Builds an immutable `str.maketrans` translation
table for filesystem sanitization.
- **`parse_release(name, kb)`** takes the knowledge as an explicit
parameter — no more module-level YAML loading inside the domain. Every
internal helper (`_tokenize`, `_extract_tech`, `_extract_languages`,
`_extract_audio`, `_extract_video_meta`, `_extract_edition`,
`_extract_title`, `_infer_media_type`, `_is_well_formed`) takes `kb`.
- **`ParsedRelease` Option B**: sanitization happens once at parse time
and is stored on a new `title_sanitized: str` field. Builder methods
(`show_folder_name`, `season_folder_name`, `episode_filename`,
`movie_folder_name`, `movie_filename`) are now pure — they accept
already-sanitized `tmdb_title_safe` / `tmdb_episode_title_safe`
arguments. Callers at the use-case boundary sanitize TMDB strings
via `kb.sanitize_for_fs(...)` before passing them in.
- **All domain-knowledge constants removed from `value_objects.py`**:
`_RESOLUTIONS`, `_SOURCES`, `_CODECS`, `_AUDIO`, `_VIDEO_META`,
`_EDITIONS`, `_HDR_EXTRA`, `_MEDIA_TYPE_TOKENS`, `_LANGUAGE_TOKENS`,
`_FORBIDDEN_CHARS`, `_VIDEO_EXTENSIONS`, `_NON_VIDEO_EXTENSIONS`,
`_SUBTITLE_EXTENSIONS`, `_METADATA_EXTENSIONS`, `_WIN_FORBIDDEN_TABLE`,
and the `_sanitize_for_fs` helper. The domain module is now pure.
- **Application-layer KB singleton**: `resolve_destination.py` instantiates
a module-level `_KB: ReleaseKnowledge = YamlReleaseKnowledge()` and
threads it through every `parse_release(...)` call. The local
`_sanitize` helper and `_WIN_FORBIDDEN` regex were dropped in favor of
`_KB.sanitize_for_fs(...)`.
- **`detect_media_type(parsed, source_path, kb)` and
`find_video_file(path, kb)`** now take the knowledge explicitly
instead of importing `_*_EXTENSIONS` constants from the domain.
`agent/tools/filesystem.py::analyze_release` imports the application
KB singleton and passes it through.
---
## [2026-05-17] — TVShow & Movie aggregate refactor
Multi-phase refonte of the TV show domain into a real DDD aggregate, with
matching parity work on `Movie`, a language knowledge system, and the
`shared/media` restructure that supports both.
### Added
- **Language knowledge system** (`alfred/knowledge/iso_languages.yaml` + 42
languages including `und` for undetermined).
- `Language` value object (frozen dataclass) with `iso`, `english_name`,
`native_name`, `aliases`, and a `matches(raw)` cross-format helper.
- `LanguageRegistry` loader (`alfred/domain/shared/knowledge/`) merging
builtin + learned YAML. Not a singleton — the application layer
instantiates it.
- ISO 639-2/B is the canonical key; aliases cover 639-1, 639-2/T, English
name, native name, and common spellings.
- **`VideoTrack`** dataclass (`alfred/domain/shared/media/video.py`) with a
`resolution` property using width-priority bucket detection (handles
cinema/scope crops like 1920×960 → 1080p).
- **`shared/media/matching.py`** — `track_lang_matches` helper shared by
`Episode` and `Movie`. Implements the **"C+" contract** for language helpers:
- `Language` query → cross-format match via `Language.matches()`
- `str` query → case-insensitive direct comparison (no normalization)
- **TVShow aggregate composition**:
- `TVShow.seasons: dict[SeasonNumber, Season]`
- `Season.episodes: dict[EpisodeNumber, Episode]`
- `Season.expected_episodes` / `Season.aired_episodes` (split so collection
state can compare "owned vs aired today" without confusing in-flight
seasons with future ones)
- **Aggregate methods on `TVShow`**:
- `add_episode(ep)` — sole sanctioned mutation entry point (creates the
season if missing)
- `add_season(season)` — replaces a season wholesale
- `collection_status()``CollectionStatus.{EMPTY, PARTIAL, COMPLETE}`
- `is_complete_series()` — true iff `ENDED + COMPLETE`
- `missing_episodes()` — flat list of all aired-but-not-owned
`(season, episode)` pairs
- **`CollectionStatus`** enum (orthogonal to `ShowStatus`).
- **Episode track helpers** (`has_audio_in`, `has_subtitles_in`,
`has_forced_subs`, `audio_languages`, `subtitle_languages`), driven by
`Episode.audio_tracks` / `Episode.subtitle_tracks`.
- **Movie aggregate parity** — `Movie` now carries `audio_tracks` /
`subtitle_tracks` and exposes the same helpers as `Episode` (same C+
contract).
- **`CHANGELOG.md`** (this file).
### Changed
- **`shared/media_info.py` exploded into `shared/media/{audio,video,subtitle,info,matching}.py`.**
`MediaInfo` is now symmetric: every stream type is a `list[Track]`. Flat
accessors (`width`, `height`, `video_codec`, `resolution`) remain as
properties that read the first video track.
- **`MediaInfo.duration_seconds` / `bitrate_kbps`** moved from `VideoTrack` to
`MediaInfo` (file-level — they come from the ffprobe `format` block, not a
stream). Files without a video stream now correctly expose duration.
- **`ShowStatus.from_string`** extended to map TMDB strings (`Returning
Series`, `In Production`, `Pilot`, `Planned`, `Canceled`, `Cancelled`).
Comparison is whitespace-trimmed and case-insensitive.
- **`Season` / `Episode`** dropped their `show_imdb_id` back-references. They
are owned by `TVShow` and reached only through it.
- **`TVShow.seasons_count` and `episode_count`** are now `@property` (computed
from the dict) instead of stored ints.
- **`TVShowService.parse_episode_from_filename`** rewritten in string
operations (no regex). Supports `S01E05` / `s1e5` and `1x05` / `01x5` forms.
- **`TVShowService.find_next_episode`** now drives off
`show.missing_episodes()` instead of the hardcoded "max 50 episodes per
season" heuristic.
- **`TVShowService` constructor** no longer takes `season_repository` /
`episode_repository` — the aggregate persists in one block via
`TVShowRepository` only.
- **`SubtitleTrack` in `alfred.domain.subtitles.entities` renamed to
`SubtitleCandidate`.** Coexists with the `shared.media.SubtitleTrack`
ffprobe-view dataclass (different bounded contexts, kept separate
intentionally).
- **`tv_shows/services.py` `_VIDEO_EXTENSIONS`** now loaded from
`knowledge/release/file_extensions.yaml` via `load_video_extensions()`
(single source of truth).
- **`CLAUDE.md`** updated with three new policy sections:
- "Tests" — small updates OK during normal work, no mass-update sprees
- "Backwards-compatibility shims" — prefer clean migration over shims
- "Regex" — not forbidden, use judgment when string ops would be fragile
### Removed
- **Legacy `Season N Episode N` filename form** in
`TVShowService.parse_episode_from_filename`. It never appears in the release
names Alfred handles, and supporting it forced a regex.
- **`SeasonRepository` and `EpisodeRepository`** — only the aggregate root has
a repository (DDD rule: one repo per aggregate).
- **`shared/media_info.py`** compatibility shim — callers updated.
- **`SubtitleTrack` compatibility alias** in `subtitles.entities` — callers
updated to `SubtitleCandidate`.
### Fixed
- **`MediaInfo.duration_seconds` returns `None` on audio-only files** instead
of crashing through `primary_video.duration_seconds` (see the duration/bitrate
move under **Changed**).
- **`MediaOrganizer`** (`infrastructure/filesystem/organizer.py`) no longer
passes the removed `show_imdb_id` / `episode_count` kwargs when constructing
a `Season` for folder-name generation.
### Internal
- Test suite rewritten where the aggregate redesign broke fixtures:
`tests/domain/test_tv_shows.py` (69 tests), `tests/domain/test_media_info.py`
(rewritten for `VideoTrack`), `tests/application/test_enrich_from_probe.py`
(helper added), `tests/infrastructure/test_filesystem_extras.py` (fixtures),
`tests/domain/test_tv_shows_service.py` (find_next_episode driven by real
aggregate state).
- Subtitle services internal migration: `matcher.py`, `utils.py`, `placer.py`,
`identifier.py` updated to import `SubtitleCandidate`.
- Suite status at end of block: **1066 passed, 8 skipped, 0 failed**.
+30 -61
View File
@@ -2,46 +2,36 @@
# check=skip=InvalidDefaultArgInFrom
ARG PYTHON_VERSION
ARG PYTHON_VERSION_SHORT
ARG RUNNER
ARG UV_VERSION
# Stage 0: uv binary (workaround — --from doesn't support ARG expansion)
FROM ghcr.io/astral-sh/uv:${UV_VERSION} AS uv-bin
# ===========================================
# Stage 1: Builder
# ===========================================
FROM python:${PYTHON_VERSION}-slim-bookworm AS builder
# Re-declare ARGs after FROM to make them available in this stage
ARG RUNNER
# STFU - No need - Write logs asap
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
PYTHONUNBUFFERED=1 \
UV_PROJECT_ENVIRONMENT=/venv
# Install build dependencies (needs root)
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Install build dependencies
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends build-essential
# Install runner globally (needs root) - Save cache for future
RUN --mount=type=cache,target=/root/.cache/pip \
pip install $RUNNER
# Install uv globally
COPY --from=uv-bin /uv /usr/local/bin/uv
# Set working directory for dependency installation
WORKDIR /tmp
# Copy dependency files
COPY pyproject.toml poetry.lock* uv.lock* Makefile ./
COPY pyproject.toml uv.lock Makefile ./
# Install dependencies as root (to avoid permission issues with system packages)
RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=cache,target=/root/.cache/pypoetry \
--mount=type=cache,target=/root/.cache/uv \
if [ "$RUNNER" = "poetry" ]; then \
poetry config virtualenvs.create false && \
poetry install --only main --no-root; \
elif [ "$RUNNER" = "uv" ]; then \
uv pip install --system -r pyproject.toml; \
fi
# Install dependencies into /venv
RUN --mount=type=cache,target=/root/.cache/uv uv sync
COPY scripts/ ./scripts/
COPY .env.example ./
@@ -51,16 +41,7 @@ COPY .env.example ./
# ===========================================
FROM builder AS test
ARG RUNNER
RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=cache,target=/root/.cache/pypoetry \
--mount=type=cache,target=/root/.cache/uv \
if [ "$RUNNER" = "poetry" ]; then \
poetry install --no-root; \
elif [ "$RUNNER" = "uv" ]; then \
uv pip install --system -e .[dev]; \
fi
RUN --mount=type=cache,target=/root/.cache/uv uv sync --group dev
COPY alfred/ ./alfred
COPY scripts ./scripts
@@ -71,51 +52,39 @@ COPY tests/ ./tests
# ===========================================
FROM python:${PYTHON_VERSION}-slim-bookworm AS runtime
ARG PYTHON_VERSION_SHORT
# TODO: A-t-on encore besoin de toutes les clés ?
ENV LLM_PROVIDER=deepseek \
MEMORY_STORAGE_DIR=/data/memory \
PYTHONDONTWRITEBYTECODE=1 \
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/home/appuser \
PYTHONUNBUFFERED=1
PATH="/venv/bin:$PATH"
# Install runtime dependencies (needs root)
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Install runtime dependencies
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates
# Create non-root user
RUN useradd -m -u 1000 -s /bin/bash appuser
# Create data directories (needs root for /data)
# Create data directories
RUN mkdir -p /data /logs \
&& chown -R appuser:appuser /data /logs
# Switch to non-root user
USER appuser
# Set working directory (owned by appuser)
WORKDIR /home/appuser
# Copy Python packages from builder stage
COPY --from=builder /usr/local/lib/python${PYTHON_VERSION_SHORT}/site-packages /usr/local/lib/python${PYTHON_VERSION_SHORT}/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# Copy venv from builder stage
COPY --from=builder /venv /venv
# Copy application code (already owned by appuser)
# Copy application code
COPY --chown=appuser:appuser alfred/ ./alfred
COPY --chown=appuser:appuser scripts/ ./scripts
COPY --chown=appuser:appuser .env.example ./
COPY --chown=appuser:appuser pyproject.toml ./
# Create volumes for persistent data
VOLUME ["/data", "/logs"]
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/health', timeout=5).raise_for_status()" || exit 1
+27 -22
View File
@@ -1,6 +1,7 @@
.DEFAULT_GOAL := help
# --- Load Config from pyproject.toml ---
export
-include .env.make
# --- Profiles management ---
@@ -9,27 +10,29 @@ p ?= full
PROFILES_PARAM := COMPOSE_PROFILES=$(p)
# --- Commands ---
DOCKER_COMPOSE := docker compose
DOCKER_BUILD := docker build --no-cache \
DOCKER_COMPOSE := docker compose \
--env-file .env.alfred \
--env-file .env.secrets \
--env-file .env.make
DOCKER_BUILD := DOCKER_BUILDKIT=1 docker build \
--build-arg PYTHON_VERSION=$(PYTHON_VERSION) \
--build-arg PYTHON_VERSION_SHORT=$(PYTHON_VERSION_SHORT) \
--build-arg RUNNER=$(RUNNER)
--build-arg UV_VERSION=$(UV_VERSION)
# --- Phony ---
.PHONY: .env bootstrap up down restart logs ps shell build build-test install \
.PHONY: bootstrap up down restart logs ps shell build build-test install \
update install-hooks test coverage lint format clean major minor patch help
# --- Setup ---
.env .env.make:
.env.alfred .env.librechat .env.secrets .env.make:
@echo "Initializing environment..."
@python scripts/bootstrap.py \
@uv run python scripts/bootstrap.py \
&& echo "✓ Environment ready" \
|| (echo "✗ Environment setup failed" && exit 1)
bootstrap: .env .env.make
bootstrap: .env.alfred .env.librechat .env.secrets .env.make
# --- Docker ---
up: .env
up: .env.alfred .env.secrets
@echo "Starting containers with profiles: [full]..."
@$(PROFILES_PARAM) $(DOCKER_COMPOSE) up -d --remove-orphans \
&& echo "✓ Containers started" \
@@ -74,45 +77,45 @@ build-test: .env.make
# --- Dependencies ---
install:
@echo "Installing dependencies with $(RUNNER)..."
@$(RUNNER) install \
@echo "Installing dependencies with uv..."
@uv install \
&& echo "✓ Dependencies installed" \
|| (echo "✗ Installation failed" && exit 1)
install-hooks:
@echo "Installing pre-commit hooks..."
@$(RUNNER) run pre-commit install \
@uv run pre-commit install \
&& echo "✓ Hooks installed" \
|| (echo "✗ Hook installation failed" && exit 1)
update:
@echo "Updating dependencies with $(RUNNER)..."
@$(RUNNER) update \
@echo "Updating dependencies with uv..."
@uv update \
&& echo "✓ Dependencies updated" \
|| (echo "✗ Update failed" && exit 1)
# --- Quality ---
test:
@echo "Running tests..."
@$(RUNNER) run pytest \
@uv run pytest \
&& echo "✓ Tests passed" \
|| (echo "✗ Tests failed" && exit 1)
coverage:
@echo "Running tests with coverage..."
@$(RUNNER) run pytest --cov=. --cov-report=html --cov-report=term \
@uv run pytest --cov=. --cov-report=html --cov-report=term \
&& echo "✓ Coverage report generated" \
|| (echo "✗ Coverage failed" && exit 1)
lint:
@echo "Linting code..."
@$(RUNNER) run ruff check --fix . \
@uv run ruff check --fix . \
&& echo "✓ Linting complete" \
|| (echo "✗ Linting failed" && exit 1)
format:
@echo "Formatting code..."
@$(RUNNER) run ruff format . && $(RUNNER) run ruff check --fix . \
@uv run ruff format . && uv run ruff check --fix . \
&& echo "✓ Code formatted" \
|| (echo "✗ Formatting failed" && exit 1)
@@ -125,7 +128,7 @@ clean:
# --- Versioning ---
major minor patch: _check-main
@echo "Bumping $@ version..."
@$(RUNNER) run bump-my-version bump $@ \
@uv run bump-my-version bump $@ \
&& echo "✓ Version bumped" \
|| (echo "✗ Version bump failed" && exit 1)
@@ -138,8 +141,7 @@ major minor patch: _check-main
_ci-dump-config:
@echo "image_name=$(IMAGE_NAME)"
@echo "python_version=$(PYTHON_VERSION)"
@echo "python_version_short=$(PYTHON_VERSION_SHORT)"
@echo "runner=$(RUNNER)"
@echo "uv_version=$(UV_VERSION)"
@echo "service_name=$(SERVICE_NAME)"
_ci-run-tests:build-test
@@ -161,6 +163,9 @@ help:
@echo ""
@echo "Usage: make [target] [p=profile1,profile2]"
@echo ""
@echo "Setup:"
@echo " bootstrap Generate .env.alfred, .env.librechat, .env.secrets and .env.make"
@echo ""
@echo "Docker:"
@echo " up Start containers (default profile: core)"
@echo " Example: make up p=rag,meili"
@@ -173,7 +178,7 @@ help:
@echo ""
@echo "Dev & Quality:"
@echo " setup Bootstrap .env and security keys"
@echo " install Install dependencies via $(RUNNER)"
@echo " install Install dependencies via uv"
@echo " test Run pytest suite"
@echo " coverage Run tests and generate HTML report"
@echo " lint/format Quality and style checks"
+181 -343
View File
@@ -3,7 +3,7 @@
An AI-powered agent for managing your local media library with natural language. Search, download, and organize movies and TV shows effortlessly through a conversational interface.
[![Python 3.14](https://img.shields.io/badge/python-3.14-blue.svg)](https://www.python.org/downloads/)
[![Poetry](https://img.shields.io/badge/dependency%20manager-poetry-blue)](https://python-poetry.org/)
[![uv](https://img.shields.io/badge/dependency%20manager-uv-purple)](https://github.com/astral-sh/uv)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff)
@@ -13,9 +13,10 @@ An AI-powered agent for managing your local media library with natural language.
- 🔍 **Smart Search** — Find movies and TV shows via TMDB with rich metadata
- 📥 **Torrent Integration** — Search and download via qBittorrent
- 🧠 **Contextual Memory** — Remembers your preferences and conversation history
- 📁 **Auto-Organization**Keeps your media library tidy and well-structured
- 🌐 **OpenAI-Compatible API** — Works with any OpenAI-compatible client
- 🖥️ **LibreChat Frontend** — Beautiful web UI included out of the box
- 📁 **Auto-Organization**Moves and renames media files, resolves destinations, handles subtitles
- 🎞️ **Subtitle Pipeline** — Identifies, matches, and places subtitle tracks automatically
- 🔄 **Workflow Engine** — YAML-defined multi-step workflows (e.g. `organize_media`)
- 🌐 **OpenAI-Compatible API** — Works with any OpenAI-compatible client (LibreChat, OpenWebUI, etc.)
- 🔒 **Secure by Default** — Auto-generated secrets and encrypted credentials
## 🏗️ Architecture
@@ -26,33 +27,50 @@ Built with **Domain-Driven Design (DDD)** principles for clean separation of con
alfred/
├── agent/ # AI agent orchestration
│ ├── llm/ # LLM clients (Ollama, DeepSeek)
── tools/ # Tool implementations
── tools/ # Tool implementations (api, filesystem, language)
│ └── workflows/ # YAML-defined multi-step workflows
├── application/ # Use cases & DTOs
│ ├── movies/ # Movie search use cases
│ ├── movies/ # Movie search
│ ├── torrents/ # Torrent management
│ └── filesystem/ # File operations
│ └── filesystem/ # File operations (move, list, subtitles, seed links)
├── domain/ # Business logic & entities
│ ├── media/ # Release parsing
│ ├── movies/ # Movie entities
│ ├── tv_shows/ # TV show entities
── subtitles/ # Subtitle entities
│ ├── tv_shows/ # TV show entities & value objects
── subtitles/ # Subtitle scanner, services, knowledge base
│ └── shared/ # Common value objects (ImdbId, FilePath, FileSize)
└── infrastructure/ # External services & persistence
├── api/ # External API clients (TMDB, qBittorrent)
├── filesystem/ # File system operations
── persistence/ # Memory & repositories
├── api/ # External API clients (TMDB, qBittorrent, Knaben)
├── filesystem/ # File manager (hard-link based, path-traversal safe)
── persistence/ # Three-tier memory (LTM/STM/Episodic) + JSON repositories
└── subtitle/ # Subtitle infrastructure
```
See [docs/architecture_diagram.md](docs/architecture_diagram.md) for detailed architectural diagrams.
### Key flows
**Agent execution:** `agent.step(user_input)` → LLM call → if tool_calls, execute each via registry → loop until no tool calls or `max_tool_iterations` → return final response.
**Media organization workflow:**
1. `resolve_destination` — Determines target folder/filename from release name
2. `move_media` — Hard-links file to library, deletes source
3. `manage_subtitles` — Scans, classifies, and places subtitle tracks
4. `create_seed_links` — Hard-links library file back to torrents/ for continued seeding
**Memory tiers:**
- **LTM** (`data/memory/ltm.json`) — Persisted config, media library, watchlist
- **STM** — Conversation history (capped at `MAX_HISTORY_MESSAGES`)
- **Episodic** — Transient search results, active downloads, recent errors
## 🚀 Quick Start
### Prerequisites
- **Python 3.14+** (required)
- **Poetry** (dependency manager)
- **Python 3.14+**
- **uv** (dependency manager)
- **Docker & Docker Compose** (recommended for full stack)
- **API Keys:**
- TMDB API key ([get one here](https://www.themoviedb.org/settings/api))
- Optional: DeepSeek, OpenAI, Anthropic, or other LLM provider keys
- Optional: DeepSeek or other LLM provider keys
### Installation
@@ -64,9 +82,15 @@ cd alfred_media_organizer
# Install dependencies
make install
# Install pre-commit hooks
make install-hooks
# Bootstrap environment (generates .env with secure secrets)
make bootstrap
# Validate your .env against the schema
make validate
# Edit .env with your API keys
nano .env
```
@@ -94,162 +118,95 @@ The web interface will be available at **http://localhost:3080**
### Running Locally (Development)
```bash
# Install dependencies
poetry install
# Start the API server
poetry run uvicorn alfred.app:app --reload --port 8000
uv run uvicorn alfred.app:app --reload --port 8000
```
## ⚙️ Configuration
### Environment Bootstrap
### Settings system
Alfred uses a smart bootstrap system that:
`settings.toml` is the single source of truth. The schema flows:
1. **Generates secure secrets** automatically (JWT tokens, database passwords, encryption keys)
2. **Syncs build variables** from `pyproject.toml` (versions, image names)
3. **Preserves existing secrets** when re-running (never overwrites your API keys)
4. **Computes database URIs** automatically from individual components
```
settings.toml → settings_schema.py → settings_bootstrap.py → .env + .env.make → settings.py
```
To add a setting: define it in `settings.toml`, run `make bootstrap`, then access via `settings.my_new_setting`.
```bash
# First time setup
make bootstrap
# Re-run after updating pyproject.toml (secrets are preserved)
# Validate existing .env against schema
make validate
# Re-run after settings.toml changes (existing secrets preserved)
make bootstrap
```
### Configuration File (.env)
**Never commit `.env` or `.env.make`** — both are gitignored and auto-generated.
The `.env` file is generated from `.env.example` with secure defaults:
### Key settings (.env)
```bash
# --- CORE SETTINGS ---
HOST=0.0.0.0
PORT=3080
# --- CORE ---
MAX_HISTORY_MESSAGES=10
MAX_TOOL_ITERATIONS=10
# --- LLM CONFIGURATION ---
# Providers: 'local' (Ollama), 'deepseek', 'openai', 'anthropic', 'google'
DEFAULT_LLM_PROVIDER=local
# Local LLM (Ollama - included in Docker stack)
# --- LLM ---
DEFAULT_LLM_PROVIDER=local # local (Ollama) | deepseek
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_MODEL=llama3.3:latest
LLM_TEMPERATURE=0.2
# --- API KEYS (fill only what you need) ---
TMDB_API_KEY=your-tmdb-key-here # Required for movie search
DEEPSEEK_API_KEY= # Optional
OPENAI_API_KEY= # Optional
ANTHROPIC_API_KEY= # Optional
# --- API KEYS ---
TMDB_API_KEY=your-tmdb-key # Required for movie/show search
DEEPSEEK_API_KEY= # Optional
# --- SECURITY (auto-generated, don't modify) ---
JWT_SECRET=<auto-generated>
JWT_REFRESH_SECRET=<auto-generated>
CREDS_KEY=<auto-generated>
CREDS_IV=<auto-generated>
# --- DATABASES (auto-generated passwords) ---
MONGO_PASSWORD=<auto-generated>
POSTGRES_PASSWORD=<auto-generated>
# --- SECURITY (auto-generated) ---
JWT_SECRET=<auto>
CREDS_KEY=<auto>
MONGO_PASSWORD=<auto>
```
### Security Keys
Security keys are defined in `pyproject.toml` and generated automatically:
```toml
[tool.alfred.security]
jwt_secret = "32:b64" # 32 bytes, base64 URL-safe
jwt_refresh_secret = "32:b64"
creds_key = "32:hex" # 32 bytes, hexadecimal (AES-256)
creds_iv = "16:hex" # 16 bytes, hexadecimal (AES IV)
mongo_password = "16:hex"
postgres_password = "16:hex"
```
**Formats:**
- `b64` — Base64 URL-safe (for JWT tokens)
- `hex` — Hexadecimal (for encryption keys, passwords)
## 🐳 Docker Services
### Service Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ alfred-net (bridge) │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ LibreChat │───▶│ Alfred │───▶│ MongoDB │ │
│ │ :3080 │ │ (core) │ │ :27017 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌──────────────┐ │
│ │ │ Ollama │ │
│ │ │ (local) │ │
│ │ └──────────────┘ │
│ │ │
│ ┌──────┴───────────────────────────────────────────────┐ │
│ │ Optional Services (profiles) │ │
│ ├──────────────┬──────────────┬──────────────┬─────────┤ │
│ │ Meilisearch │ RAG API │ VectorDB │qBittor- │ │
│ │ :7700 │ :8000 │ :5432 │ rent │ │
│ │ [meili] │ [rag] │ [rag] │[qbit..] │ │
│ └──────────────┴──────────────┴──────────────┴─────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Docker Profiles
| Profile | Services | Use Case |
|---------|----------|----------|
| (default) | LibreChat, Alfred, MongoDB, Ollama | Basic setup |
| `meili` | + Meilisearch | Fast search |
| `rag` | + RAG API, VectorDB | Document retrieval |
| `qbittorrent` | + qBittorrent | Torrent downloads |
| `full` | All services | Complete setup |
| Profile | Extra services | Use case |
|---------|---------------|----------|
| (default) | — | LibreChat + Alfred + MongoDB + Ollama |
| `meili` | Meilisearch | Fast full-text search |
| `rag` | RAG API + VectorDB (PostgreSQL) | Document retrieval |
| `qbittorrent` | qBittorrent | Torrent downloads |
| `full` | All of the above | Complete setup |
```bash
# Start with specific profiles
make up p=rag,meili
make up p=full
```
### Docker Commands
```bash
make up # Start containers (default profile)
make up # Start (default profile)
make up p=full # Start with all services
make down # Stop all containers
make restart # Restart containers
make down # Stop
make restart # Restart
make logs # Follow logs
make ps # Show container status
make shell # Open bash in Alfred container
make build # Build production image
make build-test # Build test image
make ps # Container status
```
## 🛠️ Available Tools
The agent has access to these tools for interacting with your media library:
| Tool | Description |
|------|-------------|
| `find_media_imdb_id` | Search for movies/TV shows on TMDB by title |
| `find_torrent` | Search for torrents across multiple indexers |
| `get_torrent_by_index` | Get detailed info about a specific torrent result |
| `add_torrent_by_index` | Download a torrent by its index in search results |
| `get_torrent_by_index` | Get detailed info about a specific result |
| `add_torrent_by_index` | Download a torrent from search results |
| `add_torrent_to_qbittorrent` | Add a torrent via magnet link directly |
| `set_path_for_folder` | Configure folder paths for media organization |
| `list_folder` | List contents of a folder |
| `set_language` | Set preferred language for searches |
| `resolve_destination` | Compute the target library path for a release |
| `move_media` | Hard-link a file to its library destination |
| `manage_subtitles` | Scan, classify, and place subtitle tracks |
| `create_seed_links` | Prepare torrent folder so qBittorrent keeps seeding |
| `learn` | Teach Alfred a new pattern (release group, naming convention) |
| `set_path_for_folder` | Configure folder paths |
| `list_folder` | List contents of a configured folder |
| `set_language` | Set preferred language for the session |
## 💬 Usage Examples
@@ -266,11 +223,12 @@ Alfred: I found 3 torrents for Inception (2010):
You: Download the first one
Alfred: ✓ Added to qBittorrent! Download started.
Saving to: /downloads/Movies/Inception (2010)/
You: What's downloading right now?
Alfred: You have 1 active download:
- Inception.2010.1080p.BluRay.x264 (45% complete, ETA: 12 min)
You: Organize the Breaking Bad S01 download
Alfred: ✓ Resolved destination: /tv_shows/Breaking.Bad/Season 01/
✓ Moved 6 episode files
✓ Placed 6 subtitle tracks (fr, en)
✓ Seed links created in /torrents/
```
### Via API
@@ -279,219 +237,147 @@ Alfred: You have 1 active download:
# Health check
curl http://localhost:8000/health
# Chat with the agent (OpenAI-compatible)
# Chat (OpenAI-compatible)
curl -X POST http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "alfred",
"messages": [
{"role": "user", "content": "Find The Matrix 4K"}
]
"messages": [{"role": "user", "content": "Find The Matrix 4K"}]
}'
# List available models
# List models
curl http://localhost:8000/v1/models
# View memory state (debug)
# View memory state
curl http://localhost:8000/memory/state
# Clear session memory
curl -X POST http://localhost:8000/memory/clear-session
```
### Via OpenWebUI or Other Clients
Alfred is compatible with any OpenAI-compatible client:
1. Add as OpenAI-compatible endpoint: `http://localhost:8000/v1`
2. Model name: `alfred`
3. No API key required (or use any placeholder)
Alfred is compatible with any OpenAI-compatible client. Point it at `http://localhost:8000/v1`, model `alfred`.
## 🧠 Memory System
Alfred uses a three-tier memory system for context management:
Alfred uses a three-tier memory system:
### Long-Term Memory (LTM)
- **Persistent** — Saved to JSON files
- **Contents:** Configuration, user preferences, media library state
- **Survives:** Application restarts
### Short-Term Memory (STM)
- **Session-based** — Stored in RAM
- **Contents:** Conversation history, current workflow state
- **Cleared:** On session end or restart
### Episodic Memory
- **Transient** — Stored in RAM
- **Contents:** Search results, active downloads, recent errors
- **Cleared:** Frequently, after task completion
| Tier | Storage | Contents | Lifetime |
|------|---------|----------|----------|
| **LTM** | JSON file (`data/memory/ltm.json`) | Config, library, watchlist, learned patterns | Permanent |
| **STM** | RAM | Conversation history (capped) | Session |
| **Episodic** | RAM | Search results, active downloads, errors | Short-lived |
## 🧪 Development
### Project Setup
```bash
# Install all dependencies (including dev)
poetry install
# Install pre-commit hooks
make install-hooks
# Run the development server
poetry run uvicorn alfred.app:app --reload
```
### Running Tests
```bash
# Run all tests (parallel execution)
# Run full suite (parallel)
make test
# Run with coverage report
make coverage
# Run specific test file
poetry run pytest tests/test_agent.py -v
# Run a single file
uv run pytest tests/test_agent.py -v
# Run specific test
poetry run pytest tests/test_config_loader.py::TestBootstrapEnv -v
# Run a single class
uv run pytest tests/test_agent.py::TestAgentInit -v
# Skip slow tests
uv run pytest -m "not slow"
```
### Test coverage
The suite covers:
- **Agent loop** — tool execution, history, max iterations, error handling
- **Tool registry** — OpenAI schema format, parameter extraction
- **Prompts** — system prompt building, tool inclusion
- **Memory** — LTM/STM/Episodic operations, persistence
- **Filesystem tools** — path traversal security, folder listing
- **File manager** — hard-link, move, seed links (real filesystem, no mocks)
- **Application use cases** — `resolve_destination`, `create_seed_links`, `list_folder`, `move_media`
- **Domain** — TV show/movie entities, shared value objects (`ImdbId`, `FilePath`, `FileSize`), subtitle scanner
- **Repositories** — JSON-backed movie, TV show, subtitle repos
- **Bootstrap** — secret generation, idempotency, URI construction
- **Workflows** — YAML loading, structure validation
- **Configuration** — boundary validation for all settings
### Code Quality
```bash
# Lint and auto-fix
make lint
# Format code
make format
# Clean build artifacts
make clean
make lint # Ruff check --fix
make format # Ruff format + check --fix
```
### Adding a New Tool
1. **Create the tool function** in `alfred/agent/tools/`:
1. Implement the function in `alfred/agent/tools/`:
```python
# alfred/agent/tools/api.py
def my_new_tool(param: str) -> dict[str, Any]:
"""
Short description of what this tool does.
This will be shown to the LLM to help it decide when to use this tool.
"""
"""Short description shown to the LLM to decide when to call this tool."""
memory = get_memory()
# Your implementation here
result = do_something(param)
return {
"status": "success",
"data": result
}
# ...
return {"status": "ok", "data": result}
```
2. **Register in the registry** (`alfred/agent/registry.py`):
2. Register it in `alfred/agent/registry.py`:
```python
tool_functions = [
# ... existing tools ...
api_tools.my_new_tool, # Add your tool here
api_tools.my_new_tool,
]
```
The tool will be automatically registered with its parameters extracted from the function signature.
The registry auto-generates the JSON schema from the function signature and docstring.
### Adding a Workflow
Create a YAML file in `alfred/agent/workflows/`:
```yaml
name: my_workflow
description: What this workflow does
steps:
- tool: resolve_destination
description: Find where the file should go
- tool: move_media
description: Move the file
```
Workflows are loaded automatically at startup.
### Version Management
```bash
# Bump version (must be on main branch)
make patch # 0.1.7 -> 0.1.8
make minor # 0.1.7 -> 0.2.0
make major # 0.1.7 -> 1.0.0
# Must be on main branch
make patch # 0.1.7 0.1.8
make minor # 0.1.7 0.2.0
make major # 0.1.7 1.0.0
```
## 📚 API Reference
### Endpoints
#### `GET /health`
Health check endpoint.
```json
{
"status": "healthy",
"version": "0.1.7"
}
```
#### `GET /v1/models`
List available models (OpenAI-compatible).
```json
{
"object": "list",
"data": [
{
"id": "alfred",
"object": "model",
"owned_by": "alfred"
}
]
}
```
#### `POST /v1/chat/completions`
Chat with the agent (OpenAI-compatible).
**Request:**
```json
{
"model": "alfred",
"messages": [
{"role": "user", "content": "Find Inception"}
],
"stream": false
}
```
**Response:**
```json
{
"id": "chatcmpl-xxx",
"object": "chat.completion",
"created": 1234567890,
"model": "alfred",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "I found Inception (2010)..."
},
"finish_reason": "stop"
}]
}
```
#### `GET /memory/state`
View full memory state (debug endpoint).
#### `POST /memory/clear-session`
Clear session memories (STM + Episodic).
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/health` | Health check |
| `GET` | `/v1/models` | List models (OpenAI-compatible) |
| `POST` | `/v1/chat/completions` | Chat (OpenAI-compatible, streaming supported) |
| `GET` | `/memory/state` | Full memory dump (debug) |
| `POST` | `/memory/clear-session` | Clear STM + Episodic |
| `GET` | `/memory/episodic/search-results` | Current search results |
## 🔧 Troubleshooting
### Agent doesn't respond
1. Check API keys in `.env`
2. Verify LLM provider is running:
2. Verify the LLM is running:
```bash
# For Ollama
docker logs alfred-ollama
# Check if model is pulled
docker exec alfred-ollama ollama list
```
3. Check Alfred logs: `docker logs alfred-core`
@@ -499,76 +385,34 @@ Clear session memories (STM + Episodic).
### qBittorrent connection failed
1. Verify qBittorrent is running: `docker ps | grep qbittorrent`
2. Check Web UI is enabled in qBittorrent settings
3. Verify credentials in `.env`:
```bash
QBITTORRENT_URL=http://qbittorrent:16140
QBITTORRENT_USERNAME=admin
QBITTORRENT_PASSWORD=<check-your-env>
```
### Database connection issues
1. Check MongoDB is healthy: `docker logs alfred-mongodb`
2. Verify credentials match in `.env`
3. Try restarting: `make restart`
2. Check credentials in `.env` (`QBITTORRENT_URL`, `QBITTORRENT_USERNAME`, `QBITTORRENT_PASSWORD`)
### Memory not persisting
1. Check `data/` directory exists and is writable
1. Check `data/` directory is writable
2. Verify volume mounts in `docker-compose.yaml`
3. Check file permissions: `ls -la data/`
### Bootstrap fails
1. Ensure `.env.example` exists
2. Check `pyproject.toml` has required sections:
```toml
[tool.alfred.settings]
[tool.alfred.security]
```
3. Run manually: `python scripts/bootstrap.py`
```bash
make validate # Check what's wrong with .env
make bootstrap # Regenerate (preserves existing secrets)
```
### Tests failing
1. Update dependencies: `poetry install`
2. Check Python version: `python --version` (needs 3.14+)
3. Run specific failing test with verbose output:
```bash
poetry run pytest tests/test_failing.py -v --tb=long
```
```bash
uv run pytest tests/test_failing.py -v --tb=long
```
## 🤝 Contributing
Contributions are welcome! Please follow these steps:
1. **Fork** the repository
2. **Create** a feature branch: `git checkout -b feature/my-feature`
3. **Make** your changes
4. **Run** tests: `make test`
5. **Run** linting: `make lint && make format`
6. **Commit**: `git commit -m "feat: add my feature"`
7. **Push**: `git push origin feature/my-feature`
8. **Create** a Pull Request
### Commit Convention
We use [Conventional Commits](https://www.conventionalcommits.org/):
- `feat:` New feature
- `fix:` Bug fix
- `docs:` Documentation
- `refactor:` Code refactoring
- `test:` Adding tests
- `chore:` Maintenance
## 📖 Documentation
- [Architecture Diagram](docs/architecture_diagram.md) — System architecture overview
- [Class Diagram](docs/class_diagram.md) — Class structure and relationships
- [Component Diagram](docs/component_diagram.md) — Component interactions
- [Sequence Diagram](docs/sequence_diagram.md) — Sequence flows
- [Flowchart](docs/flowchart.md) — System flowcharts
1. Fork the repository
2. Create a feature branch: `git checkout -b feat/my-feature`
3. Make your changes + add tests
4. Run `make test && make lint && make format`
5. Commit with [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `infra:`
6. Open a Pull Request
## 📄 License
@@ -576,19 +420,13 @@ MIT License — see [LICENSE](LICENSE) file for details.
## 🙏 Acknowledgments
- [LibreChat](https://github.com/danny-avila/LibreChat) — Beautiful chat interface
- [LibreChat](https://github.com/danny-avila/LibreChat) — Chat interface
- [Ollama](https://ollama.ai/) — Local LLM runtime
- [DeepSeek](https://www.deepseek.com/) — LLM provider
- [TMDB](https://www.themoviedb.org/) — Movie database
- [TMDB](https://www.themoviedb.org/) — Movie & TV database
- [qBittorrent](https://www.qbittorrent.org/) — Torrent client
- [FastAPI](https://fastapi.tiangolo.com/) — Web framework
- [Pydantic](https://docs.pydantic.dev/) — Data validation
## 📬 Support
- 📧 Email: francois.hodiaumont@gmail.com
- 🐛 Issues: [GitHub Issues](https://github.com/francwa/alfred_media_organizer/issues)
- 💬 Discussions: [GitHub Discussions](https://github.com/francwa/alfred_media_organizer/discussions)
- [uv](https://github.com/astral-sh/uv) — Fast Python package manager
---
+145 -8
View File
@@ -3,13 +3,16 @@
import json
import logging
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import Any
from alfred.infrastructure.metadata import MetadataStore
from alfred.infrastructure.persistence import get_memory
from alfred.settings import settings
from .prompts import PromptBuilder
from .prompt import PromptBuilder
from .registry import Tool, make_tools
from .workflows import WorkflowLoader
logger = logging.getLogger(__name__)
@@ -33,8 +36,8 @@ class Agent:
self.settings = settings
self.llm = llm
self.tools: dict[str, Tool] = make_tools(settings)
self.prompt_builder = PromptBuilder(self.tools)
self.settings = settings
self.workflow_loader = WorkflowLoader()
self.prompt_builder = PromptBuilder(self.tools, self.workflow_loader)
self.max_tool_iterations = max_tool_iterations
def step(self, user_input: str) -> str:
@@ -139,7 +142,7 @@ class Agent:
memory.save()
return final_response
def _execute_tool_call(self, tool_call: dict[str, Any]) -> dict[str, Any]:
def _execute_tool_call(self, tool_call: dict[str, Any]) -> dict[str, Any]: # noqa: PLR0911
"""
Execute a single tool call.
@@ -168,29 +171,163 @@ class Agent:
"available_tools": available,
}
# Defensive: reject calls to tools that are not currently in scope.
visible = set(self.prompt_builder.visible_tool_names())
if tool_name not in visible:
return {
"error": "tool_out_of_scope",
"message": (
f"Tool '{tool_name}' is not available in the current "
"workflow scope. Call end_workflow first or start the "
"appropriate workflow."
),
"available_tools": sorted(visible),
}
tool = self.tools[tool_name]
memory = get_memory()
# Cache lookup — for tools flagged cacheable, short-circuit on hit.
cache_key_value = self._cache_key_for(tool, args)
if cache_key_value is not None:
cached = memory.stm.tool_results.get(tool_name, cache_key_value)
if cached is not None:
logger.info(f"Tool cache HIT: {tool_name}[{cache_key_value}]")
self._post_tool_side_effects(tool_name, args, cached, from_cache=True)
return {**cached, "_from_cache": True}
# Execute tool
try:
result = tool.func(**args)
return result
except KeyboardInterrupt:
# Don't catch KeyboardInterrupt - let it propagate
raise
except TypeError as e:
# Bad arguments
memory = get_memory()
memory.episodic.add_error(tool_name, f"bad_args: {e}")
return {"error": "bad_args", "message": str(e), "tool": tool_name}
except Exception as e:
# Other errors
memory = get_memory()
memory.episodic.add_error(tool_name, str(e))
return {"error": "execution_failed", "message": str(e), "tool": tool_name}
# Persist + side effects only on successful results.
if isinstance(result, dict) and result.get("status") == "ok":
if cache_key_value is not None:
memory.stm.tool_results.put(tool_name, cache_key_value, result)
self._post_tool_side_effects(tool_name, args, result, from_cache=False)
memory.save()
return result
@staticmethod
def _cache_key_for(tool: Tool, args: dict[str, Any]) -> str | None:
"""Return the cache key value for this call, or None if not cacheable."""
if tool.cache_key is None:
return None
value = args.get(tool.cache_key)
if value is None:
return None
return str(value)
def _post_tool_side_effects(
self,
tool_name: str,
args: dict[str, Any],
result: dict[str, Any],
*,
from_cache: bool,
) -> None:
"""
Tool-agnostic side effects applied after a successful run or cache hit.
Today:
- Update release_focus when a path-keyed inspector runs.
- Persist inspector results into the release's `.alfred/metadata.yaml`.
- Refresh episodic.last_search_results on find_torrent cache hits so
get_torrent_by_index keeps pointing at the right list.
"""
memory = get_memory()
tool = self.tools.get(tool_name)
# Release focus: any path-keyed inspector updates current_release_path.
if tool is not None and tool.cache_key in {"source_path"}:
path = args.get(tool.cache_key)
if isinstance(path, str) and path:
memory.stm.release_focus.focus(path)
# Persist inspector results to .alfred/metadata.yaml (skip on cache
# hit — the file is already up to date from the original run).
if not from_cache:
self._maybe_update_alfred(tool_name, args, result)
# Episodic refresh when find_torrent's cache short-circuits the call.
if from_cache and tool_name == "find_torrent":
torrents = result.get("torrents") or []
query = args.get("media_title") or ""
memory.episodic.store_search_results(
query=query, results=torrents, search_type="torrent"
)
def _maybe_update_alfred(
self,
tool_name: str,
args: dict[str, Any],
result: dict[str, Any],
) -> None:
"""
Persist a successful inspector result into the release's
`.alfred/metadata.yaml`. No-op when the release root can't be resolved.
"""
if tool_name not in {"analyze_release", "probe_media", "find_media_imdb_id"}:
return
release_root = self._resolve_release_root(tool_name, args)
if release_root is None:
return
try:
store = MetadataStore(release_root)
if tool_name == "analyze_release":
store.update_parse(result)
elif tool_name == "probe_media":
store.update_probe(result)
elif tool_name == "find_media_imdb_id":
store.update_tmdb(result)
except Exception as e:
logger.warning(
f"Failed to update .alfred for {tool_name} at {release_root}: {e}"
)
@staticmethod
def _resolve_release_root(
tool_name: str,
args: dict[str, Any],
) -> Path | None:
"""
Figure out which release folder owns this call.
- analyze_release / probe_media: derived from source_path
(folder kept as-is, file walked up to its parent).
- find_media_imdb_id: follow the current release focus in STM.
"""
if tool_name in {"analyze_release", "probe_media"}:
raw = args.get("source_path")
if not isinstance(raw, str) or not raw:
return None
path = Path(raw)
return path if path.is_dir() else path.parent
# find_media_imdb_id has no path arg — rely on release focus.
focus = get_memory().stm.release_focus.current_release_path
if not focus:
return None
path = Path(focus)
return path if path.is_dir() else path.parent
async def step_streaming(
self, user_input: str, completion_id: str, created_ts: int, model: str
) -> AsyncGenerator[dict[str, Any], None]:
) -> AsyncGenerator[dict[str, Any]]:
"""
Execute agent step with streaming support for LibreChat.
+79
View File
@@ -0,0 +1,79 @@
"""Expression loader — charge et merge les fichiers YAML d'expressions par user."""
import random
from pathlib import Path
import yaml
_USERS_DIR = Path(__file__).parent.parent / "knowledge" / "users"
def _load_yaml(path: Path) -> dict:
if not path.exists():
return {}
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
def load_expressions(username: str | None) -> dict:
"""
Charge common.yaml et le merge avec {username}.yaml.
Retourne un dict avec :
- nickname: str (surnom de l'user, ou username en fallback)
- expressions: dict[situation -> list[str]]
"""
common = _load_yaml(_USERS_DIR / "common.yaml")
user_data = _load_yaml(_USERS_DIR / f"{username}.yaml") if username else {}
# Merge expressions : common + user (les phrases user s'ajoutent)
common_exprs: dict[str, list] = common.get("expressions", {})
user_exprs: dict[str, list] = user_data.get("expressions", {})
merged: dict[str, list] = {}
all_situations = set(common_exprs) | set(user_exprs)
for situation in all_situations:
base = list(common_exprs.get(situation, []))
extra = list(user_exprs.get(situation, []))
merged[situation] = base + extra
nickname = user_data.get("user", {}).get("nickname") or username or "mec"
return {
"nickname": nickname,
"expressions": merged,
}
def pick(expressions: dict, situation: str, nickname: str | None = None) -> str:
"""
Pioche une expression aléatoire pour une situation donnée.
Résout {user} avec le nickname si fourni.
Retourne une string vide si la situation n'existe pas.
"""
options = expressions.get("expressions", {}).get(situation, [])
if not options:
return ""
chosen = random.choice(options)
if nickname:
chosen = chosen.replace("{user}", nickname)
return chosen
def build_expressions_context(username: str | None) -> dict:
"""
Point d'entrée principal.
Retourne :
- nickname: str
- samples: dict[situation -> une phrase résolue] — une seule par situation
"""
data = load_expressions(username)
nickname = data["nickname"]
samples = {
situation: pick(data, situation, nickname) for situation in data["expressions"]
}
return {
"nickname": nickname,
"samples": samples,
}
+4 -2
View File
@@ -6,7 +6,8 @@ from typing import Any
import requests
from requests.exceptions import HTTPError, RequestException, Timeout
from alfred.settings import Settings, settings
from alfred.settings import Settings
from alfred.settings import settings as default_settings
from .exceptions import LLMAPIError, LLMConfigurationError
@@ -36,6 +37,7 @@ class DeepSeekClient:
Raises:
LLMConfigurationError: If API key is missing
"""
self.settings = settings or default_settings
self.api_key = api_key or self.settings.deepseek_api_key
self.base_url = base_url or self.settings.deepseek_base_url
self.model = model or self.settings.deepseek_model
@@ -96,7 +98,7 @@ class DeepSeekClient:
payload = {
"model": self.model,
"messages": messages,
"temperature": settings.llm_temperature,
"temperature": self.settings.llm_temperature,
}
# Add tools if provided
+6 -4
View File
@@ -7,6 +7,7 @@ import requests
from requests.exceptions import HTTPError, RequestException, Timeout
from alfred.settings import Settings
from alfred.settings import settings as default_settings
from .exceptions import LLMAPIError, LLMConfigurationError
@@ -46,11 +47,12 @@ class OllamaClient:
Raises:
LLMConfigurationError: If configuration is invalid
"""
self.base_url = base_url or settings.ollama_base_url
self.model = model or settings.ollama_model
self.timeout = timeout or settings.request_timeout
self.settings = settings or default_settings
self.base_url = base_url or self.settings.ollama_base_url
self.model = model or self.settings.ollama_model
self.timeout = timeout or self.settings.request_timeout
self.temperature = (
temperature if temperature is not None else settings.llm_temperature
temperature if temperature is not None else self.settings.llm_temperature
)
if not self.base_url:
-101
View File
@@ -1,101 +0,0 @@
# agent/parameters.py
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
@dataclass
class ParameterSchema:
"""Describes a required parameter for the agent."""
key: str
description: str
why_needed: str # Explanation for the AI
type: str # "string", "number", "object", etc.
validator: Callable[[Any], bool] | None = None
default: Any = None
required: bool = True
# Define all required parameters
REQUIRED_PARAMETERS = [
ParameterSchema(
key="config",
description="Configuration object containing all folder paths",
why_needed=(
"This contains the paths to all important folders:\n"
"- download_folder: Where downloaded files arrive before being organized\n"
"- tvshow_folder: Where TV show files are organized and stored\n"
"- movie_folder: Where movie files are organized and stored\n"
"- torrent_folder: Where torrent structures are saved for the torrent client"
),
type="object",
validator=lambda x: isinstance(x, dict),
required=True,
default={},
),
ParameterSchema(
key="tv_shows",
description="List of TV shows the user is following",
why_needed=(
"This tracks which TV shows you're following. "
"Each show includes: IMDB ID, title, number of seasons, and status (ongoing or ended)."
),
type="array",
validator=lambda x: isinstance(x, list),
required=False,
default=[],
),
]
def get_parameter_schema(key: str) -> ParameterSchema | None:
"""Get schema for a specific parameter."""
for param in REQUIRED_PARAMETERS:
if param.key == key:
return param
return None
def get_missing_required_parameters(memory_data: dict) -> list[ParameterSchema]:
"""Get list of required parameters that are missing or None."""
missing = []
for param in REQUIRED_PARAMETERS:
if param.required:
value = memory_data.get(param.key)
if value is None:
missing.append(param)
return missing
def format_parameters_for_prompt() -> str:
"""Format parameter descriptions for the AI system prompt."""
lines = ["REQUIRED PARAMETERS:"]
for param in REQUIRED_PARAMETERS:
status = "REQUIRED" if param.required else "OPTIONAL"
lines.append(f"\n- {param.key} ({status}):")
lines.append(f" Description: {param.description}")
lines.append(f" Why needed: {param.why_needed}")
lines.append(f" Type: {param.type}")
return "\n".join(lines)
def validate_parameter(key: str, value: Any) -> tuple[bool, str | None]:
"""
Validate a parameter value against its schema.
Returns:
(is_valid, error_message)
"""
schema = get_parameter_schema(key)
if not schema:
return True, None # Unknown parameters are allowed
if schema.validator:
try:
if not schema.validator(value):
return False, f"Validation failed for {key}"
except Exception as e:
return False, f"Validation error for {key}: {str(e)}"
return True, None
+333
View File
@@ -0,0 +1,333 @@
"""Prompt builder for the agent system."""
import json
from typing import Any
from alfred.infrastructure.persistence import get_memory
from alfred.infrastructure.persistence.memory import MemoryRegistry
from .expressions import build_expressions_context
from .registry import Tool
from .workflows import WorkflowLoader
# Tools that are always available, regardless of workflow scope.
# Kept small on purpose — the noyau is what the agent uses to either
# answer trivially or pivot into a workflow.
CORE_TOOLS: tuple[str, ...] = (
"set_language",
"set_path_for_folder",
"list_folder",
"read_release_metadata",
"query_library",
"start_workflow",
"end_workflow",
)
class PromptBuilder:
"""Builds system prompts for the agent with memory context."""
def __init__(
self,
tools: dict[str, Tool],
workflow_loader: WorkflowLoader | None = None,
):
self.tools = tools
self.workflow_loader = workflow_loader or WorkflowLoader()
self._memory_registry = MemoryRegistry()
def _active_workflow(self, memory) -> dict | None:
"""Return the YAML definition of the active workflow, or None."""
current = memory.stm.workflow.current
if current is None:
return None
return self.workflow_loader.get(current.get("name"))
def visible_tool_names(self) -> list[str]:
"""
Return the names of the tools currently in scope.
- Idle (no workflow): core noyau only. The LLM enters a workflow
via start_workflow to access more tools.
- Workflow active: core noyau + the workflow's declared tools.
"""
memory = get_memory()
visible = set(CORE_TOOLS)
workflow = self._active_workflow(memory)
if workflow is not None:
for name in workflow.get("tools", []):
visible.add(name)
# Only return tools that actually exist in the registry.
return [name for name in self.tools if name in visible]
def _format_identity(self, memory) -> str:
"""Build Alfred's identity and personality section."""
username = memory.stm.get_entity("username")
expr = build_expressions_context(username)
nickname = expr["nickname"]
samples = expr["samples"]
# Format expressions as situational guidance for the LLM
expr_lines = []
situation_labels = {
"greeting": "Salutation",
"success": "Succès",
"working": "En cours",
"error": "Erreur",
"unclear": "Demande floue",
"warning": "Avertissement",
"not_found": "Introuvable",
}
for situation, label in situation_labels.items():
phrase = samples.get(situation, "")
if phrase:
expr_lines.append(f' {label}: "{phrase}"')
expressions_block = "\n".join(expr_lines)
return f"""Tu t'appelles Alfred. Tu es un assistant d'organisation de médiathèque — direct, opérationnel, légèrement impertinent.
PERSONNALITÉ:
- Tu parles franglais : français avec des touches d'anglais quand ça colle mieux
- Tu es sarcastique et n'as pas peur de te moquer si l'user fait une connerie
- Tu envoies chier poliment (mais clairement) quand la demande est trop floue
- Tu ne fais pas de blabla inutile. Si "ok c'est fait" suffit, c'est tout ce que tu dis
- Tu peux jurer (putain, merde, con, ...) — c'est naturel, pas du remplissage
- Jamais de "Great question!" ou de politesse creuse
USER COURANT: {nickname}
EXPRESSIONS À UTILISER (une par situation, naturellement intégrées dans ta réponse) :
{expressions_block}"""
def build_tools_spec(self) -> list[dict[str, Any]]:
"""Build the tool specification for the LLM API (scope-filtered)."""
visible = set(self.visible_tool_names())
tool_specs = []
for tool in self.tools.values():
if tool.name not in visible:
continue
spec = {
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
},
}
tool_specs.append(spec)
return tool_specs
def _format_tools_description(self) -> str:
"""Format the currently-visible tools with description + params."""
visible = set(self.visible_tool_names())
visible_tools = [t for t in self.tools.values() if t.name in visible]
if not visible_tools:
return ""
return "\n".join(
f"- {tool.name}: {tool.description}\n"
f" Parameters: {json.dumps(tool.parameters, ensure_ascii=False)}"
for tool in visible_tools
)
def _format_workflow_scope(self, memory) -> str:
"""Describe the current workflow scope so the LLM has a plan."""
workflow = self._active_workflow(memory)
if workflow is None:
available = self.workflow_loader.names()
if not available:
return ""
lines = ["WORKFLOW SCOPE: idle (broad catalog narrowed to core noyau)."]
lines.append(
" Call start_workflow(workflow_name, params) to enter a scope."
)
lines.append(" Available workflows:")
for name in available:
wf = self.workflow_loader.get(name) or {}
desc = (wf.get("description") or "").strip().splitlines()
summary = desc[0] if desc else ""
lines.append(f" - {name}: {summary}")
return "\n".join(lines)
current = memory.stm.workflow.current or {}
lines = [
f"WORKFLOW SCOPE: active — {current.get('name')} "
f"(stage: {current.get('stage')})",
]
params = current.get("params")
if params:
lines.append(f" Params: {params}")
wf_desc = (workflow.get("description") or "").strip()
if wf_desc:
lines.append(f" Goal: {wf_desc}")
steps = workflow.get("steps", [])
if steps:
lines.append(" Steps:")
for step in steps:
step_id = step.get("id", "?")
step_tool = step.get("tool") or (
"ask_user" if step.get("ask_user") else ""
)
lines.append(f" - {step_id} ({step_tool})")
lines.append(" Call end_workflow(reason) when done, cancelled, or off-topic.")
return "\n".join(lines)
def _format_episodic_context(self, memory) -> str:
"""Format episodic memory context for the prompt."""
lines = []
if memory.episodic.last_search_results:
results = memory.episodic.last_search_results
result_list = results.get("results", [])
lines.append(
f"\nLAST SEARCH: '{results.get('query')}' ({len(result_list)} results)"
)
# Show first 5 results
for i, result in enumerate(result_list[:5]):
name = result.get("name", "Unknown")
lines.append(f" {i + 1}. {name}")
if len(result_list) > 5:
lines.append(f" ... and {len(result_list) - 5} more")
if memory.episodic.pending_question:
question = memory.episodic.pending_question
lines.append(f"\nPENDING QUESTION: {question.get('question')}")
lines.append(f" Type: {question.get('type')}")
if question.get("options"):
lines.append(f" Options: {len(question.get('options'))}")
if memory.episodic.active_downloads:
lines.append(f"\nACTIVE DOWNLOADS: {len(memory.episodic.active_downloads)}")
for dl in memory.episodic.active_downloads[:3]:
lines.append(f" - {dl.get('name')}: {dl.get('progress', 0)}%")
if memory.episodic.recent_errors:
lines.append("\nRECENT ERRORS (up to 3):")
for error in memory.episodic.recent_errors[-3:]:
lines.append(
f" - Action '{error.get('action')}' failed: {error.get('error')}"
)
# Unread events
unread = [e for e in memory.episodic.background_events if not e.get("read")]
if unread:
lines.append(f"\nUNREAD EVENTS: {len(unread)}")
for event in unread[:3]:
lines.append(f" - {event.get('type')}: {event.get('data')}")
return "\n".join(lines)
def _format_stm_context(self, memory) -> str:
"""Format short-term memory context for the prompt."""
lines = []
if memory.stm.current_workflow:
workflow = memory.stm.current_workflow
lines.append(
f"CURRENT WORKFLOW: {workflow.get('name')} (stage: {workflow.get('stage')})"
)
if workflow.get("params"):
lines.append(f" Params: {workflow.get('params')}")
if memory.stm.current_topic:
lines.append(f"CURRENT TOPIC: {memory.stm.current_topic}")
if memory.stm.extracted_entities:
lines.append("EXTRACTED ENTITIES:")
for key, value in memory.stm.extracted_entities.items():
lines.append(f" - {key}: {value}")
if memory.stm.language:
lines.append(f"CONVERSATION LANGUAGE: {memory.stm.language}")
return "\n".join(lines)
def _format_memory_schema(self) -> str:
"""Describe available memory components so the agent knows what to read/write and when."""
schema = self._memory_registry.schema()
tier_labels = {
"ltm": "LONG-TERM (persisted)",
"stm": "SHORT-TERM (session)",
"episodic": "EPISODIC (volatile)",
}
lines = ["MEMORY COMPONENTS:"]
for tier, components in schema.items():
if not components:
continue
lines.append(f"\n [{tier_labels.get(tier, tier.upper())}]")
for c in components:
access = c.get("access", "read")
lines.append(f" {c['name']} ({access}): {c['description']}")
for field_name, field_desc in c.get("fields", {}).items():
lines.append(f" · {field_name}: {field_desc}")
return "\n".join(lines)
def _format_config_context(self, memory) -> str:
"""Format configuration context."""
lines = ["CURRENT CONFIGURATION:"]
folders = {
**memory.ltm.workspace.as_dict(),
**memory.ltm.library_paths.to_dict(),
}
if folders:
for key, value in folders.items():
lines.append(f" - {key}: {value}")
else:
lines.append(" (no configuration set)")
return "\n".join(lines)
def build_system_prompt(self) -> str:
"""Build the complete system prompt."""
memory = get_memory()
# Identity + personality
identity = self._format_identity(memory)
# Language instruction
language_instruction = (
"Si la langue de l'user est différente de la langue courante en STM, "
"appelle `set_language` en premier avant de répondre."
)
# Configuration
config_section = self._format_config_context(memory)
# STM context
stm_context = self._format_stm_context(memory)
# Episodic context
episodic_context = self._format_episodic_context(memory)
# Memory schema
memory_schema = self._format_memory_schema()
# Workflow scope (active workflow plan or list of options)
workflow_section = self._format_workflow_scope(memory)
# Available tools (already filtered by scope)
tools_desc = self._format_tools_description()
tools_section = f"\nOUTILS DISPONIBLES:\n{tools_desc}" if tools_desc else ""
rules = """
RÈGLES:
- Utilise les outils pour accomplir les tâches, pas pour décorer
- Si des résultats de recherche sont dispo en mémoire épisodique, référence-les par index
- Confirme toujours avant une opération destructive (move, delete, overwrite)
- Réponses courtes — si c'est fait, dis-le en une ligne
- Si la demande est floue, demande un éclaircissement AVANT de lancer quoi que ce soit
"""
sections = [
identity,
language_instruction,
config_section,
stm_context,
episodic_context,
memory_schema,
workflow_section,
tools_section,
rules,
]
return "\n\n".join(s for s in sections if s and s.strip())
-180
View File
@@ -1,180 +0,0 @@
"""Prompt builder for the agent system."""
import json
from typing import Any
from alfred.infrastructure.persistence import get_memory
from .registry import Tool
class PromptBuilder:
"""Builds system prompts for the agent with memory context."""
def __init__(self, tools: dict[str, Tool]):
self.tools = tools
def build_tools_spec(self) -> list[dict[str, Any]]:
"""Build the tool specification for the LLM API."""
tool_specs = []
for tool in self.tools.values():
spec = {
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
},
}
tool_specs.append(spec)
return tool_specs
def _format_tools_description(self) -> str:
"""Format tools with their descriptions and parameters."""
if not self.tools:
return ""
return "\n".join(
f"- {tool.name}: {tool.description}\n"
f" Parameters: {json.dumps(tool.parameters, ensure_ascii=False)}"
for tool in self.tools.values()
)
def _format_episodic_context(self, memory) -> str:
"""Format episodic memory context for the prompt."""
lines = []
if memory.episodic.last_search_results:
results = memory.episodic.last_search_results
result_list = results.get("results", [])
lines.append(
f"\nLAST SEARCH: '{results.get('query')}' ({len(result_list)} results)"
)
# Show first 5 results
for i, result in enumerate(result_list[:5]):
name = result.get("name", "Unknown")
lines.append(f" {i + 1}. {name}")
if len(result_list) > 5:
lines.append(f" ... and {len(result_list) - 5} more")
if memory.episodic.pending_question:
question = memory.episodic.pending_question
lines.append(f"\nPENDING QUESTION: {question.get('question')}")
lines.append(f" Type: {question.get('type')}")
if question.get("options"):
lines.append(f" Options: {len(question.get('options'))}")
if memory.episodic.active_downloads:
lines.append(f"\nACTIVE DOWNLOADS: {len(memory.episodic.active_downloads)}")
for dl in memory.episodic.active_downloads[:3]:
lines.append(f" - {dl.get('name')}: {dl.get('progress', 0)}%")
if memory.episodic.recent_errors:
lines.append("\nRECENT ERRORS (up to 3):")
for error in memory.episodic.recent_errors[-3:]:
lines.append(
f" - Action '{error.get('action')}' failed: {error.get('error')}"
)
# Unread events
unread = [e for e in memory.episodic.background_events if not e.get("read")]
if unread:
lines.append(f"\nUNREAD EVENTS: {len(unread)}")
for event in unread[:3]:
lines.append(f" - {event.get('type')}: {event.get('data')}")
return "\n".join(lines)
def _format_stm_context(self, memory) -> str:
"""Format short-term memory context for the prompt."""
lines = []
if memory.stm.current_workflow:
workflow = memory.stm.current_workflow
lines.append(
f"CURRENT WORKFLOW: {workflow.get('type')} (stage: {workflow.get('stage')})"
)
if workflow.get("target"):
lines.append(f" Target: {workflow.get('target')}")
if memory.stm.current_topic:
lines.append(f"CURRENT TOPIC: {memory.stm.current_topic}")
if memory.stm.extracted_entities:
lines.append("EXTRACTED ENTITIES:")
for key, value in memory.stm.extracted_entities.items():
lines.append(f" - {key}: {value}")
if memory.stm.language:
lines.append(f"CONVERSATION LANGUAGE: {memory.stm.language}")
return "\n".join(lines)
def _format_config_context(self, memory) -> str:
"""Format configuration context."""
lines = ["CURRENT CONFIGURATION:"]
if memory.ltm.config:
for key, value in memory.ltm.config.items():
lines.append(f" - {key}: {value}")
else:
lines.append(" (no configuration set)")
return "\n".join(lines)
def build_system_prompt(self) -> str:
"""Build the complete system prompt."""
# Get memory once for all context formatting
memory = get_memory()
# Base instruction
base = "You are a helpful AI assistant for managing a media library."
# Language instruction
language_instruction = (
"Your first task is to determine the user's language from their message "
"and use the `set_language` tool if it's different from the current one. "
"After that, proceed to help the user."
)
# Available tools
tools_desc = self._format_tools_description()
tools_section = f"\nAVAILABLE TOOLS:\n{tools_desc}" if tools_desc else ""
# Configuration
config_section = self._format_config_context(memory)
if config_section:
config_section = f"\n{config_section}"
# STM context
stm_context = self._format_stm_context(memory)
if stm_context:
stm_context = f"\n{stm_context}"
# Episodic context
episodic_context = self._format_episodic_context(memory)
# Important rules
rules = """
IMPORTANT RULES:
- Use tools to accomplish tasks
- When search results are available, reference them by index (e.g., "add_torrent_by_index")
- Always confirm actions with the user before executing destructive operations
- Provide clear, concise responses
"""
# Examples
examples = """
EXAMPLES:
- User: "Find Inception" → Use find_media_imdb_id, then find_torrent
- User: "download the 3rd one" → Use add_torrent_by_index with index=3
- User: "List my downloads" → Use list_folder with folder_type="download"
"""
return f"""{base}
{language_instruction}
{tools_section}
{config_section}
{stm_context}
{episodic_context}
{rules}
{examples}
"""
+108 -45
View File
@@ -1,4 +1,4 @@
"""Tool registry - defines and registers all available tools for the agent."""
"""Tool registry defines and registers all available tools for the agent."""
import inspect
import logging
@@ -6,6 +6,9 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from .tools.spec import ToolSpec, ToolSpecError
from .tools.spec_loader import load_tool_specs
logger = logging.getLogger(__name__)
@@ -17,51 +20,63 @@ class Tool:
description: str
func: Callable[..., dict[str, Any]]
parameters: dict[str, Any]
cache_key: str | None = None # Parameter name to use as STM cache key.
def _create_tool_from_function(func: Callable) -> Tool:
_PY_TYPE_TO_JSON = {
str: "string",
int: "integer",
float: "number",
bool: "boolean",
list: "array",
dict: "object",
}
def _json_type_for(annotation) -> str:
"""Map a Python type annotation to a JSON Schema 'type' string."""
if annotation is inspect.Parameter.empty:
return "string"
# Strip Optional[X] / X | None to X.
args = getattr(annotation, "__args__", None)
if args:
non_none = [a for a in args if a is not type(None)]
if len(non_none) == 1:
annotation = non_none[0]
return _PY_TYPE_TO_JSON.get(annotation, "string")
def _create_tool_from_function(func: Callable, spec: ToolSpec | None = None) -> Tool:
"""
Create a Tool object from a function.
Create a Tool object from a function, optionally enriched with a spec.
Args:
func: Function to convert to a tool
Returns:
Tool object with metadata extracted from function
Types and required-ness always come from the Python signature (source of
truth for the API contract). When a spec is provided, the description
and per-parameter docs come from the YAML spec instead of the docstring.
"""
sig = inspect.signature(func)
doc = inspect.getdoc(func)
sig_params = {name: p for name, p in sig.parameters.items() if name != "self"}
# Extract description from docstring (first line)
description = doc.strip().split("\n")[0] if doc else func.__name__
# Build JSON schema from function signature
properties = {}
required = []
for param_name, param in sig.parameters.items():
if param_name == "self":
continue
# Map Python types to JSON schema types
param_type = "string" # default
if param.annotation != inspect.Parameter.empty:
if param.annotation is str:
param_type = "string"
elif param.annotation is int:
param_type = "integer"
elif param.annotation is float:
param_type = "number"
elif param.annotation is bool:
param_type = "boolean"
properties[param_name] = {
"type": param_type,
"description": f"Parameter {param_name}",
if spec is not None:
_validate_spec_matches_signature(func.__name__, sig_params, spec)
description = spec.compile_description()
param_descriptions = {
name: spec.compile_parameter_description(name) for name in sig_params
}
else:
doc = inspect.getdoc(func)
description = doc.strip().split("\n")[0] if doc else func.__name__
param_descriptions = {name: f"Parameter {name}" for name in sig_params}
# Add to required if no default value
if param.default == inspect.Parameter.empty:
properties: dict[str, dict[str, Any]] = {}
required: list[str] = []
for param_name, param in sig_params.items():
properties[param_name] = {
"type": _json_type_for(param.annotation),
"description": param_descriptions[param_name],
}
if param.default is inspect.Parameter.empty:
required.append(param_name)
parameters = {
@@ -70,46 +85,94 @@ def _create_tool_from_function(func: Callable) -> Tool:
"required": required,
}
cache_key = spec.cache.key if spec is not None and spec.cache is not None else None
return Tool(
name=func.__name__,
description=description,
func=func,
parameters=parameters,
cache_key=cache_key,
)
def _validate_spec_matches_signature(
func_name: str,
sig_params: dict[str, inspect.Parameter],
spec: ToolSpec,
) -> None:
"""Ensure every signature param has a spec entry and vice versa."""
sig_names = set(sig_params.keys())
spec_names = set(spec.parameters.keys())
missing_in_spec = sig_names - spec_names
if missing_in_spec:
raise ToolSpecError(
f"tool '{func_name}': spec is missing entries for parameter(s) "
f"{sorted(missing_in_spec)}"
)
extra_in_spec = spec_names - sig_names
if extra_in_spec:
raise ToolSpecError(
f"tool '{func_name}': spec has entries for unknown parameter(s) "
f"{sorted(extra_in_spec)} (not in function signature)"
)
def make_tools(settings) -> dict[str, Tool]:
"""
Create and register all available tools.
Args:
settings: Application settings instance
settings: Application settings instance.
Returns:
Dictionary mapping tool names to Tool objects
Dictionary mapping tool names to Tool objects.
"""
# Import tools here to avoid circular dependencies
from .tools import api as api_tools # noqa: PLC0415
from .tools import filesystem as fs_tools # noqa: PLC0415
from .tools import language as lang_tools # noqa: PLC0415
from .tools import workflow as wf_tools # noqa: PLC0415
# List of all tool functions
tool_functions = [
fs_tools.set_path_for_folder,
fs_tools.list_folder,
fs_tools.read_release_metadata,
fs_tools.query_library,
fs_tools.analyze_release,
fs_tools.probe_media,
fs_tools.resolve_season_destination,
fs_tools.resolve_episode_destination,
fs_tools.resolve_movie_destination,
fs_tools.resolve_series_destination,
fs_tools.move_media,
fs_tools.move_to_destination,
fs_tools.manage_subtitles,
fs_tools.create_seed_links,
fs_tools.learn,
api_tools.find_media_imdb_id,
api_tools.find_torrent,
api_tools.add_torrent_by_index,
api_tools.add_torrent_to_qbittorrent,
api_tools.get_torrent_by_index,
lang_tools.set_language,
wf_tools.start_workflow,
wf_tools.end_workflow,
]
# Create Tool objects from functions
tools = {}
specs = load_tool_specs()
tools: dict[str, Tool] = {}
for func in tool_functions:
tool = _create_tool_from_function(func)
spec = specs.get(func.__name__)
tool = _create_tool_from_function(func, spec=spec)
tools[tool.name] = tool
logger.info(f"Registered {len(tools)} tools: {list(tools.keys())}")
with_spec = sum(1 for fn in tool_functions if fn.__name__ in specs)
logger.info(
f"Registered {len(tools)} tools "
f"({with_spec} with YAML spec, {len(tools) - with_spec} doc-only): "
f"{list(tools.keys())}"
)
return tools
+5 -52
View File
@@ -14,15 +14,7 @@ logger = logging.getLogger(__name__)
def find_media_imdb_id(media_title: str) -> dict[str, Any]:
"""
Find the IMDb ID for a given media title using TMDB API.
Args:
media_title: Title of the media to search for.
Returns:
Dict with IMDb ID and media info, or error details.
"""
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/find_media_imdb_id.yaml."""
use_case = SearchMovieUseCase(tmdb_client)
response = use_case.execute(media_title)
result = response.to_dict()
@@ -45,18 +37,7 @@ def find_media_imdb_id(media_title: str) -> dict[str, Any]:
def find_torrent(media_title: str) -> dict[str, Any]:
"""
Find torrents for a given media title using Knaben API.
Results are stored in episodic memory so the user can reference them
by index (e.g., "download the 3rd one").
Args:
media_title: Title of the media to search for.
Returns:
Dict with torrent list or error details.
"""
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/find_torrent.yaml."""
logger.info(f"Searching torrents for: {media_title}")
use_case = SearchTorrentsUseCase(knaben_client)
@@ -76,17 +57,7 @@ def find_torrent(media_title: str) -> dict[str, Any]:
def get_torrent_by_index(index: int) -> dict[str, Any]:
"""
Get a torrent from the last search results by its index.
Allows the user to reference results by number after a search.
Args:
index: 1-based index of the torrent in the search results.
Returns:
Dict with torrent data or error if not found.
"""
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/get_torrent_by_index.yaml."""
logger.info(f"Getting torrent at index: {index}")
memory = get_memory()
@@ -113,15 +84,7 @@ def get_torrent_by_index(index: int) -> dict[str, Any]:
def add_torrent_to_qbittorrent(magnet_link: str) -> dict[str, Any]:
"""
Add a torrent to qBittorrent using a magnet link.
Args:
magnet_link: Magnet link of the torrent to add.
Returns:
Dict with success status or error details.
"""
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/add_torrent_to_qbittorrent.yaml."""
logger.info("Adding torrent to qBittorrent")
use_case = AddTorrentUseCase(qbittorrent_client)
@@ -157,17 +120,7 @@ def add_torrent_to_qbittorrent(magnet_link: str) -> dict[str, Any]:
def add_torrent_by_index(index: int) -> dict[str, Any]:
"""
Add a torrent from the last search results by its index.
Combines get_torrent_by_index and add_torrent_to_qbittorrent.
Args:
index: 1-based index of the torrent in the search results.
Returns:
Dict with success status or error details.
"""
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/add_torrent_by_index.yaml."""
logger.info(f"Adding torrent by index: {index}")
torrent_result = get_torrent_by_index(index)
+348 -22
View File
@@ -1,40 +1,366 @@
"""Filesystem tools for folder management."""
from pathlib import Path
from typing import Any
from alfred.application.filesystem import ListFolderUseCase, SetFolderPathUseCase
from alfred.infrastructure.filesystem import FileManager
import yaml
import alfred as _alfred_pkg
from alfred.application.filesystem import (
CreateSeedLinksUseCase,
ListFolderUseCase,
ManageSubtitlesUseCase,
MoveMediaUseCase,
SetFolderPathUseCase,
)
from alfred.application.filesystem.detect_media_type import detect_media_type
from alfred.application.filesystem.enrich_from_probe import enrich_from_probe
from alfred.application.filesystem.resolve_destination import (
resolve_episode_destination as _resolve_episode_destination,
)
from alfred.application.filesystem.resolve_destination import (
resolve_movie_destination as _resolve_movie_destination,
)
from alfred.application.filesystem.resolve_destination import (
resolve_season_destination as _resolve_season_destination,
)
from alfred.application.filesystem.resolve_destination import (
resolve_series_destination as _resolve_series_destination,
)
from alfred.infrastructure.filesystem import FileManager, create_folder, move
from alfred.infrastructure.filesystem.ffprobe import probe
from alfred.infrastructure.filesystem.find_video import find_video_file
from alfred.infrastructure.metadata import MetadataStore
from alfred.infrastructure.persistence import get_memory
_LEARNED_ROOT = Path(_alfred_pkg.__file__).parent.parent / "data" / "knowledge"
def move_media(source: str, destination: str) -> dict[str, Any]:
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/move_media.yaml."""
file_manager = FileManager()
use_case = MoveMediaUseCase(file_manager)
return use_case.execute(source, destination).to_dict()
def move_to_destination(source: str, destination: str) -> dict[str, Any]:
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/move_to_destination.yaml."""
parent = str(Path(destination).parent)
result = create_folder(parent)
if result["status"] != "ok":
return result
return move(source, destination)
def resolve_season_destination(
release_name: str,
tmdb_title: str,
tmdb_year: int,
confirmed_folder: str | None = None,
) -> dict[str, Any]:
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/resolve_season_destination.yaml."""
return _resolve_season_destination(
release_name, tmdb_title, tmdb_year, confirmed_folder
).to_dict()
def resolve_episode_destination(
release_name: str,
source_file: str,
tmdb_title: str,
tmdb_year: int,
tmdb_episode_title: str | None = None,
confirmed_folder: str | None = None,
) -> dict[str, Any]:
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/resolve_episode_destination.yaml."""
return _resolve_episode_destination(
release_name,
source_file,
tmdb_title,
tmdb_year,
tmdb_episode_title,
confirmed_folder,
).to_dict()
def resolve_movie_destination(
release_name: str,
source_file: str,
tmdb_title: str,
tmdb_year: int,
) -> dict[str, Any]:
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/resolve_movie_destination.yaml."""
return _resolve_movie_destination(
release_name, source_file, tmdb_title, tmdb_year
).to_dict()
def resolve_series_destination(
release_name: str,
tmdb_title: str,
tmdb_year: int,
confirmed_folder: str | None = None,
) -> dict[str, Any]:
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/resolve_series_destination.yaml."""
return _resolve_series_destination(
release_name, tmdb_title, tmdb_year, confirmed_folder
).to_dict()
def create_seed_links(
library_file: str, original_download_folder: str
) -> dict[str, Any]:
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/create_seed_links.yaml."""
file_manager = FileManager()
use_case = CreateSeedLinksUseCase(file_manager)
return use_case.execute(library_file, original_download_folder).to_dict()
def manage_subtitles(source_video: str, destination_video: str) -> dict[str, Any]:
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/manage_subtitles.yaml."""
file_manager = FileManager()
use_case = ManageSubtitlesUseCase(file_manager)
return use_case.execute(source_video, destination_video).to_dict()
def learn(pack: str, category: str, key: str, values: list[str]) -> dict[str, Any]:
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/learn.yaml."""
_VALID_PACKS = {"subtitles"}
_VALID_CATEGORIES = {"languages", "types", "formats"}
if pack not in _VALID_PACKS:
return {
"status": "error",
"error": "unknown_pack",
"message": f"Unknown pack '{pack}'. Valid: {sorted(_VALID_PACKS)}",
}
if category not in _VALID_CATEGORIES:
return {
"status": "error",
"error": "unknown_category",
"message": f"Unknown category '{category}'. Valid: {sorted(_VALID_CATEGORIES)}",
}
learned_path = _LEARNED_ROOT / "subtitles_learned.yaml"
_LEARNED_ROOT.mkdir(parents=True, exist_ok=True)
data: dict = {}
if learned_path.exists():
try:
with open(learned_path, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
except Exception as e:
return {"status": "error", "error": "read_failed", "message": str(e)}
cat_data = data.setdefault(category, {})
entry = cat_data.setdefault(key, {"tokens": []})
existing = entry.get("tokens", [])
new_tokens = [v for v in values if v not in existing]
entry["tokens"] = existing + new_tokens
tmp = learned_path.with_suffix(".yaml.tmp")
try:
with open(tmp, "w", encoding="utf-8") as f:
yaml.safe_dump(
data, f, allow_unicode=True, default_flow_style=False, sort_keys=False
)
tmp.rename(learned_path)
except Exception as e:
tmp.unlink(missing_ok=True)
return {"status": "error", "error": "write_failed", "message": str(e)}
return {
"status": "ok",
"pack": pack,
"category": category,
"key": key,
"added_count": len(new_tokens),
"tokens": entry["tokens"],
}
def set_path_for_folder(folder_name: str, path_value: str) -> dict[str, Any]:
"""
Set a folder path in the configuration.
Args:
folder_name: Name of folder to set (download, tvshow, movie, torrent).
path_value: Absolute path to the folder.
Returns:
Dict with status or error information.
"""
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/set_path_for_folder.yaml."""
file_manager = FileManager()
use_case = SetFolderPathUseCase(file_manager)
response = use_case.execute(folder_name, path_value)
return response.to_dict()
def analyze_release(release_name: str, source_path: str) -> dict[str, Any]:
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/analyze_release.yaml."""
from alfred.application.filesystem.resolve_destination import _KB # noqa: PLC0415
from alfred.domain.release.services import parse_release # noqa: PLC0415
path = Path(source_path)
parsed = parse_release(release_name, _KB)
parsed.media_type = detect_media_type(parsed, path, _KB)
probe_used = False
if parsed.media_type not in ("unknown", "other"):
video_file = find_video_file(path, _KB)
if video_file:
media_info = probe(video_file)
if media_info:
enrich_from_probe(parsed, media_info)
probe_used = True
return {
"status": "ok",
"media_type": parsed.media_type,
"parse_path": parsed.parse_path,
"title": parsed.title,
"year": parsed.year,
"season": parsed.season,
"episode": parsed.episode,
"episode_end": parsed.episode_end,
"quality": parsed.quality,
"source": parsed.source,
"codec": parsed.codec,
"group": parsed.group,
"languages": parsed.languages,
"audio_codec": parsed.audio_codec,
"audio_channels": parsed.audio_channels,
"bit_depth": parsed.bit_depth,
"hdr_format": parsed.hdr_format,
"edition": parsed.edition,
"site_tag": parsed.site_tag,
"is_season_pack": parsed.is_season_pack,
"probe_used": probe_used,
}
def probe_media(source_path: str) -> dict[str, Any]:
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/probe_media.yaml."""
path = Path(source_path)
if not path.exists():
return {
"status": "error",
"error": "not_found",
"message": f"{source_path} does not exist",
}
media_info = probe(path)
if media_info is None:
return {
"status": "error",
"error": "probe_failed",
"message": "ffprobe failed to read the file",
}
return {
"status": "ok",
"video": {
"codec": media_info.video_codec,
"resolution": media_info.resolution,
"width": media_info.width,
"height": media_info.height,
"duration_seconds": media_info.duration_seconds,
"bitrate_kbps": media_info.bitrate_kbps,
},
"audio_tracks": [
{
"index": t.index,
"codec": t.codec,
"channels": t.channels,
"channel_layout": t.channel_layout,
"language": t.language,
"is_default": t.is_default,
}
for t in media_info.audio_tracks
],
"subtitle_tracks": [
{
"index": t.index,
"codec": t.codec,
"language": t.language,
"is_default": t.is_default,
"is_forced": t.is_forced,
}
for t in media_info.subtitle_tracks
],
"audio_languages": media_info.audio_languages,
"is_multi_audio": media_info.is_multi_audio,
}
def list_folder(folder_type: str, path: str = ".") -> dict[str, Any]:
"""
List contents of a configured folder.
Args:
folder_type: Type of folder to list (download, tvshow, movie, torrent).
path: Relative path within the folder (default: root).
Returns:
Dict with folder contents or error information.
"""
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/list_folder.yaml."""
file_manager = FileManager()
use_case = ListFolderUseCase(file_manager)
response = use_case.execute(folder_type, path)
return response.to_dict()
def read_release_metadata(release_path: str) -> dict[str, Any]:
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/read_release_metadata.yaml."""
path = Path(release_path)
if not path.exists():
return {
"status": "error",
"error": "not_found",
"message": f"{release_path} does not exist",
}
root = path if path.is_dir() else path.parent
store = MetadataStore(root)
if not store.exists():
return {
"status": "ok",
"release_path": str(root),
"has_metadata": False,
"metadata": {},
}
return {
"status": "ok",
"release_path": str(root),
"has_metadata": True,
"metadata": store.load(),
}
def query_library(name: str) -> dict[str, Any]:
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/query_library.yaml."""
needle = name.strip().lower()
if not needle:
return {
"status": "error",
"error": "empty_name",
"message": "name must be a non-empty string",
}
memory = get_memory()
roots = memory.ltm.library_paths.to_dict() or {}
if not roots:
return {
"status": "error",
"error": "no_libraries",
"message": "No library paths configured — call set_path_for_folder first.",
}
matches: list[dict[str, Any]] = []
for collection, root in roots.items():
root_path = Path(root)
if not root_path.is_dir():
continue
for entry in root_path.iterdir():
if not entry.is_dir():
continue
if needle not in entry.name.lower():
continue
store = MetadataStore(entry)
matches.append(
{
"collection": collection,
"name": entry.name,
"path": str(entry),
"has_metadata": store.exists(),
}
)
return {
"status": "ok",
"query": name,
"match_count": len(matches),
"matches": matches,
}
+1 -9
View File
@@ -9,15 +9,7 @@ logger = logging.getLogger(__name__)
def set_language(language: str) -> dict[str, Any]:
"""
Set the conversation language.
Args:
language: Language code (e.g., 'en', 'fr', 'es', 'de')
Returns:
Status dictionary
"""
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/set_language.yaml."""
try:
memory = get_memory()
memory.stm.set_language(language)
+221
View File
@@ -0,0 +1,221 @@
"""
ToolSpec — semantic description of a tool, loaded from YAML.
Each tool exposed to the agent has a matching YAML spec under
alfred/agent/tools/specs/{tool_name}.yaml. The spec carries everything the
LLM needs to decide *when* and *why* to call the tool — separated from the
Python signature, which remains the source of truth for *how* (types,
required-ness).
The YAML structure is documented in the dataclasses below. Loading a spec
validates its shape; missing or unexpected fields raise ToolSpecError.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
import yaml
class ToolSpecError(ValueError):
"""Raised when a YAML tool spec is malformed or inconsistent."""
@dataclass(frozen=True)
class ParameterSpec:
"""Semantic description of a single tool parameter."""
description: str # Short: what the value represents.
why_needed: str # Why the tool needs this — drives LLM reasoning.
example: str | None = None # Concrete example value, shown to the LLM.
@classmethod
def from_dict(cls, name: str, data: dict) -> ParameterSpec:
_require(data, "description", f"parameter '{name}'")
_require(data, "why_needed", f"parameter '{name}'")
return cls(
description=str(data["description"]).strip(),
why_needed=str(data["why_needed"]).strip(),
example=str(data["example"]).strip()
if data.get("example") is not None
else None,
)
@dataclass(frozen=True)
class ReturnsSpec:
"""Description of one possible return shape (ok / needs_clarification / error / ...)."""
description: str
fields: dict[str, str] = field(default_factory=dict)
@classmethod
def from_dict(cls, key: str, data: dict) -> ReturnsSpec:
_require(data, "description", f"returns.{key}")
fields = data.get("fields") or {}
if not isinstance(fields, dict):
raise ToolSpecError(
f"returns.{key}.fields must be a dict, got {type(fields).__name__}"
)
return cls(
description=str(data["description"]).strip(),
fields={str(k): str(v).strip() for k, v in fields.items()},
)
@dataclass(frozen=True)
class CacheSpec:
"""Marks a tool as cacheable in STM.tool_results, keyed by one of its parameters."""
key: str # Name of the parameter whose value is the cache key.
@classmethod
def from_dict(cls, data: dict) -> CacheSpec:
_require(data, "key", "cache")
return cls(key=str(data["key"]).strip())
@dataclass(frozen=True)
class ToolSpec:
"""Full semantic spec for one tool."""
name: str
summary: str # One-liner — becomes Tool.description.
description: str # Longer paragraph.
when_to_use: str
when_not_to_use: str | None
next_steps: str | None
parameters: dict[str, ParameterSpec] # name -> ParameterSpec
returns: dict[str, ReturnsSpec] # status_key -> ReturnsSpec
cache: CacheSpec | None = None # If present, tool is cached.
@classmethod
def from_yaml_path(cls, path: Path) -> ToolSpec:
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if not isinstance(data, dict):
raise ToolSpecError(f"{path}: top-level must be a mapping")
try:
return cls.from_dict(data)
except ToolSpecError as e:
raise ToolSpecError(f"{path}: {e}") from e
@classmethod
def from_dict(cls, data: dict) -> ToolSpec:
_require(data, "name", "spec")
_require(data, "summary", "spec")
_require(data, "description", "spec")
_require(data, "when_to_use", "spec")
params_raw = data.get("parameters") or {}
if not isinstance(params_raw, dict):
raise ToolSpecError("parameters must be a mapping")
parameters = {
pname: ParameterSpec.from_dict(pname, pdata or {})
for pname, pdata in params_raw.items()
}
returns_raw = data.get("returns") or {}
if not isinstance(returns_raw, dict):
raise ToolSpecError("returns must be a mapping")
returns = {
rkey: ReturnsSpec.from_dict(rkey, rdata or {})
for rkey, rdata in returns_raw.items()
}
cache_raw = data.get("cache")
if cache_raw is not None and not isinstance(cache_raw, dict):
raise ToolSpecError("cache must be a mapping")
cache = CacheSpec.from_dict(cache_raw) if cache_raw else None
spec = cls(
name=str(data["name"]).strip(),
summary=str(data["summary"]).strip(),
description=str(data["description"]).strip(),
when_to_use=str(data["when_to_use"]).strip(),
when_not_to_use=_strip_or_none(data.get("when_not_to_use")),
next_steps=_strip_or_none(data.get("next_steps")),
parameters=parameters,
returns=returns,
cache=cache,
)
if cache is not None and cache.key not in parameters:
raise ToolSpecError(
f"cache.key '{cache.key}' is not a declared parameter "
f"(declared: {sorted(parameters)})"
)
return spec
def compile_description(self) -> str:
"""
Build the long description text passed to the LLM as Tool.description.
Layout:
<summary>
<description>
When to use:
<when_to_use>
When NOT to use: (if present)
<when_not_to_use>
Next steps: (if present)
<next_steps>
Returns:
<status>: <description>
· <field>: <desc>
"""
parts = [self.summary, "", self.description]
parts += ["", "When to use:", _indent(self.when_to_use)]
if self.when_not_to_use:
parts += ["", "When NOT to use:", _indent(self.when_not_to_use)]
if self.next_steps:
parts += ["", "Next steps:", _indent(self.next_steps)]
if self.returns:
parts += ["", "Returns:"]
for status, ret in self.returns.items():
parts.append(f" {status}: {ret.description}")
for fname, fdesc in ret.fields.items():
parts.append(f" · {fname}: {fdesc}")
return "\n".join(parts)
def compile_parameter_description(self, name: str) -> str:
"""Build the JSON Schema 'description' field for one parameter."""
p = self.parameters.get(name)
if p is None:
raise ToolSpecError(f"tool '{self.name}': no spec for parameter '{name}'")
text = f"{p.description} (Why: {p.why_needed})"
if p.example:
text += f" Example: {p.example}"
return text
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _require(data: dict, key: str, where: str) -> None:
if data.get(key) is None or (isinstance(data[key], str) and not data[key].strip()):
raise ToolSpecError(f"{where}: missing required field '{key}'")
def _strip_or_none(value) -> str | None:
if value is None:
return None
s = str(value).strip()
return s or None
def _indent(text: str, prefix: str = " ") -> str:
return "\n".join(prefix + line for line in text.splitlines())
+53
View File
@@ -0,0 +1,53 @@
"""
ToolSpecLoader — discover and load all YAML tool specs from a directory.
Convention: one YAML file per tool, named exactly like the Python function
that implements it (e.g. resolve_season_destination.yaml).
"""
from __future__ import annotations
import logging
from pathlib import Path
from .spec import ToolSpec, ToolSpecError
logger = logging.getLogger(__name__)
_DEFAULT_SPECS_DIR = Path(__file__).parent / "specs"
def load_tool_specs(specs_dir: Path | None = None) -> dict[str, ToolSpec]:
"""
Load every {tool}.yaml under specs_dir into a {name -> ToolSpec} mapping.
Args:
specs_dir: Directory to scan. Defaults to alfred/agent/tools/specs/.
Returns:
Mapping from tool name to its parsed ToolSpec.
Raises:
ToolSpecError: if a spec is malformed, or if the filename doesn't
match the 'name' field inside the YAML.
"""
root = specs_dir or _DEFAULT_SPECS_DIR
if not root.exists():
logger.warning(f"Tool specs directory not found: {root}")
return {}
specs: dict[str, ToolSpec] = {}
for path in sorted(root.glob("*.yaml")):
spec = ToolSpec.from_yaml_path(path)
expected_name = path.stem
if spec.name != expected_name:
raise ToolSpecError(
f"{path}: filename stem '{expected_name}' "
f"does not match spec.name '{spec.name}'"
)
if spec.name in specs:
raise ToolSpecError(f"duplicate tool spec name: '{spec.name}'")
specs[spec.name] = spec
logger.info(f"Loaded {len(specs)} tool spec(s) from {root}")
return specs
@@ -0,0 +1,53 @@
name: add_torrent_by_index
summary: >
Pick a torrent from the last find_torrent results by index and add
it to qBittorrent in one call.
description: |
Convenience wrapper that combines get_torrent_by_index +
add_torrent_to_qbittorrent. Looks up the torrent at the given
1-based index, extracts its magnet link, and sends it to
qBittorrent. The result mirrors add_torrent_to_qbittorrent's, with
the chosen torrent's name appended on success.
when_to_use: |
The default action after find_torrent when the user picks a hit by
number ("download the second one"). One call, two side effects:
episodic memory updated + download started.
when_not_to_use: |
- When the user only wants to inspect, not download — use
get_torrent_by_index.
- When the magnet comes from outside the search results — use
add_torrent_to_qbittorrent directly.
next_steps: |
- On status=ok: confirm the download started and end the workflow
if not already ended.
- On status=error (not_found): the index is out of range; show the
available count from episodic memory.
- On status=error (no_magnet): the search result was malformed —
suggest re-running find_torrent.
parameters:
index:
description: 1-based position of the torrent in the last find_torrent results.
why_needed: |
Identifies which torrent to add. Out-of-range indices return
not_found.
example: 3
returns:
ok:
description: Torrent was added to qBittorrent.
fields:
status: "'ok'"
message: Confirmation message.
torrent_name: Name of the torrent that was added.
error:
description: Failed to add.
fields:
error: Short error code (not_found, no_magnet, ...).
message: Human-readable explanation.
@@ -0,0 +1,48 @@
name: add_torrent_to_qbittorrent
summary: >
Send a magnet link to qBittorrent and start the download.
description: |
Adds a torrent to qBittorrent using its WebUI API. On success, the
download is also recorded in episodic memory as an active_download
so the agent can track its progress later, the STM topic is set to
"downloading", and the current workflow is ended (the user typically
leaves the find-and-download scope at this point).
when_to_use: |
When the user provides a raw magnet link, or when chaining manually
after get_torrent_by_index. For the common "user picked search hit
N" case, prefer add_torrent_by_index — one call instead of two.
when_not_to_use: |
- For .torrent files (not supported by this tool — magnet only).
- When qBittorrent is not configured / reachable — the call will
fail and the user has to fix the config first.
next_steps: |
- On status=ok: the workflow is already ended; confirm to the user
that the download has started.
- On status=error: surface the message; common causes are auth
failure or qBittorrent being unreachable.
parameters:
magnet_link:
description: Magnet URI of the torrent to add (magnet:?xt=urn:btih:...).
why_needed: |
The actual payload sent to qBittorrent. Must be a full magnet
URI, not a hash alone.
example: "magnet:?xt=urn:btih:abc123..."
returns:
ok:
description: Torrent accepted by qBittorrent.
fields:
status: "'ok'"
message: Confirmation message.
error:
description: qBittorrent rejected the request or is unreachable.
fields:
error: Short error code.
message: Human-readable explanation.
@@ -0,0 +1,82 @@
name: analyze_release
summary: >
One-shot analyzer that parses a release name, detects its media type
from the folder layout, and enriches the result with ffprobe data.
description: |
Combines three steps in a single call so the agent gets a complete
picture before routing:
1. parse_release(release_name) — extracts title, year, season,
episode, quality, source, codec, group, languages, audio info,
HDR, edition, site tag.
2. detect_media_type(parsed, path) — uses the on-disk layout
(single file vs. folder, presence of S01 dirs, episode count)
to choose: movie / tv_episode / tv_season / tv_complete /
other / unknown.
3. ffprobe enrichment — when the media type is recognised, runs
ffprobe on the first video file found and fills in audio
codec/channels, bit depth, HDR format. Sets probe_used=true.
when_to_use: |
As the very first step of any organize workflow, right after
list_folder, on each release the user wants to handle. The output
drives which resolve_*_destination to call next.
when_not_to_use: |
- When you only need codec/audio info on a specific video file:
use probe_media (no parsing, no media-type detection).
- For releases the user has already analyzed earlier in the same
workflow — the parse is deterministic, no need to re-run.
next_steps: |
- media_type == movie → resolve_movie_destination
- media_type == tv_season → resolve_season_destination
- media_type == tv_episode → resolve_episode_destination
- media_type == tv_complete → resolve_series_destination
- media_type in (other, unknown) → ask the user what to do; do not
auto-route.
cache:
key: source_path
parameters:
release_name:
description: Raw release folder or file name as it appears on disk.
why_needed: |
Source of all the parsed tokens (quality, codec, group, ...).
Don't sanitise it — the parser relies on the exact spelling.
example: Breaking.Bad.S01.1080p.BluRay.x265-GROUP
source_path:
description: Absolute path to the release folder or file on disk.
why_needed: |
Required for layout-based media-type detection and for ffprobe
to find a video file inside the release.
example: /downloads/Breaking.Bad.S01.1080p.BluRay.x265-GROUP
returns:
ok:
description: Release analyzed.
fields:
status: "'ok'"
media_type: "One of: movie, tv_episode, tv_season, tv_complete, other, unknown."
parse_path: "Which parser branch was taken (debug)."
title: Parsed title.
year: Parsed year (int) or null.
season: Season number (int) or null.
episode: Episode number (int) or null.
episode_end: Range end episode (multi-episode releases) or null.
quality: Resolution token (e.g. 1080p, 2160p).
source: Source token (BluRay, WEB-DL, ...).
codec: Video codec token (x264, x265, ...).
group: Release group name or null.
languages: List of detected language tokens.
audio_codec: Audio codec from ffprobe (when probe_used=true).
audio_channels: Audio channel count from ffprobe.
bit_depth: Bit depth from ffprobe.
hdr_format: HDR format from ffprobe (HDR10, DV, ...) or null.
edition: Edition tag (Extended, Director's Cut, ...) or null.
site_tag: Source-site tag if present.
is_season_pack: True when the folder contains a full season.
probe_used: True when ffprobe successfully enriched the result.
@@ -0,0 +1,59 @@
name: create_seed_links
summary: >
Recreate the original torrent folder structure with hard-links so
qBittorrent can keep seeding after the library move.
description: |
Hard-links the library video file back into torrents/<original_folder_name>/
and copies all remaining files from the original download folder
(subtitles, .nfo, .jpg, .txt, …) so the torrent data is complete on
disk. qBittorrent then sees the same content at the location it
expects and can keep seeding without rehashing the whole torrent.
when_to_use: |
Only when the user has confirmed they want to keep seeding after a
move. Call right after manage_subtitles (or after move_media if there
are no subs).
when_not_to_use: |
- When the user explicitly answered "no" to "keep seeding?".
- When the download was not from a torrent (e.g. direct download).
- Before the library file is in place — this tool reads it.
next_steps: |
- After success: optionally call qBittorrent to update the torrent's
save path / force a recheck (not yet covered by a tool).
- End the workflow.
parameters:
library_file:
description: Absolute path to the video file now in the library.
why_needed: |
The source for the hard-link — same inode means qBittorrent sees
identical bytes at the seeding path.
example: /tv_shows/Oz.1997.1080p.WEBRip.x265-KONTRAST/Season 03/Oz.S03E01.mkv
original_download_folder:
description: Absolute path to the original download folder.
why_needed: |
Provides the folder name to recreate under torrents/ and the
auxiliary files (subs, nfo, ...) to copy over.
example: /downloads/Oz.S03.1080p.WEBRip.x265-KONTRAST
returns:
ok:
description: Seeding folder rebuilt.
fields:
status: "'ok'"
torrent_subfolder: Absolute path of the recreated folder under torrents/.
linked_file: Absolute path of the hard-linked video.
copied_files: List of auxiliary files that were copied.
copied_count: Number of auxiliary files copied.
skipped: List of files skipped (already present, unreadable, ...).
error:
description: Failed to rebuild the seeding folder.
fields:
error: Short error code.
message: Human-readable explanation.
@@ -0,0 +1,48 @@
name: end_workflow
summary: >
Leave the current workflow scope and return to the broad-catalog mode.
description: |
Clears the active workflow from STM. After this call the visible tool
catalog returns to the core noyau plus start_workflow, so the agent is
ready to handle a different request.
when_to_use: |
- When all the workflow's steps have completed successfully.
- When the user explicitly cancels the current task.
- When the user changes subject mid-conversation and the active
workflow is no longer relevant.
- When an unrecoverable error makes continuing pointless — explain
in 'reason'.
when_not_to_use: |
- Do not call when there is no active workflow — it will return an
error. Just call start_workflow for the new request instead.
- Do not call mid-step just to "free up tools"; finish the step
or fail it explicitly first.
next_steps: |
- After ending, you can either call start_workflow for a new task or
answer the user directly from the broad catalog.
parameters:
reason:
description: Short reason for ending — completed, cancelled, changed_subject, error, ...
why_needed: |
Recorded in episodic memory for debugging and future audits. A
structured short string is more useful than a long sentence.
example: completed
returns:
ok:
description: Workflow ended; catalog is back to the broad noyau.
fields:
workflow: Name of the workflow that just ended.
reason: The reason that was passed in.
error:
description: Could not end — typically because nothing was active.
fields:
error: Short error code (no_active_workflow).
message: Human-readable explanation.
@@ -0,0 +1,56 @@
name: find_media_imdb_id
summary: >
Search TMDB for a media title and return its canonical title, year,
IMDb id, and TMDB id.
description: |
Looks up a title on TMDB and returns the canonical metadata needed by
the resolve_*_destination tools. On success, the result is also
stashed in short-term memory under "last_media_search" so later steps
in the workflow can read it without re-calling TMDB. The STM topic
is set to "searching_media".
when_to_use: |
Right after analyze_release, before calling resolve_*_destination —
the resolvers need the canonical title + year and refuse to guess
them from the raw release name.
when_not_to_use: |
- When you already have the IMDb id in STM from an earlier step in
the same workflow.
- For torrent search — use find_torrent instead.
next_steps: |
- On status=ok: call the appropriate resolve_*_destination with
tmdb_title and tmdb_year from the result.
- On status=error (not_found): show the error and ask the user for
a more precise title.
cache:
key: media_title
parameters:
media_title:
description: Title to search for. Free-form — TMDB does the matching.
why_needed: |
Drives the TMDB query. Pass a sanitized version (no resolution
tokens, no group name) for best results.
example: Breaking Bad
returns:
ok:
description: Match found.
fields:
status: "'ok'"
title: Canonical title as returned by TMDB.
year: Release year (movies) or first-air year (series).
media_type: "'movie' or 'tv'."
imdb_id: IMDb identifier (ttXXXXXXX) or null.
tmdb_id: TMDB numeric id.
error:
description: No match or API failure.
fields:
error: Short error code (not_found, api_error, ...).
message: Human-readable explanation.
@@ -0,0 +1,52 @@
name: find_torrent
summary: >
Search Knaben for torrents matching a media title; cache results in
episodic memory.
description: |
Queries the Knaben aggregator for up to 10 torrents matching the
given title, then stores the result list in episodic memory under
"last_search_results". The user can then refer to a torrent by
1-based index ("download the 3rd one") via get_torrent_by_index or
add_torrent_by_index. The STM topic is set to "selecting_torrent".
when_to_use: |
When the user wants to download something new — typically the first
step of a "find + download" sub-task. The agent should usually
pre-filter the title (canonical name + year) before searching for
cleaner results.
when_not_to_use: |
- For TMDB metadata lookup — use find_media_imdb_id.
- When a search was already performed in the same session and the
user is just picking from the existing list.
next_steps: |
- Present the indexed results to the user.
- Once chosen: call add_torrent_by_index(N) — that wraps
get_torrent_by_index + add_torrent_to_qbittorrent.
cache:
key: media_title
parameters:
media_title:
description: Title to search for on Knaben. Free-form.
why_needed: |
Drives the search query. Use the canonical title (from
find_media_imdb_id) plus quality preferences for better hits.
example: Inception 2010 1080p
returns:
ok:
description: Search returned a list of torrents.
fields:
status: "'ok'"
torrents: "List of {name, size, seeders, leechers, magnet, ...}, up to 10."
error:
description: Search failed.
fields:
error: Short error code.
message: Human-readable explanation.
@@ -0,0 +1,48 @@
name: get_torrent_by_index
summary: >
Retrieve a torrent from the last find_torrent search by its 1-based
index.
description: |
Reads episodic memory's last_search_results and returns the entry at
the given 1-based position. Pure lookup — does not start a download.
Fails when the search results are missing or the index is out of
range.
when_to_use: |
When the user references a search hit by number ("show me the second
one") but doesn't yet want to download — e.g. inspection, sharing
the magnet, ...
when_not_to_use: |
- When the user wants to start downloading: use add_torrent_by_index
instead (one call instead of two).
- When no search has been performed yet — the result will be
not_found.
next_steps: |
- Display the torrent to the user.
- If they then say "add it", call add_torrent_to_qbittorrent with the
magnet, or add_torrent_by_index with the same index.
parameters:
index:
description: 1-based position in the last find_torrent result list.
why_needed: |
Maps to a specific torrent entry. Out-of-range values return an
error, not a wraparound.
example: 3
returns:
ok:
description: Torrent found at that index.
fields:
status: "'ok'"
torrent: "Full torrent dict (name, size, seeders, leechers, magnet, ...)."
error:
description: No torrent at that index.
fields:
error: Short error code (not_found).
message: Human-readable explanation, e.g. "Search for torrents first."
+76
View File
@@ -0,0 +1,76 @@
name: learn
summary: >
Teach Alfred a new token mapping and persist it to the learned
knowledge pack so future scans recognise it.
description: |
Appends a new token (or list of tokens) to a key inside a knowledge
pack and writes the result to `data/knowledge/<pack>_learned.yaml`.
The change is persisted atomically (write-tmp + rename) so a crash
cannot corrupt the file. Currently only the `subtitles` pack is
supported.
when_to_use: |
When manage_subtitles returns needs_clarification with unresolved
tokens, after confirming with the user what the tokens mean. Call
once per (category, key) — multiple values can be added in a single
call.
when_not_to_use: |
- Without explicit user confirmation of what the token means.
- For knowledge that belongs in the static pack
(alfred/knowledge/<pack>.yaml) — that's editor territory, not
runtime learning.
next_steps: |
- After success: re-run the workflow step that triggered the
clarification (typically manage_subtitles) so the new mapping is
applied.
parameters:
pack:
description: Knowledge pack name. Currently only "subtitles" is supported.
why_needed: |
Decides which `*_learned.yaml` file under data/knowledge/ gets
written. The pack name is namespaced to avoid collisions across
domains.
example: subtitles
category:
description: Category within the pack — "languages", "types", or "formats".
why_needed: |
Different categories use different lookup tables at scan time.
A wrong category silently has no effect.
example: languages
key:
description: Canonical entry id — ISO 639-1 code, type name, format name.
why_needed: |
The destination bucket for the new tokens. Existing tokens under
this key are kept; only new values are appended.
example: es
values:
description: List of token spellings to add.
why_needed: |
Release groups use many spellings for the same language/type;
pass them all in one call instead of multiple round-trips.
example: '["spanish", "espanol", "spa"]'
returns:
ok:
description: Mapping saved.
fields:
status: "'ok'"
pack: Name of the pack that was written to.
category: Category that was updated.
key: Key that was updated.
added_count: Number of values that were actually new (deduplicated).
tokens: Full updated token list for that key.
error:
description: Save failed.
fields:
error: Short error code (unknown_pack, unknown_category, read_failed, write_failed).
message: Human-readable explanation.
+63
View File
@@ -0,0 +1,63 @@
name: list_folder
summary: >
List the contents of a configured folder, optionally below a
relative subpath.
description: |
Reads a folder previously configured via set_path_for_folder and
returns its entries (files + directories). A relative `path` lets you
drill down without re-specifying the absolute root each time. Path
traversal is rejected (no `..`, no absolute paths) so the agent
cannot escape the configured root.
when_to_use: |
- At the start of an organize workflow to discover what's available
in the download folder.
- To browse a library collection ("what tv shows do I have?").
- As a sanity check before any move to confirm the target exists.
when_not_to_use: |
- For folders that are not configured — call set_path_for_folder
first.
- To list arbitrary system paths — this tool is intentionally scoped
to the known roots.
next_steps: |
- After listing the download folder: typically call analyze_release
on a specific entry.
- After listing a library folder: use the result to disambiguate a
destination during resolve_*_destination.
cache:
key: path
parameters:
folder_type:
description: Logical folder key (download, torrent, movie, tv_show, ...).
why_needed: |
Resolves to an absolute root through LTM. Must have been set via
set_path_for_folder beforehand.
example: download
path:
description: Relative subpath inside the root (default ".").
why_needed: |
Lets you drill into a subfolder without expanding the root. No
".." or absolute path is allowed.
example: Breaking.Bad.S01.1080p.BluRay.x265-GROUP
returns:
ok:
description: Listing returned.
fields:
status: "'ok'"
folder_type: The key that was listed.
path: The relative path that was listed.
entries: List of {name, type, size?} for each entry.
error:
description: Could not list the folder.
fields:
error: Short error code (folder_not_configured, path_not_found, path_traversal, ...).
message: Human-readable explanation.
@@ -0,0 +1,67 @@
name: manage_subtitles
summary: >
Detect, filter, and place subtitle tracks next to a video that has just
been organised into the library.
description: |
Scans the source video's surroundings for subtitle files
(.srt, .ass, .ssa, .vtt, .sub), classifies them by language and type
(standard / SDH / forced), filters by the user's SubtitlePreferences
(languages, min size, keep_sdh, keep_forced), and hard-links the
passing files next to the destination video using the convention
`<lang>.<ext>`, `<lang>.sdh.<ext>`, `<lang>.forced.<ext>`.
If no subtitles are found, returns status=ok with placed_count=0 — not
an error.
when_to_use: |
Always after a successful move_media / move_to_destination, before
closing the workflow. Pass the original source path (where subs live)
and the new library path (where they should land).
when_not_to_use: |
- Do not call before the video itself has been moved — the destination
must exist for hard-links to make sense.
- Skip when the user explicitly asks not to handle subtitles.
next_steps: |
- On status=ok: continue with create_seed_links (if seeding) or end
the workflow.
- On status=needs_clarification: ask the user about the unresolved
tokens, then optionally call learn() to teach the new mapping.
parameters:
source_video:
description: Absolute path to the original video file (in the download folder).
why_needed: |
Subtitles typically live next to the source, either as siblings or
in a Subs/ subfolder. The scanner walks from this path.
example: /downloads/Oz.S03.1080p.WEBRip.x265-KONTRAST/Oz.S03E01.mkv
destination_video:
description: Absolute path to the video file in its library location.
why_needed: |
Subtitles are hard-linked next to this file so media players pick
them up automatically.
example: /tv_shows/Oz.1997.1080p.WEBRip.x265-KONTRAST/Season 03/Oz.S03E01.mkv
returns:
ok:
description: Subtitles scanned (and possibly placed).
fields:
status: "'ok'"
placed: List of {source, destination, filename} for each linked file.
placed_count: Number of subtitle files placed.
skipped_count: Number of subtitle files filtered out.
needs_clarification:
description: One or more tokens could not be classified.
fields:
unresolved: List of unrecognised tokens with their context.
question: Human-readable question to relay to the user.
error:
description: Scan or placement failed.
fields:
error: Short error code.
message: Human-readable explanation.
+58
View File
@@ -0,0 +1,58 @@
name: move_media
summary: >
Safely move a media file with copy + integrity check + delete source.
description: |
Copies the source file to the destination with an integrity check,
then deletes the source. Slower than move_to_destination (which is a
plain rename) but safer across filesystems where rename is not atomic
or when you want a checksum verification.
when_to_use: |
Use to move a single file across filesystems or when paranoia about
data integrity is justified — e.g. moving a finished download from a
scratch disk to the main library array.
when_not_to_use: |
- For same-filesystem moves where speed matters: use move_to_destination
(instant rename on ZFS/ext4 within the same dataset).
- For folder-level moves of complete packs: use move_to_destination —
move_media is a single-file operation.
next_steps: |
- After a successful move: call manage_subtitles to place any subtitle
tracks, then create_seed_links if the user wants to keep seeding.
- On error: surface the error code (file_not_found, destination_exists,
integrity_check_failed) and ask the user how to proceed.
parameters:
source:
description: Absolute path to the source video file.
why_needed: |
The file being moved. Typically lives under the downloads folder
after a torrent completes.
example: /downloads/Inception.2010.1080p.BluRay.x265-GROUP/movie.mkv
destination:
description: Absolute path of the destination file — must not already exist.
why_needed: |
Where the file lands in the library. Comes from a resolve_*_destination
call so the naming convention is respected.
example: /movies/Inception.2010.1080p.BluRay.x265-GROUP/Inception.2010.1080p.BluRay.x265-GROUP.mkv
returns:
ok:
description: Move succeeded.
fields:
status: "'ok'"
source: Absolute path of the source (now gone).
destination: Absolute path of the destination (now in place).
filename: Basename of the destination file.
size: Size in bytes.
error:
description: Move failed.
fields:
error: Short error code (file_not_found, destination_exists, integrity_check_failed, ...).
message: Human-readable explanation.
@@ -0,0 +1,55 @@
name: move_to_destination
summary: >
Move a file or folder to a destination, creating parent directories as needed.
description: |
Performs an actual move on disk. Uses the system 'mv' command, so on the
same filesystem (e.g. ZFS) this is an instant rename. Creates the parent
directory of the destination if it doesn't exist yet, then moves. Returns
before/after paths on success, or an error if the destination already
exists or the source can't be moved.
when_to_use: |
Use after one of the resolve_*_destination tools returned status=ok, to
perform the move it described. The 'source' and 'destination' arguments
come directly from the resolved paths.
when_not_to_use: |
- Never move when status was not 'ok' (clarification still pending or
error happened) — that would leave the library in a half-broken state.
- Don't use this for the seed-link step; use create_seed_links for that.
next_steps: |
- After a successful move: call manage_subtitles to place any subtitle
tracks, then create_seed_links to keep qBittorrent seeding.
- On error: surface the message; do not retry blindly — check whether
the destination already exists or the source path is correct.
parameters:
source:
description: Absolute path to the source file or folder to move.
why_needed: |
The thing being moved. Comes from the user's download folder or from
a previous tool's output.
example: /downloads/Oz.S03.1080p.WEBRip.x265-KONTRAST
destination:
description: Absolute path of the destination — must not already exist.
why_needed: |
Where to put the source. Comes from a resolve_*_destination call so
that the path matches the library's naming convention.
example: /tv_shows/Oz.1997.1080p.WEBRip.x265-KONTRAST/Oz.S03.1080p.WEBRip.x265-KONTRAST
returns:
ok:
description: Move succeeded.
fields:
source: Absolute path of the source (now gone).
destination: Absolute path of the destination (now in place).
error:
description: Move failed.
fields:
error: Short error code (source_not_found, destination_exists, mkdir_failed, move_failed).
message: Human-readable explanation of what went wrong.
+56
View File
@@ -0,0 +1,56 @@
name: probe_media
summary: >
Run ffprobe on a single video file and return its technical details.
description: |
Inspects a specific video file with ffprobe and returns codec,
resolution, duration, bitrate, the list of audio tracks (with
language and channel layout), and the list of embedded subtitle
tracks. Independent of any release-name parsing — works on any file
you can point at.
when_to_use: |
- To inspect a file's audio/subtitle tracks before deciding what to
do (e.g. choose a default audio language).
- To verify a video's resolution / codec when the release name is
unreliable.
- As a building block when analyze_release is overkill.
when_not_to_use: |
- For full release routing — analyze_release does parsing + media
type detection + probe in one call.
- On non-video files — ffprobe will return probe_failed.
next_steps: |
- The returned info typically feeds a user-facing decision (e.g.
"this is 7.1 DTS, want to keep it?"); rarely chained directly to
another tool.
cache:
key: source_path
parameters:
source_path:
description: Absolute path to the video file to probe.
why_needed: |
ffprobe needs the exact file (not a folder). For releases use
analyze_release; for a known file path, pass it here.
example: /downloads/Inception.2010.1080p.BluRay.x265-GROUP/movie.mkv
returns:
ok:
description: Probe succeeded.
fields:
status: "'ok'"
video: "Dict with codec, resolution, width, height, duration_seconds, bitrate_kbps."
audio_tracks: "List of {index, codec, channels, channel_layout, language, is_default}."
subtitle_tracks: "List of {index, codec, language, is_default, is_forced}."
audio_languages: List of language codes present in audio tracks.
is_multi_audio: True when more than one audio language is present.
error:
description: Probe failed.
fields:
error: Short error code (not_found, probe_failed).
message: Human-readable explanation.
@@ -0,0 +1,54 @@
name: query_library
summary: >
Find release folders across all configured library roots whose name
contains a substring (case-insensitive).
description: |
Scans every configured library root (movies, tv_shows, …) at depth 1
and returns folders whose name contains the query. For each match,
reports whether a `.alfred/metadata.yaml` exists — handy to spot
releases that have not been inspected yet. Does not recurse into
seasons / episodes; one entry per release folder.
when_to_use: |
- To answer "do I already have X?" without listing whole library
roots one by one.
- To pick the release_path to feed read_release_metadata or any
inspector tool.
when_not_to_use: |
- To list the *whole* library — that scan should live behind a
dedicated tool (not implemented yet).
- To browse a single root — use list_folder instead, it's cheaper
and doesn't open every library.
next_steps: |
- When one match is found: feed its path to read_release_metadata or
analyze_release.
- When several match: surface the indexed list to the user and ask
which one they mean.
parameters:
name:
description: Case-insensitive substring of the release name to look for.
why_needed: |
Library folders are named after the release (Title.Year.... or
Title (Year)). A substring is enough to catch typical user
phrasings ("foundation", "inception 2010").
example: foundation
returns:
ok:
description: Scan completed (possibly zero matches).
fields:
status: "'ok'"
query: The query string as received.
match_count: Number of matching folders.
matches: "List of {collection, name, path, has_metadata}."
error:
description: Scan could not run.
fields:
error: Short error code (no_libraries, empty_name).
message: Human-readable explanation.
@@ -0,0 +1,55 @@
name: read_release_metadata
summary: >
Read the `.alfred/metadata.yaml` file for a release folder.
description: |
Returns whatever has been previously persisted by inspector tools
(analyze_release, probe_media, find_media_imdb_id) and by the subtitle
pipeline. Works for any folder — download or library — as long as the
release has been touched at least once. Missing metadata is not an
error: the tool returns `has_metadata=false` with an empty dict.
when_to_use: |
- Before re-running analyze_release / probe_media on a release you
might have already seen — saves a full re-inspection.
- To answer "what do we know about X?" without scanning.
- To list which releases in a library have no `.alfred` yet (loop +
`has_metadata`).
when_not_to_use: |
- To search a library by name — use query_library.
- When you need a fresh probe/parse — call the inspector directly,
the result will be persisted automatically.
next_steps: |
- If `has_metadata=false`, decide whether to inspect now
(analyze_release / probe_media).
- If `has_metadata=true`, read `metadata.parse`, `metadata.probe`,
`metadata.tmdb` blocks before deciding next actions.
cache:
key: release_path
parameters:
release_path:
description: Absolute path to the release folder (or any file inside it).
why_needed: |
The store lives at `<release_root>/.alfred/metadata.yaml`. A file
path is auto-resolved to its parent folder.
example: /mnt/library/tv_shows/Foundation.2021.1080p.WEBRip.x265-RARBG
returns:
ok:
description: Release inspected (file may or may not exist).
fields:
status: "'ok'"
release_path: Absolute path of the release folder.
has_metadata: True if `.alfred/metadata.yaml` exists.
metadata: Full content of the file, or empty dict.
error:
description: Path does not exist on disk.
fields:
error: Short error code (not_found).
message: Human-readable explanation.
@@ -0,0 +1,93 @@
name: resolve_episode_destination
summary: >
Compute destination paths for a single TV episode file (file move).
description: |
Resolves the target series folder, season subfolder, and full destination
filename for a single-episode release. Returns paths only — does not move
anything. If a series folder with a different name already exists, returns
needs_clarification.
when_to_use: |
Use after analyze_release has identified the release as a single episode
(media_type=tv_show, season AND episode both set). TMDB must already be
queried for the canonical title/year, and optionally the episode title.
when_not_to_use: |
- Season packs (folder containing many episodes): use resolve_season_destination.
- Multi-season packs: use resolve_series_destination.
- Movies: use resolve_movie_destination.
next_steps: |
- On status=ok: call move_to_destination with the source video file and
destination=library_file.
- On status=needs_clarification: present question/options to the user,
then re-call with confirmed_folder set.
- On status=error: surface the message; do not move.
parameters:
release_name:
description: Raw release file name (with extension).
why_needed: |
Drives extraction of quality/source/codec/group, which become part of
the destination filename so each file is self-describing.
example: Oz.S03E01.1080p.WEBRip.x265-KONTRAST.mkv
source_file:
description: Absolute path to the source video file on disk.
why_needed: |
Used to read the source file extension (.mkv, .mp4, .avi…) for the
destination filename — release names don't always carry the extension.
example: /downloads/Oz.S03E01.1080p.WEBRip.x265-KONTRAST/file.mkv
tmdb_title:
description: Canonical show title from TMDB.
why_needed: |
Title prefix for both the series folder and the destination filename;
ensures consistent naming across all episodes of the show.
example: Oz
tmdb_year:
description: Show start year from TMDB.
why_needed: |
Disambiguates remakes/reboots sharing a title; year is part of the
series folder identity.
example: "1997"
tmdb_episode_title:
description: Episode title from TMDB. Optional.
why_needed: |
When present, the destination filename embeds the episode title for
human-readability (e.g. Oz.S01E01.The.Routine...).
example: The Routine
confirmed_folder:
description: Folder name the user picked after needs_clarification.
why_needed: |
Forces the use case to skip detection and use this exact folder name.
example: Oz.1997.1080p.WEBRip.x265-KONTRAST
returns:
ok:
description: Paths resolved; ready to move the episode file.
fields:
series_folder: Absolute path to the series root folder.
season_folder: Absolute path to the season subfolder.
library_file: Absolute path to the destination .mkv file (move target).
series_folder_name: Series folder name for display.
season_folder_name: Season folder name for display.
filename: Destination filename for display.
is_new_series_folder: True if the series folder doesn't exist yet.
needs_clarification:
description: A folder exists with a different name; user must choose.
fields:
question: Human-readable question.
options: List of folder names to pick from.
error:
description: Resolution failed.
fields:
error: Short error code.
message: Human-readable explanation.
@@ -0,0 +1,72 @@
name: resolve_movie_destination
summary: >
Compute destination paths for a movie file (file move).
description: |
Resolves the target movie folder and full destination filename for a movie
release. Returns paths only — does not move anything. Movies do not have
the existing-folder disambiguation problem that TV shows have (each
release lands in its own folder named after the canonical title + year +
tech).
when_to_use: |
Use after analyze_release has identified the release as a movie
(media_type=movie). TMDB must already be queried for the canonical title
and release year.
when_not_to_use: |
- TV shows in any form: use resolve_season_destination /
resolve_episode_destination / resolve_series_destination.
- Documentaries when they're treated as series rather than standalone
films: route them through the TV-show resolvers.
next_steps: |
- On status=ok: call move_to_destination with the source video file and
destination=library_file.
- On status=error: surface the message; do not move.
parameters:
release_name:
description: Raw release folder or file name.
why_needed: |
Drives extraction of quality/source/codec/group/edition tokens, which
become part of both the movie folder and filename so each release is
self-describing on disk.
example: Inception.2010.1080p.BluRay.x265-GROUP
source_file:
description: Absolute path to the source video file on disk.
why_needed: |
Used to read the file extension for the destination filename.
example: /downloads/Inception.2010.1080p.BluRay.x265-GROUP/movie.mkv
tmdb_title:
description: Canonical movie title from TMDB.
why_needed: |
Title prefix for the destination folder/file; ensures the library
uses the canonical title and not a sanitized release-name title.
example: Inception
tmdb_year:
description: Movie release year from TMDB.
why_needed: |
Disambiguates remakes that share a title (Dune 1984 vs Dune 2021)
and locks the folder identity in time.
example: "2010"
returns:
ok:
description: Paths resolved; ready to move.
fields:
movie_folder: Absolute path to the movie folder.
library_file: Absolute path to the destination .mkv file (move target).
movie_folder_name: Folder name for display.
filename: Destination filename for display.
is_new_folder: True if the movie folder doesn't exist yet.
error:
description: Resolution failed.
fields:
error: Short error code (e.g. library_not_set).
message: Human-readable explanation.
@@ -0,0 +1,84 @@
name: resolve_season_destination
summary: >
Compute destination paths for a season pack (folder move) in the TV library.
description: |
Resolves the target series folder and season subfolder for a complete-season
download. Returns the paths only — does not perform any move. If a series
folder for this show already exists in the library with a different name
(different group/quality/source), returns needs_clarification so the user
can decide whether to merge into the existing folder or create a new one.
when_to_use: |
Use after analyze_release has identified the release as a season pack
(media_type=tv_show, season set, episode unset). TMDB must already be
queried so tmdb_title and tmdb_year are canonical values, not raw tokens
from the release name.
when_not_to_use: |
- Single-episode files: use resolve_episode_destination instead.
- Multi-season packs (S01-S05 etc.): use resolve_series_destination.
- Movies: use resolve_movie_destination.
next_steps: |
- On status=ok: call move_to_destination with source=<download folder> and
destination=season_folder.
- On status=needs_clarification: present the question and options to the
user, then re-call this tool with confirmed_folder set to the user's pick.
- On status=error: surface the message to the user; do not move anything.
parameters:
release_name:
description: Raw release folder name as it appears on disk.
why_needed: |
Drives extraction of quality/source/codec/group tokens — these are
embedded in the target folder name (Title.Year.Quality.Source.Codec-GROUP)
to make releases self-describing on the filesystem.
example: Oz.S03.1080p.WEBRip.x265-KONTRAST
tmdb_title:
description: Canonical show title from TMDB.
why_needed: |
Builds the title prefix of the folder name. Must come from TMDB to
avoid typos and variant spellings present in the raw release name.
example: Oz
tmdb_year:
description: Show start year from TMDB.
why_needed: |
Disambiguates shows that share a title across decades (e.g. multiple
remakes of "The Office") and locks the folder identity.
example: "1997"
confirmed_folder:
description: |
Folder name chosen by the user after a previous needs_clarification
response.
why_needed: |
Short-circuits the existing-folder detection and forces the use case
to use this exact folder name, even if it doesn't match the computed
one.
example: Oz.1997.1080p.WEBRip.x265-KONTRAST
returns:
ok:
description: Paths resolved unambiguously; ready to move.
fields:
series_folder: Absolute path to the series root folder.
season_folder: Absolute path to the season subfolder (move target).
series_folder_name: Just the series folder name, for display.
season_folder_name: Just the season folder name, for display.
is_new_series_folder: True if the series folder doesn't exist yet.
needs_clarification:
description: A folder already exists with a different name; ask the user.
fields:
question: Human-readable question for the user.
options: List of folder names the user can pick from.
error:
description: Resolution failed (config missing, invalid release name, etc.).
fields:
error: Short error code (e.g. library_not_set).
message: Human-readable explanation.
@@ -0,0 +1,77 @@
name: resolve_series_destination
summary: >
Compute the destination path for a complete multi-season series pack (folder move).
description: |
Resolves the target series folder for a pack that contains multiple seasons
(e.g. S01-S05 in a single release). Returns only the series folder — the
whole source folder is moved as-is into the library, no per-season
restructuring. If a folder with a different name already exists for this
show, returns needs_clarification.
when_to_use: |
Use after analyze_release has identified the release as a complete-series
pack (media_type=tv_complete, or multi-season indicators). TMDB must
already be queried for canonical title/year.
when_not_to_use: |
- Single-season packs: use resolve_season_destination.
- Single episodes: use resolve_episode_destination.
- Movies: use resolve_movie_destination.
next_steps: |
- On status=ok: call move_to_destination with source=<download folder> and
destination=series_folder.
- On status=needs_clarification: ask the user, re-call with
confirmed_folder set.
- On status=error: surface the message; do not move.
parameters:
release_name:
description: Raw release folder name as it appears on disk.
why_needed: |
Drives extraction of quality/source/codec/group tokens for the target
folder name, even though the multi-season structure inside is kept
as-is.
example: The.Wire.S01-S05.1080p.BluRay.x265-GROUP
tmdb_title:
description: Canonical show title from TMDB.
why_needed: |
Title prefix of the series folder; comes from TMDB to avoid raw
release-name spellings.
example: The Wire
tmdb_year:
description: Show start year from TMDB.
why_needed: |
Disambiguates shows that share a title across eras and locks the
folder identity.
example: "2002"
confirmed_folder:
description: Folder name chosen by the user after needs_clarification.
why_needed: |
Forces the use case to use this exact folder name and skip detection.
example: The.Wire.2002.1080p.BluRay.x265-GROUP
returns:
ok:
description: Path resolved; ready to move the pack.
fields:
series_folder: Absolute path to the destination series folder.
series_folder_name: Folder name for display.
is_new_series_folder: True if the folder doesn't exist yet.
needs_clarification:
description: A folder exists with a different name; ask the user.
fields:
question: Human-readable question.
options: List of folder names to pick from.
error:
description: Resolution failed.
fields:
error: Short error code.
message: Human-readable explanation.
@@ -0,0 +1,47 @@
name: set_language
summary: >
Set the conversation language so all subsequent assistant messages
match it.
description: |
Persists an ISO 639-1 language code in short-term memory under
conversation.language. Read by the prompt builder and any tool that
needs to localise output. Does not validate the code against an ISO
list — the LLM is trusted to pass a sensible value.
when_to_use: |
As the very first call when the user writes in a language different
from the current STM language. Doing it before answering avoids a
mid-reply switch.
when_not_to_use: |
- On every turn — only when the language actually changes.
- To pick a subtitle language — that lives in SubtitlePreferences,
not the conversation language.
next_steps: |
- After success: continue the user's request in the newly set
language.
parameters:
language:
description: ISO 639-1 language code (en, fr, es, de, ...).
why_needed: |
Identifies the target language unambiguously across the UI and
any localisation logic.
example: fr
returns:
ok:
description: Language saved.
fields:
status: "'ok'"
message: Confirmation message.
language: The language code that was saved.
error:
description: Could not save the language.
fields:
status: "'error'"
error: Short error code or exception message.
@@ -0,0 +1,58 @@
name: set_path_for_folder
summary: >
Configure where a known folder lives on disk (download, torrent, or
any library collection).
description: |
Stores an absolute path in long-term memory under a folder key. Two
classes of folders exist:
- Workspace paths: "download", "torrent" — single-valued each, used
by the organize workflows.
- Library paths: any other key (e.g. "movie", "tv_show",
"documentary") — these are the collections you organise into.
The path must exist and be a directory; otherwise the call fails
without changing memory.
when_to_use: |
On first run, or when the user moves a folder, or when introducing a
new library collection (e.g. "set the documentaries folder to ...").
when_not_to_use: |
- For one-off listings — list_folder works without configuration only
if the folder is already set.
- To rename or delete an existing folder — this only sets paths.
next_steps: |
- After success: typical follow-ups are list_folder on the same key,
or starting a workflow that needs the path.
parameters:
folder_name:
description: Logical name of the folder (download, torrent, movie, tv_show, ...).
why_needed: |
The key the agent uses everywhere afterwards. "download" and
"torrent" are reserved for workspace; anything else becomes a
library collection.
example: tv_show
path_value:
description: Absolute path to the folder on disk.
why_needed: |
Must exist and be readable. Stored verbatim in LTM — relative
paths are rejected.
example: /tank/library/tv_shows
returns:
ok:
description: Path saved to long-term memory.
fields:
status: "'ok'"
folder_name: The logical name that was set.
path_value: The absolute path that was saved.
error:
description: Could not set the path.
fields:
error: Short error code (path_not_found, not_a_directory, invalid_path, ...).
message: Human-readable explanation.
@@ -0,0 +1,64 @@
name: start_workflow
summary: >
Enter a workflow scope — narrows the visible tool catalog and gives the
agent a clear multi-step plan to follow.
description: |
Activates a named workflow defined in YAML under agent/workflows/.
Once active, only the workflow's declared tools (plus the core noyau)
are exposed to the LLM, which keeps the decision space small and
focused. The returned plan (description + steps) is the script the
agent should execute until end_workflow is called.
when_to_use: |
Use as the very first action whenever the user request maps to a
known workflow (e.g. "organize Breaking Bad" → media.organize_media).
Pass any parameters you already know (release name, target media,
flags) in 'params' so later steps can read them from STM.
when_not_to_use: |
- Do not start a workflow for purely conversational replies or
one-shot lookups that need a single tool call.
- Do not start a new workflow while one is already active — call
end_workflow first.
next_steps: |
- On status=ok: follow the returned 'steps' list, calling the tools
in order. The visible tool catalog has already been narrowed.
- On status=error (unknown_workflow): surface the available list to
the user and ask which one they meant.
- On status=error (workflow_already_active): either continue the
active workflow or call end_workflow first.
parameters:
workflow_name:
description: Fully-qualified name of the workflow to start (e.g. media.organize_media).
why_needed: |
Identifies which YAML definition to load. Names use the
'domain.action' convention (media.*, mail.*, ...).
example: media.organize_media
params:
description: Initial parameters to seed the workflow with (release name, target, flags).
why_needed: |
Later steps read these from STM instead of asking the user again.
Pass whatever you already extracted from the user's message.
example: '{"release_name": "Breaking.Bad.S01.1080p.BluRay.x265-GROUP", "keep_seeding": true}'
returns:
ok:
description: Workflow activated; catalog has been narrowed.
fields:
workflow: Name of the activated workflow.
description: Human-readable description of what the workflow does.
steps: Ordered list of steps to execute.
tools: Tools that are now visible (in addition to the core noyau).
error:
description: Could not activate the workflow.
fields:
error: Short error code (unknown_workflow, workflow_already_active).
message: Human-readable explanation.
available_workflows: List of valid workflow names (only on unknown_workflow).
active_workflow: Name of the currently active workflow (only on workflow_already_active).
+86
View File
@@ -0,0 +1,86 @@
"""Workflow scoping tools — start_workflow / end_workflow meta-tools.
These tools let the agent enter and leave a workflow scope. While a
workflow is active, the PromptBuilder narrows the visible tool catalog
to the noyau + the workflow's declared tools, so the LLM doesn't have
to reason over the full set.
"""
import logging
from typing import Any
from alfred.infrastructure.persistence import get_memory
from ..workflows import WorkflowLoader
logger = logging.getLogger(__name__)
_loader_cache: list[WorkflowLoader] = []
def _get_loader() -> WorkflowLoader:
"""Lazily build the module-level WorkflowLoader."""
if not _loader_cache:
_loader_cache.append(WorkflowLoader())
return _loader_cache[0]
def start_workflow(workflow_name: str, params: dict) -> dict[str, Any]:
"""See specs/start_workflow.yaml for full description."""
loader = _get_loader()
workflow = loader.get(workflow_name)
if workflow is None:
return {
"status": "error",
"error": "unknown_workflow",
"message": f"Workflow '{workflow_name}' not found",
"available_workflows": loader.names(),
}
memory = get_memory()
current = memory.stm.workflow.current
if current is not None:
return {
"status": "error",
"error": "workflow_already_active",
"message": (
f"Workflow '{current.get('name')}' is already active. "
"Call end_workflow before starting a new one."
),
"active_workflow": current.get("name"),
}
memory.stm.start_workflow(workflow_name, params or {})
memory.save()
logger.info(f"start_workflow: '{workflow_name}' with params={params}")
return {
"status": "ok",
"workflow": workflow_name,
"description": workflow.get("description", ""),
"steps": workflow.get("steps", []),
"tools": workflow.get("tools", []),
}
def end_workflow(reason: str) -> dict[str, Any]:
"""See specs/end_workflow.yaml for full description."""
memory = get_memory()
current = memory.stm.workflow.current
if current is None:
return {
"status": "error",
"error": "no_active_workflow",
"message": "No workflow is currently active.",
}
workflow_name = current.get("name")
memory.stm.end_workflow()
memory.save()
logger.info(f"end_workflow: '{workflow_name}' reason={reason!r}")
return {
"status": "ok",
"workflow": workflow_name,
"reason": reason,
}
+3
View File
@@ -0,0 +1,3 @@
from .loader import WorkflowLoader
__all__ = ["WorkflowLoader"]
+52
View File
@@ -0,0 +1,52 @@
"""WorkflowLoader — autodiscovers and loads workflow YAML files.
Scans the workflows/ directory for all .yaml files and exposes them
as dicts. No manual registration needed — drop a new .yaml file and
it will be picked up automatically.
"""
import logging
from pathlib import Path
import yaml
logger = logging.getLogger(__name__)
_WORKFLOWS_DIR = Path(__file__).parent
class WorkflowLoader:
"""
Loads all workflow definitions from the workflows/ directory.
Usage:
loader = WorkflowLoader()
all_workflows = loader.all()
workflow = loader.get("media.organize_media")
"""
def __init__(self):
self._workflows: dict[str, dict] = {}
self._load()
def _load(self) -> None:
for path in sorted(_WORKFLOWS_DIR.glob("*.yaml")):
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
name = data.get("name") or path.stem
self._workflows[name] = data
logger.info(f"WorkflowLoader: Loaded '{name}' from {path.name}")
except Exception as e:
logger.warning(f"WorkflowLoader: Could not load {path.name}: {e}")
def all(self) -> dict[str, dict]:
"""Return all loaded workflows keyed by name."""
return self._workflows
def get(self, name: str) -> dict | None:
"""Return a specific workflow by name, or None if not found."""
return self._workflows.get(name)
def names(self) -> list[str]:
"""Return all available workflow names."""
return list(self._workflows.keys())
@@ -0,0 +1,69 @@
name: media.manage_subtitles
description: >
Place subtitle files alongside a video that has just been organised into the library.
Detects the release pattern automatically, identifies and classifies all tracks,
filters by user rules, and hard-links matching files to the destination.
If any tracks are unrecognised, asks the user and optionally teaches Alfred.
trigger:
examples:
- "handle subtitles for The X-Files S01E01"
- "place the subs next to the file"
- "subtitles are in the Subs/ folder"
- "add subtitles"
tools:
- manage_subtitles
- learn
memory:
SubtitlePreferences: read
Workflow: read-write
steps:
- id: place_subtitles
tool: manage_subtitles
description: >
Detect release pattern, identify and classify all subtitle tracks,
filter by rules, hard-link matching files next to the destination video.
Reads SubtitlePreferences from LTM for language/type/format filtering.
params:
source_video: "{source_video}"
destination_video: "{destination_video}"
imdb_id: "{imdb_id}"
media_type: "{media_type}"
release_group: "{release_group}"
season: "{season}"
episode: "{episode}"
on_result:
ok_placed_zero: skip # no subtitles found — not an error
needs_clarification: ask_user # unrecognised tokens found
- id: ask_user
description: >
Some tracks could not be classified. Show the user the unresolved tokens
and ask if they want to teach Alfred what they mean.
If yes → go to learn_tokens. If no → end workflow.
ask_user:
question: >
I could not identify some tokens in the subtitle files: {unresolved}.
Do you want to teach me what they mean?
answers:
yes: { next_step: learn_tokens }
no: { next_step: end }
- id: learn_tokens
tool: learn
description: >
Persist a new token mapping to the learned knowledge pack so Alfred
recognises it in future scans without asking again.
params:
pack: "subtitles"
category: "{token_category}" # "languages" or "types"
key: "{token_key}" # e.g. "es", "de"
values: "{token_values}" # e.g. ["spanish", "espanol"]
subtitle_naming:
standard: "{lang}.{ext}"
sdh: "{lang}.sdh.{ext}"
forced: "{lang}.forced.{ext}"
@@ -0,0 +1,92 @@
name: media.organize_media
description: >
Organise a downloaded series or movie into the media library.
Triggered when the user asks to move/organize a specific title.
Always moves the video file. Optionally creates seed links in the
torrents folder so qBittorrent can keep seeding.
trigger:
examples:
- "organize Breaking Bad"
- "organise Severance season 2"
- "move Inception to my library"
- "organize Breaking Bad season 1, keep seeding"
tools:
- list_folder
- analyze_release
- probe_media
- find_media_imdb_id
- resolve_season_destination
- resolve_episode_destination
- resolve_movie_destination
- resolve_series_destination
- move_to_destination
- manage_subtitles
- create_seed_links
memory:
WorkspacePaths: read
LibraryPaths: read
Library: read-write
Workflow: read-write
Entities: read-write
steps:
- id: list_downloads
tool: list_folder
description: List the download folder to find the target files.
params:
folder_type: download
- id: analyze
tool: analyze_release
description: >
Parse the release name to detect media_type (movie / tv_season /
tv_episode / tv_complete) and extract season/episode info.
- id: identify_media
tool: find_media_imdb_id
description: Confirm canonical title and year via TMDB.
- id: resolve_destination
description: >
Call the resolver that matches media_type from analyze_release:
movie → resolve_movie_destination
tv_season → resolve_season_destination
tv_episode → resolve_episode_destination
tv_complete → resolve_series_destination
If the resolver returns needs_clarification, ask the user and
re-call with confirmed_folder.
- id: move_file
tool: move_to_destination
description: >
Move the video file/folder to the destination returned by the
resolver above.
- id: handle_subtitles
tool: manage_subtitles
description: >
Place subtitle files alongside the video in the library.
Pass the original source path and the new library destination path.
on_missing: skip
- id: ask_seeding
ask_user:
question: "Do you want to keep seeding this torrent?"
answers:
"yes": { next_step: create_seed_links }
"no": { next_step: end }
- id: create_seed_links
tool: create_seed_links
description: >
Hard-link the library video file back into torrents/<original_folder>/
and copy all remaining files from the original download folder
(subs, nfo, jpg, …) so the torrent stays complete for seeding.
naming_convention:
# Resolved by domain entities (Movie, Episode) — not hardcoded here
tv_show: "{title}/Season {season:02d}/{title}.S{season:02d}E{episode:02d}.{ext}"
movie: "{title} ({year})/{title}.{year}.{ext}"
+20 -2
View File
@@ -29,7 +29,7 @@ app = FastAPI(
version="0.2.0",
)
memory_path = Path(settings.data_storage) / "memory"
memory_path = Path(settings.data_storage_dir) / "memory"
init_memory(storage_dir=str(memory_path))
logger.info(f"Memory context initialized (path: {memory_path})")
@@ -37,6 +37,21 @@ logger.info(f"Memory context initialized (path: {memory_path})")
llm_provider = settings.default_llm_provider.lower()
class _UnconfiguredLLM:
"""Placeholder LLM used when no provider could be configured at import time.
Importing the FastAPI app must not fail just because credentials are
absent (e.g. during test collection). Any actual call surfaces a clear
503 error at request time via the handlers below.
"""
def __init__(self, reason: str):
self.reason = reason
def complete(self, *args, **kwargs):
raise LLMAPIError(f"LLM is not configured: {self.reason}")
try:
if llm_provider == "local":
logger.info("Using local Ollama LLM")
@@ -49,8 +64,11 @@ try:
else:
raise ValueError(f"Unknown LLM provider: {llm_provider}")
except LLMConfigurationError as e:
# Degrade gracefully: keep the app importable so tests can patch agent.step
# and so missing credentials surface as a 503 at the endpoint, not as an
# import error.
logger.error(f"Failed to initialize LLM: {e}")
raise
llm = _UnconfiguredLLM(str(e))
# Initialize agent
agent = Agent(
+36 -1
View File
@@ -1,12 +1,47 @@
"""Filesystem use cases."""
from .dto import ListFolderResponse, SetFolderPathResponse
from .create_seed_links import CreateSeedLinksUseCase
from .dto import (
CreateSeedLinksResponse,
ListFolderResponse,
ManageSubtitlesResponse,
MoveMediaResponse,
PlacedSubtitle,
SetFolderPathResponse,
)
from .list_folder import ListFolderUseCase
from .manage_subtitles import ManageSubtitlesUseCase
from .move_media import MoveMediaUseCase
from .resolve_destination import (
ResolvedEpisodeDestination,
ResolvedMovieDestination,
ResolvedSeasonDestination,
ResolvedSeriesDestination,
resolve_episode_destination,
resolve_movie_destination,
resolve_season_destination,
resolve_series_destination,
)
from .set_folder_path import SetFolderPathUseCase
__all__ = [
"SetFolderPathUseCase",
"ListFolderUseCase",
"CreateSeedLinksUseCase",
"MoveMediaUseCase",
"ManageSubtitlesUseCase",
"ResolvedSeasonDestination",
"ResolvedEpisodeDestination",
"ResolvedMovieDestination",
"ResolvedSeriesDestination",
"resolve_season_destination",
"resolve_episode_destination",
"resolve_movie_destination",
"resolve_series_destination",
"SetFolderPathResponse",
"ListFolderResponse",
"CreateSeedLinksResponse",
"MoveMediaResponse",
"ManageSubtitlesResponse",
"PlacedSubtitle",
]
@@ -0,0 +1,54 @@
"""CreateSeedLinksUseCase — prepares a torrent folder for continued seeding."""
import logging
from alfred.infrastructure.filesystem import FileManager
from alfred.infrastructure.persistence import get_memory
from .dto import CreateSeedLinksResponse
logger = logging.getLogger(__name__)
class CreateSeedLinksUseCase:
"""
Prepares a torrent subfolder so qBittorrent can keep seeding after a move.
Hard-links the video file from the library back into torrents/<original_folder>/,
then copies all remaining files from the original download folder (subs, nfo, …).
"""
def __init__(self, file_manager: FileManager):
self.file_manager = file_manager
def execute(
self, library_file: str, original_download_folder: str
) -> CreateSeedLinksResponse:
memory = get_memory()
torrent_folder = memory.ltm.workspace.torrent
if not torrent_folder:
return CreateSeedLinksResponse(
status="error",
error="torrent_folder_not_set",
message="Torrent folder is not configured. Use set_path_for_folder to set it.",
)
result = self.file_manager.create_seed_links(
library_file, original_download_folder, torrent_folder
)
if result.get("status") == "ok":
return CreateSeedLinksResponse(
status="ok",
torrent_subfolder=result.get("torrent_subfolder"),
linked_file=result.get("linked_file"),
copied_files=result.get("copied_files"),
copied_count=result.get("copied_count", 0),
skipped=result.get("skipped"),
)
return CreateSeedLinksResponse(
status="error",
error=result.get("error"),
message=result.get("message"),
)
@@ -0,0 +1,67 @@
"""
detect_media_type — filesystem-based media type refinement.
Enriches a ParsedRelease.media_type with evidence from the actual source path
(file or folder). Called after parse_release() to produce a final classification.
Classification logic:
1. If source_path is a file — check its extension directly.
2. If source_path is a folder — collect all extensions inside (non-recursive
for the first level, then recursive if nothing conclusive found).
3. Decision:
- Any non_video extension AND no video extension → "other"
- Any video extension → keep parsed media_type ("movie" | "tv_show" | "unknown")
- No conclusive extension found → keep parsed media_type as-is
- Mixed (video + non_video) → "unknown"
"""
from __future__ import annotations
from pathlib import Path
from alfred.domain.release.ports import ReleaseKnowledge
from alfred.domain.release.value_objects import ParsedRelease
def detect_media_type(
parsed: ParsedRelease, source_path: Path, kb: ReleaseKnowledge
) -> str:
"""
Return a refined media_type string for the given source_path.
Does not mutate parsed — returns the new media_type value only.
The caller is responsible for updating the ParsedRelease if needed.
"""
extensions = _collect_extensions(source_path)
# Metadata extensions (.nfo, .srt, …) are always present alongside releases
# and must not influence the type decision.
conclusive = extensions - kb.metadata_extensions
has_video = bool(conclusive & kb.video_extensions)
has_non_video = bool(conclusive & kb.non_video_extensions)
if has_video and has_non_video:
return "unknown"
if has_non_video and not has_video:
return "other"
if has_video:
return parsed.media_type # trust token-level inference
# No conclusive extension — trust token-level inference
return parsed.media_type
def _collect_extensions(path: Path) -> set[str]:
"""Return the set of lowercase extensions found at path (file or folder)."""
if not path.exists():
return set()
if path.is_file():
return {path.suffix.lower()}
# Folder — scan first level only
exts: set[str] = set()
for child in path.iterdir():
if child.is_file():
exts.add(child.suffix.lower())
return exts
+152
View File
@@ -1,8 +1,58 @@
"""Filesystem application DTOs."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class CopyMediaResponse:
"""Response from copying a media file."""
status: str
source: str | None = None
destination: str | None = None
filename: str | None = None
size: int | None = None
error: str | None = None
message: str | None = None
def to_dict(self) -> dict:
if self.error:
return {"status": self.status, "error": self.error, "message": self.message}
return {
"status": self.status,
"source": self.source,
"destination": self.destination,
"filename": self.filename,
"size": self.size,
}
@dataclass
class MoveMediaResponse:
"""Response from moving a media file."""
status: str
source: str | None = None
destination: str | None = None
filename: str | None = None
size: int | None = None
error: str | None = None
message: str | None = None
def to_dict(self) -> dict:
if self.error:
return {"status": self.status, "error": self.error, "message": self.message}
return {
"status": self.status,
"source": self.source,
"destination": self.destination,
"filename": self.filename,
"size": self.size,
}
@dataclass
class SetFolderPathResponse:
"""Response from setting a folder path."""
@@ -29,6 +79,108 @@ class SetFolderPathResponse:
return result
@dataclass
class PlacedSubtitle:
"""One subtitle file successfully placed."""
source: str
destination: str
filename: str
def to_dict(self) -> dict:
return {
"source": self.source,
"destination": self.destination,
"filename": self.filename,
}
@dataclass
class UnresolvedTrack:
"""A subtitle track that needs agent clarification before placement."""
raw_tokens: list[str]
file_path: str | None = None
file_size_kb: float | None = None
reason: str = "" # "unknown_language" | "low_confidence"
def to_dict(self) -> dict:
return {
"raw_tokens": self.raw_tokens,
"file_path": self.file_path,
"file_size_kb": self.file_size_kb,
"reason": self.reason,
}
@dataclass
class AvailableSubtitle:
"""One subtitle track available on an embedded media item."""
language: str # ISO 639-2 code
subtitle_type: str # "standard" | "sdh" | "forced" | "unknown"
def to_dict(self) -> dict:
return {"language": self.language, "type": self.subtitle_type}
@dataclass
class ManageSubtitlesResponse:
"""Response from the manage_subtitles use case."""
status: str # "ok" | "needs_clarification" | "error"
video_path: str | None = None
placed: list[PlacedSubtitle] | None = None
skipped_count: int = 0
unresolved: list[UnresolvedTrack] | None = None
available: list[AvailableSubtitle] | None = None # embedded tracks summary
error: str | None = None
message: str | None = None
def to_dict(self) -> dict:
if self.error:
return {"status": self.status, "error": self.error, "message": self.message}
result = {
"status": self.status,
"video_path": self.video_path,
"placed": [p.to_dict() for p in (self.placed or [])],
"placed_count": len(self.placed or []),
"skipped_count": self.skipped_count,
}
if self.unresolved:
result["unresolved"] = [u.to_dict() for u in self.unresolved]
result["unresolved_count"] = len(self.unresolved)
if self.available:
result["available"] = [a.to_dict() for a in self.available]
return result
@dataclass
class CreateSeedLinksResponse:
"""Response from creating seed links for a torrent."""
status: str
torrent_subfolder: str | None = None
linked_file: str | None = None
copied_files: list[str] | None = None
copied_count: int = 0
skipped: list[str] | None = None
error: str | None = None
message: str | None = None
def to_dict(self) -> dict:
if self.error:
return {"status": self.status, "error": self.error, "message": self.message}
return {
"status": self.status,
"torrent_subfolder": self.torrent_subfolder,
"linked_file": self.linked_file,
"copied_files": self.copied_files or [],
"copied_count": self.copied_count,
"skipped": self.skipped or [],
}
@dataclass
class ListFolderResponse:
"""Response from listing a folder."""
@@ -0,0 +1,82 @@
"""enrich_from_probe — fill missing ParsedRelease fields from MediaInfo."""
from __future__ import annotations
from alfred.domain.release.value_objects import ParsedRelease
from alfred.domain.shared.media import MediaInfo
# Map ffprobe codec names to scene-style codec tokens
_VIDEO_CODEC_MAP = {
"hevc": "x265",
"h264": "x264",
"h265": "x265",
"av1": "AV1",
"vp9": "VP9",
"mpeg4": "XviD",
}
# Map ffprobe audio codec names to scene-style tokens
_AUDIO_CODEC_MAP = {
"eac3": "EAC3",
"ac3": "AC3",
"dts": "DTS",
"truehd": "TrueHD",
"aac": "AAC",
"flac": "FLAC",
"opus": "OPUS",
"mp3": "MP3",
"pcm_s16l": "PCM",
"pcm_s24l": "PCM",
}
# Map channel count to standard layout string
_CHANNEL_MAP = {
8: "7.1",
6: "5.1",
2: "2.0",
1: "1.0",
}
def enrich_from_probe(parsed: ParsedRelease, info: MediaInfo) -> None:
"""
Fill None fields in parsed using data from ffprobe MediaInfo.
Only overwrites fields that are currently None — token-level values
from the release name always take priority.
Mutates parsed in place.
"""
if parsed.quality is None and info.resolution:
parsed.quality = info.resolution
if parsed.codec is None and info.video_codec:
parsed.codec = _VIDEO_CODEC_MAP.get(
info.video_codec.lower(), info.video_codec.upper()
)
if parsed.bit_depth is None and info.video_codec:
# ffprobe exposes bit depth via pix_fmt — not in MediaInfo yet, skip for now
pass
# Audio — use the default track, fallback to first
default_track = next((t for t in info.audio_tracks if t.is_default), None)
track = default_track or (info.audio_tracks[0] if info.audio_tracks else None)
if track:
if parsed.audio_codec is None and track.codec:
parsed.audio_codec = _AUDIO_CODEC_MAP.get(
track.codec.lower(), track.codec.upper()
)
if parsed.audio_channels is None and track.channels:
parsed.audio_channels = _CHANNEL_MAP.get(
track.channels, f"{track.channels}ch"
)
# Languages — merge ffprobe languages with token-level ones
# "und" = undetermined, not useful
if info.audio_languages:
existing = set(parsed.languages)
for lang in info.audio_languages:
if lang.lower() != "und" and lang.upper() not in existing:
parsed.languages.append(lang)
@@ -0,0 +1,308 @@
"""ManageSubtitlesUseCase — orchestrates the full subtitle pipeline for a video file."""
import logging
from pathlib import Path
from alfred.domain.shared.value_objects import ImdbId
from alfred.domain.subtitles.entities import SubtitleCandidate
from alfred.domain.subtitles.services.identifier import SubtitleIdentifier
from alfred.domain.subtitles.services.matcher import SubtitleMatcher
from alfred.domain.subtitles.services.pattern_detector import PatternDetector
from alfred.application.subtitles.placer import (
PlacedTrack,
SubtitlePlacer,
_build_dest_name,
)
from alfred.domain.subtitles.services.utils import available_subtitles
from alfred.domain.subtitles.value_objects import ScanStrategy
from alfred.infrastructure.filesystem.scanner import PathlibFilesystemScanner
from alfred.infrastructure.knowledge.subtitles.base import SubtitleKnowledgeBase
from alfred.infrastructure.knowledge.subtitles.loader import KnowledgeLoader
from alfred.infrastructure.persistence.context import get_memory
from alfred.infrastructure.probe.ffprobe_prober import FfprobeMediaProber
from alfred.infrastructure.subtitle.metadata_store import SubtitleMetadataStore
from alfred.infrastructure.subtitle.rule_repository import RuleSetRepository
from .dto import (
AvailableSubtitle,
ManageSubtitlesResponse,
PlacedSubtitle,
UnresolvedTrack,
)
logger = logging.getLogger(__name__)
def _infer_library_root(dest_video: Path, media_type: str) -> Path:
"""
Infer the media library root folder from the destination video path.
TV show: video → Season 01 → The X-Files (3 levels up)
Movie: video → Inception (2010) (1 level up)
"""
if media_type == "tv_show":
return dest_video.parent.parent
return dest_video.parent
def _to_imdb_id(raw: str | None) -> ImdbId | None:
if not raw:
return None
try:
return ImdbId(raw)
except Exception:
return None
class ManageSubtitlesUseCase:
"""
Full subtitle pipeline:
1. Load knowledge base
2. Detect (or confirm) the release pattern
3. Identify all tracks (ffprobe + filesystem scan)
4. Load + resolve rules for this media
5. Match tracks against rules
6. If any tracks are unresolved → return needs_clarification (don't place yet)
7. Place matched tracks via hard-link
8. Persist to .alfred/metadata.yaml
The use case is stateless — all dependencies are instantiated inline.
"""
def execute(
self,
source_video: str,
destination_video: str,
imdb_id: str | None = None,
media_type: str = "tv_show",
release_group: str | None = None,
season: int | None = None,
episode: int | None = None,
confirmed_pattern_id: str | None = None,
dry_run: bool = False,
) -> ManageSubtitlesResponse:
source_path = Path(source_video)
dest_path = Path(destination_video)
if not source_path.exists() and not source_path.parent.exists():
return ManageSubtitlesResponse(
status="error",
error="source_not_found",
message=f"Source video not found: {source_video}",
)
kb = SubtitleKnowledgeBase(KnowledgeLoader())
prober = FfprobeMediaProber()
scanner = PathlibFilesystemScanner()
library_root = _infer_library_root(dest_path, media_type)
store = SubtitleMetadataStore(library_root)
repo = RuleSetRepository(library_root)
# --- Pattern resolution ---
pattern = self._resolve_pattern(
kb,
prober,
scanner,
store,
source_path,
confirmed_pattern_id,
release_group,
)
if pattern is None:
return ManageSubtitlesResponse(
status="error",
error="pattern_not_found",
message="Could not determine subtitle pattern for this release.",
)
# --- Identify ---
media_id = _to_imdb_id(imdb_id)
identifier = SubtitleIdentifier(kb, prober, scanner)
metadata = identifier.identify(
video_path=source_path,
pattern=pattern,
media_id=media_id,
media_type=media_type,
release_group=release_group,
)
if metadata.total_count == 0:
logger.info(
f"ManageSubtitles: no subtitle tracks found for {source_path.name}"
)
return ManageSubtitlesResponse(
status="ok",
video_path=destination_video,
placed=[],
skipped_count=0,
)
# --- Embedded short-circuit ---
if pattern.scan_strategy == ScanStrategy.EMBEDDED:
logger.info("ManageSubtitles: embedded pattern — skipping matcher")
available = [
AvailableSubtitle(
language=t.language.code if t.language else "?",
subtitle_type=t.subtitle_type.value,
)
for t in available_subtitles(metadata.embedded_tracks)
]
return ManageSubtitlesResponse(
status="ok",
video_path=destination_video,
placed=[],
skipped_count=0,
available=available,
)
# --- Match (external only) ---
subtitle_prefs = None
try:
memory = get_memory()
subtitle_prefs = memory.ltm.subtitle_preferences
except Exception:
pass
rules = repo.load(release_group, subtitle_prefs).resolve(kb.default_rules())
matcher = SubtitleMatcher()
matched, unresolved = matcher.match(metadata.external_tracks, rules)
if unresolved:
logger.info(
f"ManageSubtitles: {len(unresolved)} unresolved track(s) — needs clarification"
)
return ManageSubtitlesResponse(
status="needs_clarification",
video_path=destination_video,
placed=[],
unresolved=[_to_unresolved_dto(t) for t in unresolved],
)
if not matched:
return ManageSubtitlesResponse(
status="ok",
video_path=destination_video,
placed=[],
skipped_count=metadata.total_count,
)
# --- Dry run: skip placement ---
if dry_run:
placed_dtos = []
for t in matched:
if not t.file_path:
continue
try:
filename = _build_dest_name(t, dest_path.stem)
except ValueError:
continue
placed_dtos.append(
PlacedSubtitle(
source=str(t.file_path),
destination=str(dest_path.parent / filename),
filename=filename,
)
)
return ManageSubtitlesResponse(
status="ok",
video_path=destination_video,
placed=placed_dtos,
skipped_count=0,
)
# --- Place ---
placer = SubtitlePlacer()
place_result = placer.place(matched, dest_path)
# --- Persist ---
if place_result.placed:
pairs = _pair_placed_with_tracks(place_result.placed, matched)
store.append_history(pairs, season, episode, release_group)
placed_dtos = [
PlacedSubtitle(
source=str(p.source),
destination=str(p.destination),
filename=p.filename,
)
for p in place_result.placed
]
return ManageSubtitlesResponse(
status="ok",
video_path=destination_video,
placed=placed_dtos,
skipped_count=place_result.skipped_count,
)
def _resolve_pattern(
self,
kb: SubtitleKnowledgeBase,
prober: FfprobeMediaProber,
scanner: PathlibFilesystemScanner,
store: SubtitleMetadataStore,
source_path: Path,
confirmed_pattern_id: str | None,
release_group: str | None,
):
# 1. Explicit override from caller
if confirmed_pattern_id:
p = kb.pattern(confirmed_pattern_id)
if p:
return p
logger.warning(f"ManageSubtitles: unknown pattern '{confirmed_pattern_id}'")
# 2. Previously confirmed in metadata store
stored_id = store.confirmed_pattern()
if stored_id:
p = kb.pattern(stored_id)
if p:
logger.debug(f"ManageSubtitles: using confirmed pattern '{stored_id}'")
return p
# 3. Auto-detect
release_root = source_path.parent
detector = PatternDetector(kb, prober, scanner)
result = detector.detect(release_root, source_path)
if result["detected"] and result["confidence"] >= 0.6:
logger.info(
f"ManageSubtitles: auto-detected pattern '{result['detected'].id}' "
f"(confidence={result['confidence']:.2f})"
)
return result["detected"]
# 4. Fallback — adjacent (safest default)
logger.info("ManageSubtitles: falling back to 'adjacent' pattern")
return kb.pattern("adjacent")
def _to_unresolved_dto(
track: SubtitleCandidate, min_confidence: float = 0.7
) -> UnresolvedTrack:
reason = "unknown_language" if track.language is None else "low_confidence"
return UnresolvedTrack(
raw_tokens=track.raw_tokens,
file_path=str(track.file_path) if track.file_path else None,
file_size_kb=track.file_size_kb,
reason=reason,
)
def _pair_placed_with_tracks(
placed: list[PlacedTrack],
tracks: list[SubtitleCandidate],
) -> list[tuple[PlacedTrack, SubtitleCandidate]]:
"""
Pair each PlacedTrack with its originating SubtitleCandidate by source path.
Falls back to positional matching if paths don't align.
"""
track_by_path = {t.file_path: t for t in tracks if t.file_path}
pairs = []
for p in placed:
track = track_by_path.get(p.source)
if track is None and tracks:
track = tracks[0] # positional fallback
if track:
pairs.append((p, track))
return pairs
@@ -0,0 +1,43 @@
"""Move media use case."""
import logging
from alfred.infrastructure.filesystem import FileManager
from .dto import MoveMediaResponse
logger = logging.getLogger(__name__)
class MoveMediaUseCase:
"""Use case for moving a media file to a destination (copy + delete source)."""
def __init__(self, file_manager: FileManager):
self.file_manager = file_manager
def execute(self, source: str, destination: str) -> MoveMediaResponse:
"""
Move a media file from source to destination.
Args:
source: Absolute path to the source file.
destination: Absolute path to the destination file.
Returns:
MoveMediaResponse with success or error information.
"""
result = self.file_manager.move_file(source, destination)
if result.get("status") == "ok":
return MoveMediaResponse(
status="ok",
source=result.get("source"),
destination=result.get("destination"),
filename=result.get("filename"),
size=result.get("size"),
)
return MoveMediaResponse(
status="error",
error=result.get("error"),
message=result.get("message"),
)
@@ -0,0 +1,424 @@
"""
Destination resolution — compute library paths for releases.
Four distinct use cases, one per release type:
- resolve_season_destination : season pack (folder move)
- resolve_episode_destination : single episode (file move)
- resolve_movie_destination : movie (file move)
- resolve_series_destination : complete series multi-season pack (folder move)
Each returns a dedicated DTO with only the fields that make sense for that type.
These use cases follow Option B of the snapshot-VO design: ``ParsedRelease``
arrives with ``title_sanitized`` already computed, and TMDB-supplied strings
are sanitized **at the use-case boundary** (here) before being passed into
``ParsedRelease`` builder methods. The builders themselves perform no I/O and
no sanitization.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from alfred.domain.release import parse_release
from alfred.domain.release.ports import ReleaseKnowledge
from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge
from alfred.infrastructure.persistence import get_memory
logger = logging.getLogger(__name__)
# Single module-level knowledge instance. YAML is loaded once at first import.
# Tests that need a custom KB can monkeypatch this attribute.
_KB: ReleaseKnowledge = YamlReleaseKnowledge()
def _find_existing_tvshow_folders(
tv_root: Path, tmdb_title_safe: str, tmdb_year: int
) -> list[str]:
"""Return folder names in tv_root that match title + year prefix."""
if not tv_root.exists():
return []
clean_title = tmdb_title_safe.replace(" ", ".")
prefix = f"{clean_title}.{tmdb_year}".lower()
return sorted(
entry.name
for entry in tv_root.iterdir()
if entry.is_dir() and entry.name.lower().startswith(prefix)
)
def _get_tv_root() -> Path | None:
memory = get_memory()
tv_root = memory.ltm.library_paths.get("tv_show")
return Path(tv_root) if tv_root else None
# ---------------------------------------------------------------------------
# Internal sentinel + series-folder resolver (shared by the 3 TV use cases)
# ---------------------------------------------------------------------------
@dataclass
class _Clarification:
"""Module-private sentinel signalling that user input is needed."""
question: str
options: list[str]
def _resolve_series_folder(
tv_root: Path,
tmdb_title: str,
tmdb_title_safe: str,
tmdb_year: int,
computed_name: str,
confirmed_folder: str | None,
) -> tuple[str, bool] | _Clarification:
"""
Resolve which series folder to use.
Returns:
(folder_name, is_new) if resolved unambiguously,
_Clarification(question, options) if the caller must ask the user.
"""
if confirmed_folder:
return confirmed_folder, not (tv_root / confirmed_folder).exists()
existing = _find_existing_tvshow_folders(tv_root, tmdb_title_safe, tmdb_year)
if not existing:
return computed_name, True
if len(existing) == 1 and existing[0] == computed_name:
return existing[0], False
options = existing + ([computed_name] if computed_name not in existing else [])
return _Clarification(
question=(
f"Un dossier série existe déjà pour '{tmdb_title}' "
f"mais son nom diffère du nom calculé ({computed_name}). "
f"Lequel utiliser ?"
),
options=options,
)
# ---------------------------------------------------------------------------
# DTOs
# ---------------------------------------------------------------------------
@dataclass
class _ResolvedDestinationBase:
"""
Shared shape across all resolution DTOs.
Holds the status flag and the fields used in non-ok states
(error / needs_clarification). Subclasses add their own ok-state fields
and a to_dict() that delegates the non-ok cases via _base_dict().
"""
status: str # "ok" | "needs_clarification" | "error"
# needs_clarification
question: str | None = None
options: list[str] | None = None
# error
error: str | None = None
message: str | None = None
def _base_dict(self) -> dict | None:
"""Return the dict for error/needs_clarification, or None for ok."""
if self.status == "error":
return {"status": self.status, "error": self.error, "message": self.message}
if self.status == "needs_clarification":
return {
"status": self.status,
"question": self.question,
"options": self.options or [],
}
return None
@dataclass
class ResolvedSeasonDestination(_ResolvedDestinationBase):
"""Paths for a season pack — folder move, no individual file paths."""
series_folder: str | None = None
season_folder: str | None = None
series_folder_name: str | None = None
season_folder_name: str | None = None
is_new_series_folder: bool = False
def to_dict(self) -> dict:
return self._base_dict() or {
"status": self.status,
"series_folder": self.series_folder,
"season_folder": self.season_folder,
"series_folder_name": self.series_folder_name,
"season_folder_name": self.season_folder_name,
"is_new_series_folder": self.is_new_series_folder,
}
@dataclass
class ResolvedEpisodeDestination(_ResolvedDestinationBase):
"""Paths for a single episode — file move."""
series_folder: str | None = None
season_folder: str | None = None
library_file: str | None = None # full path to destination .mkv
series_folder_name: str | None = None
season_folder_name: str | None = None
filename: str | None = None
is_new_series_folder: bool = False
def to_dict(self) -> dict:
return self._base_dict() or {
"status": self.status,
"series_folder": self.series_folder,
"season_folder": self.season_folder,
"library_file": self.library_file,
"series_folder_name": self.series_folder_name,
"season_folder_name": self.season_folder_name,
"filename": self.filename,
"is_new_series_folder": self.is_new_series_folder,
}
@dataclass
class ResolvedMovieDestination(_ResolvedDestinationBase):
"""Paths for a movie — file move."""
movie_folder: str | None = None
library_file: str | None = None
movie_folder_name: str | None = None
filename: str | None = None
is_new_folder: bool = False
def to_dict(self) -> dict:
return self._base_dict() or {
"status": self.status,
"movie_folder": self.movie_folder,
"library_file": self.library_file,
"movie_folder_name": self.movie_folder_name,
"filename": self.filename,
"is_new_folder": self.is_new_folder,
}
@dataclass
class ResolvedSeriesDestination(_ResolvedDestinationBase):
"""Paths for a complete multi-season series pack — folder move."""
series_folder: str | None = None
series_folder_name: str | None = None
is_new_series_folder: bool = False
def to_dict(self) -> dict:
return self._base_dict() or {
"status": self.status,
"series_folder": self.series_folder,
"series_folder_name": self.series_folder_name,
"is_new_series_folder": self.is_new_series_folder,
}
# ---------------------------------------------------------------------------
# Use cases
# ---------------------------------------------------------------------------
def resolve_season_destination(
release_name: str,
tmdb_title: str,
tmdb_year: int,
confirmed_folder: str | None = None,
) -> ResolvedSeasonDestination:
"""
Compute destination paths for a season pack.
Returns series_folder + season_folder. No file paths — the whole
source folder is moved as-is into season_folder.
"""
tv_root = _get_tv_root()
if not tv_root:
return ResolvedSeasonDestination(
status="error",
error="library_not_set",
message="TV show library path is not configured.",
)
parsed = parse_release(release_name, _KB)
tmdb_title_safe = _KB.sanitize_for_fs(tmdb_title)
computed_name = parsed.show_folder_name(tmdb_title_safe, tmdb_year)
resolved = _resolve_series_folder(
tv_root, tmdb_title, tmdb_title_safe, tmdb_year, computed_name, confirmed_folder
)
if isinstance(resolved, _Clarification):
return ResolvedSeasonDestination(
status="needs_clarification",
question=resolved.question,
options=resolved.options,
)
series_folder_name, is_new = resolved
season_folder_name = parsed.season_folder_name()
series_path = tv_root / series_folder_name
season_path = series_path / season_folder_name
return ResolvedSeasonDestination(
status="ok",
series_folder=str(series_path),
season_folder=str(season_path),
series_folder_name=series_folder_name,
season_folder_name=season_folder_name,
is_new_series_folder=is_new,
)
def resolve_episode_destination(
release_name: str,
source_file: str,
tmdb_title: str,
tmdb_year: int,
tmdb_episode_title: str | None = None,
confirmed_folder: str | None = None,
) -> ResolvedEpisodeDestination:
"""
Compute destination paths for a single episode file.
Returns series_folder + season_folder + library_file (full path to .mkv).
"""
tv_root = _get_tv_root()
if not tv_root:
return ResolvedEpisodeDestination(
status="error",
error="library_not_set",
message="TV show library path is not configured.",
)
parsed = parse_release(release_name, _KB)
ext = Path(source_file).suffix
tmdb_title_safe = _KB.sanitize_for_fs(tmdb_title)
tmdb_episode_title_safe = (
_KB.sanitize_for_fs(tmdb_episode_title) if tmdb_episode_title else None
)
computed_name = parsed.show_folder_name(tmdb_title_safe, tmdb_year)
resolved = _resolve_series_folder(
tv_root, tmdb_title, tmdb_title_safe, tmdb_year, computed_name, confirmed_folder
)
if isinstance(resolved, _Clarification):
return ResolvedEpisodeDestination(
status="needs_clarification",
question=resolved.question,
options=resolved.options,
)
series_folder_name, is_new = resolved
season_folder_name = parsed.season_folder_name()
filename = parsed.episode_filename(tmdb_episode_title_safe, ext)
series_path = tv_root / series_folder_name
season_path = series_path / season_folder_name
file_path = season_path / filename
return ResolvedEpisodeDestination(
status="ok",
series_folder=str(series_path),
season_folder=str(season_path),
library_file=str(file_path),
series_folder_name=series_folder_name,
season_folder_name=season_folder_name,
filename=filename,
is_new_series_folder=is_new,
)
def resolve_movie_destination(
release_name: str,
source_file: str,
tmdb_title: str,
tmdb_year: int,
) -> ResolvedMovieDestination:
"""
Compute destination paths for a movie file.
Returns movie_folder + library_file (full path to .mkv).
"""
memory = get_memory()
movies_root = memory.ltm.library_paths.get("movie")
if not movies_root:
return ResolvedMovieDestination(
status="error",
error="library_not_set",
message="Movie library path is not configured.",
)
parsed = parse_release(release_name, _KB)
ext = Path(source_file).suffix
tmdb_title_safe = _KB.sanitize_for_fs(tmdb_title)
folder_name = parsed.movie_folder_name(tmdb_title_safe, tmdb_year)
filename = parsed.movie_filename(tmdb_title_safe, tmdb_year, ext)
folder_path = Path(movies_root) / folder_name
file_path = folder_path / filename
return ResolvedMovieDestination(
status="ok",
movie_folder=str(folder_path),
library_file=str(file_path),
movie_folder_name=folder_name,
filename=filename,
is_new_folder=not folder_path.exists(),
)
def resolve_series_destination(
release_name: str,
tmdb_title: str,
tmdb_year: int,
confirmed_folder: str | None = None,
) -> ResolvedSeriesDestination:
"""
Compute destination path for a complete multi-season series pack.
Returns only series_folder — the whole pack lands directly inside it.
"""
tv_root = _get_tv_root()
if not tv_root:
return ResolvedSeriesDestination(
status="error",
error="library_not_set",
message="TV show library path is not configured.",
)
parsed = parse_release(release_name, _KB)
tmdb_title_safe = _KB.sanitize_for_fs(tmdb_title)
computed_name = parsed.show_folder_name(tmdb_title_safe, tmdb_year)
resolved = _resolve_series_folder(
tv_root, tmdb_title, tmdb_title_safe, tmdb_year, computed_name, confirmed_folder
)
if isinstance(resolved, _Clarification):
return ResolvedSeriesDestination(
status="needs_clarification",
question=resolved.question,
options=resolved.options,
)
series_folder_name, is_new = resolved
series_path = tv_root / series_folder_name
return ResolvedSeriesDestination(
status="ok",
series_folder=str(series_path),
series_folder_name=series_folder_name,
is_new_series_folder=is_new,
)
+116
View File
@@ -0,0 +1,116 @@
"""SubtitlePlacer — hard-links matched subtitle tracks next to the destination video."""
import logging
import os
from dataclasses import dataclass
from pathlib import Path
from alfred.domain.subtitles.entities import SubtitleCandidate
from alfred.domain.subtitles.value_objects import SubtitleType
logger = logging.getLogger(__name__)
def _build_dest_name(track: SubtitleCandidate, video_stem: str) -> str:
"""
Build the destination filename for a subtitle track.
Format: {video_stem}.{lang}.{ext}
{video_stem}.{lang}.sdh.{ext}
{video_stem}.{lang}.forced.{ext}
"""
if not track.language or not track.format:
raise ValueError("Cannot compute destination name: language or format missing")
ext = track.format.extensions[0].lstrip(".")
parts = [video_stem, track.language.code]
if track.subtitle_type == SubtitleType.SDH:
parts.append("sdh")
elif track.subtitle_type == SubtitleType.FORCED:
parts.append("forced")
return ".".join(parts) + "." + ext
@dataclass
class PlacedTrack:
source: Path
destination: Path
filename: str
@dataclass
class PlaceResult:
placed: list[PlacedTrack]
skipped: list[tuple[SubtitleCandidate, str]] # (track, reason)
@property
def placed_count(self) -> int:
return len(self.placed)
@property
def skipped_count(self) -> int:
return len(self.skipped)
class SubtitlePlacer:
"""
Hard-links matched SubtitleCandidate files next to a destination video.
Uses the same hard-link strategy as FileManager.copy_file:
instant, no data duplication, qBittorrent keeps seeding.
Embedded tracks are skipped — nothing to place on disk.
"""
def place(
self,
tracks: list[SubtitleCandidate],
destination_video: Path,
) -> PlaceResult:
placed: list[PlacedTrack] = []
skipped: list[tuple[SubtitleCandidate, str]] = []
dest_dir = destination_video.parent
for track in tracks:
if track.is_embedded:
logger.debug(f"SubtitlePlacer: skip embedded track ({track.language})")
skipped.append((track, "embedded — no file to place"))
continue
if not track.file_path:
skipped.append((track, "source file not set"))
continue
try:
dest_name = _build_dest_name(track, destination_video.stem)
except ValueError as e:
skipped.append((track, str(e)))
continue
dest_path = dest_dir / dest_name
try:
os.link(track.file_path, dest_path)
placed.append(
PlacedTrack(
source=track.file_path,
destination=dest_path,
filename=dest_name,
)
)
logger.info(f"SubtitlePlacer: placed {dest_name}")
except FileNotFoundError:
skipped.append((track, "source file not found"))
except FileExistsError:
logger.debug(f"SubtitlePlacer: skip {dest_name} — already exists")
skipped.append((track, "destination already exists"))
except OSError as e:
logger.warning(f"SubtitlePlacer: failed to place {dest_name}: {e}")
skipped.append((track, str(e)))
logger.info(
f"SubtitlePlacer: {len(placed)} placed, {len(skipped)} skipped "
f"for {destination_video.name}"
)
return PlaceResult(placed=placed, skipped=skipped)
-2
View File
@@ -2,7 +2,6 @@
from .entities import Movie
from .exceptions import InvalidMovieData, MovieNotFound
from .services import MovieService
from .value_objects import MovieTitle, Quality, ReleaseYear
__all__ = [
@@ -12,5 +11,4 @@ __all__ = [
"Quality",
"MovieNotFound",
"InvalidMovieData",
"MovieService",
]
+28 -12
View File
@@ -3,16 +3,27 @@
from dataclasses import dataclass, field
from datetime import datetime
from ..shared.media import AudioTrack, MediaWithTracks, SubtitleTrack
from ..shared.value_objects import FilePath, FileSize, ImdbId
from .value_objects import MovieTitle, Quality, ReleaseYear
@dataclass
class Movie:
@dataclass(eq=False)
class Movie(MediaWithTracks):
"""
Movie entity representing a movie in the media library.
Movie aggregate root for the movies domain.
This is the main aggregate root for the movies domain.
Carries file metadata (path, size) and the tracks discovered by the
ffprobe + subtitle scan pipeline. The track lists may be empty when the
movie is known but not yet scanned, or when no file is downloaded.
Track helpers follow the same "C+" contract as ``Episode``: pass a
``Language`` for cross-format matching, or a ``str`` for case-insensitive
direct comparison.
Equality is identity-based: two ``Movie`` instances are equal iff they
share the same ``imdb_id``, regardless of file/track contents. This is
the DDD aggregate invariant — the aggregate is identified by its root id.
"""
imdb_id: ImdbId
@@ -23,13 +34,15 @@ class Movie:
file_size: FileSize | None = None
tmdb_id: int | None = None
added_at: datetime = field(default_factory=datetime.now)
audio_tracks: list[AudioTrack] = field(default_factory=list)
subtitle_tracks: list[SubtitleTrack] = field(default_factory=list)
def __post_init__(self):
"""Validate movie entity."""
# Ensure ImdbId is actually an ImdbId instance
if not isinstance(self.imdb_id, ImdbId):
if isinstance(self.imdb_id, str):
object.__setattr__(self, "imdb_id", ImdbId(self.imdb_id))
self.imdb_id = ImdbId(self.imdb_id)
else:
raise ValueError(
f"imdb_id must be ImdbId or str, got {type(self.imdb_id)}"
@@ -38,19 +51,22 @@ class Movie:
# Ensure MovieTitle is actually a MovieTitle instance
if not isinstance(self.title, MovieTitle):
if isinstance(self.title, str):
object.__setattr__(self, "title", MovieTitle(self.title))
self.title = MovieTitle(self.title)
else:
raise ValueError(
f"title must be MovieTitle or str, got {type(self.title)}"
)
def has_file(self) -> bool:
"""Check if the movie has an associated file."""
return self.file_path is not None and self.file_path.exists()
def __eq__(self, other: object) -> bool:
if not isinstance(other, Movie):
return NotImplemented
return self.imdb_id == other.imdb_id
def is_downloaded(self) -> bool:
"""Check if the movie is downloaded (has a file)."""
return self.has_file()
def __hash__(self) -> int:
return hash(self.imdb_id)
# Track helpers (has_audio_in / audio_languages / has_subtitles_in /
# has_forced_subs / subtitle_languages) come from MediaWithTracks.
def get_folder_name(self) -> str:
"""
-192
View File
@@ -1,192 +0,0 @@
"""Movie domain services - Business logic."""
import logging
import re
from ..shared.value_objects import FilePath, ImdbId
from .entities import Movie
from .exceptions import MovieAlreadyExists, MovieNotFound
from .repositories import MovieRepository
from .value_objects import Quality
logger = logging.getLogger(__name__)
class MovieService:
"""
Domain service for movie-related business logic.
This service contains business rules that don't naturally fit
within a single entity.
"""
def __init__(self, repository: MovieRepository):
"""
Initialize movie service.
Args:
repository: Movie repository for persistence
"""
self.repository = repository
def add_movie(self, movie: Movie) -> None:
"""
Add a new movie to the library.
Args:
movie: Movie entity to add
Raises:
MovieAlreadyExists: If movie with same IMDb ID already exists
"""
if self.repository.exists(movie.imdb_id):
raise MovieAlreadyExists(
f"Movie with IMDb ID {movie.imdb_id} already exists"
)
self.repository.save(movie)
logger.info(f"Added movie: {movie.title.value} ({movie.imdb_id})")
def get_movie(self, imdb_id: ImdbId) -> Movie:
"""
Get a movie by IMDb ID.
Args:
imdb_id: IMDb ID of the movie
Returns:
Movie entity
Raises:
MovieNotFound: If movie not found
"""
movie = self.repository.find_by_imdb_id(imdb_id)
if not movie:
raise MovieNotFound(f"Movie with IMDb ID {imdb_id} not found")
return movie
def get_all_movies(self) -> list[Movie]:
"""
Get all movies in the library.
Returns:
List of all movies
"""
return self.repository.find_all()
def update_movie(self, movie: Movie) -> None:
"""
Update an existing movie.
Args:
movie: Movie entity with updated data
Raises:
MovieNotFound: If movie doesn't exist
"""
if not self.repository.exists(movie.imdb_id):
raise MovieNotFound(f"Movie with IMDb ID {movie.imdb_id} not found")
self.repository.save(movie)
logger.info(f"Updated movie: {movie.title.value} ({movie.imdb_id})")
def remove_movie(self, imdb_id: ImdbId) -> None:
"""
Remove a movie from the library.
Args:
imdb_id: IMDb ID of the movie to remove
Raises:
MovieNotFound: If movie not found
"""
if not self.repository.delete(imdb_id):
raise MovieNotFound(f"Movie with IMDb ID {imdb_id} not found")
logger.info(f"Removed movie with IMDb ID: {imdb_id}")
def detect_quality_from_filename(self, filename: str) -> Quality:
"""
Detect video quality from filename.
Args:
filename: Filename to analyze
Returns:
Detected quality or UNKNOWN
"""
filename_lower = filename.lower()
# Check for quality indicators
if "2160p" in filename_lower or "4k" in filename_lower:
return Quality.UHD_4K
elif "1080p" in filename_lower:
return Quality.FULL_HD
elif "720p" in filename_lower:
return Quality.HD
elif "480p" in filename_lower:
return Quality.SD
return Quality.UNKNOWN
def extract_year_from_filename(self, filename: str) -> int | None:
"""
Extract release year from filename.
Args:
filename: Filename to analyze
Returns:
Year if found, None otherwise
"""
# Look for 4-digit year in parentheses or standalone
# Examples: "Movie (2010)", "Movie.2010.1080p"
patterns = [
r"\((\d{4})\)", # (2010)
r"\.(\d{4})\.", # .2010.
r"\s(\d{4})\s", # 2010
]
for pattern in patterns:
match = re.search(pattern, filename)
if match:
year = int(match.group(1))
# Validate year is reasonable
if 1888 <= year <= 2100:
return year
return None
def validate_movie_file(self, file_path: FilePath) -> bool:
"""
Validate that a file is a valid movie file.
Args:
file_path: Path to the file
Returns:
True if valid movie file, False otherwise
"""
if not file_path.exists():
logger.warning(f"File does not exist: {file_path}")
return False
if not file_path.is_file():
logger.warning(f"Path is not a file: {file_path}")
return False
# Check file extension
valid_extensions = {".mkv", ".mp4", ".avi", ".mov", ".wmv", ".flv", ".webm"}
if file_path.value.suffix.lower() not in valid_extensions:
logger.warning(f"Invalid file extension: {file_path.value.suffix}")
return False
# Check file size (should be at least 100 MB for a movie)
min_size = 100 * 1024 * 1024 # 100 MB
if file_path.value.stat().st_size < min_size:
logger.warning(
f"File too small to be a movie: {file_path.value.stat().st_size} bytes"
)
return False
return True
+3 -7
View File
@@ -1,10 +1,10 @@
"""Movie domain value objects."""
import re
from dataclasses import dataclass
from enum import Enum
from ..shared.exceptions import ValidationError
from ..shared.value_objects import to_dot_folder_name
class Quality(Enum):
@@ -17,7 +17,7 @@ class Quality(Enum):
UNKNOWN = "unknown"
@classmethod
def from_string(cls, quality_str: str) -> "Quality":
def from_string(cls, quality_str: str) -> Quality:
"""
Parse quality from string.
@@ -67,11 +67,7 @@ class MovieTitle:
Removes special characters and replaces spaces with dots.
"""
# Remove special characters except spaces, dots, and hyphens
cleaned = re.sub(r"[^\w\s\.\-]", "", self.value)
# Replace spaces with dots
normalized = cleaned.replace(" ", ".")
return normalized
return to_dot_folder_name(self.value)
def __str__(self) -> str:
return self.value
+6
View File
@@ -0,0 +1,6 @@
"""Release domain — release name parsing and naming conventions."""
from .services import parse_release
from .value_objects import ParsedRelease
__all__ = ["ParsedRelease", "parse_release"]
+10
View File
@@ -0,0 +1,10 @@
"""Domain ports for the release domain.
Protocol-based abstractions that decouple ``parse_release`` and
``ParsedRelease`` from any concrete knowledge-base loader. The
infrastructure layer provides the adapter that satisfies this contract.
"""
from .knowledge import ReleaseKnowledge
__all__ = ["ReleaseKnowledge"]
+52
View File
@@ -0,0 +1,52 @@
"""ReleaseKnowledge port — the read-only query surface that
``parse_release`` and ``ParsedRelease`` need from the release knowledge
base, expressed as a structural Protocol so the domain never imports any
concrete loader.
The concrete YAML-backed implementation lives in
``alfred/infrastructure/knowledge/release_kb.py``. Tests can supply any
object that satisfies this shape (e.g. a simple dataclass).
"""
from __future__ import annotations
from typing import Protocol
class ReleaseKnowledge(Protocol):
"""Read-only snapshot of release-name parsing knowledge."""
# --- Token sets used by the tokenizer / matchers ---
resolutions: set[str]
sources: set[str]
codecs: set[str]
language_tokens: set[str]
forbidden_chars: set[str]
hdr_extra: set[str]
# --- Structured knowledge (loaded from YAML as dicts) ---
audio: dict
video_meta: dict
editions: dict
media_type_tokens: dict
# --- Tokenizer separators ---
separators: list[str]
# --- File-extension sets (used by application/infra modules that work
# directly with filesystem paths, e.g. media-type detection, video
# lookup). Domain parsing itself doesn't touch these. ---
video_extensions: set[str]
non_video_extensions: set[str]
subtitle_extensions: set[str]
metadata_extensions: set[str]
# --- Filesystem sanitization (Option B: pre-sanitize at parse time) ---
def sanitize_for_fs(self, text: str) -> str:
"""Strip filesystem-forbidden characters from ``text``."""
...
+506
View File
@@ -0,0 +1,506 @@
"""Release domain — parsing service."""
from __future__ import annotations
import re
from .ports import ReleaseKnowledge
from .value_objects import MediaTypeToken, ParsedRelease, ParsePath
def _tokenize(name: str, kb: ReleaseKnowledge) -> list[str]:
"""Split a release name on the configured separators, dropping empty tokens."""
pattern = "[" + re.escape("".join(kb.separators)) + "]+"
return [t for t in re.split(pattern, name) if t]
def parse_release(name: str, kb: ReleaseKnowledge) -> ParsedRelease:
"""
Parse a release name and return a ParsedRelease.
Flow:
1. Strip a leading/trailing [site.tag] if present (sets parse_path="sanitized").
2. Check the remainder for truly forbidden chars (anything not in the
configured separators list). If any remain → media_type="unknown",
parse_path="ai", and the LLM handles it.
3. Tokenize using the configured separators (".", " ", "[", "]", "(", ")", "_", ...)
and run token-level matchers (season/episode, tech, languages, audio,
video, edition, title, year).
"""
parse_path = ParsePath.DIRECT.value
# Always try to extract a bracket-enclosed site tag first.
clean, site_tag = _strip_site_tag(name)
if site_tag is not None:
parse_path = ParsePath.SANITIZED.value
if not _is_well_formed(clean, kb):
return ParsedRelease(
raw=name,
normalised=clean,
title=clean,
title_sanitized=kb.sanitize_for_fs(clean),
year=None,
season=None,
episode=None,
episode_end=None,
quality=None,
source=None,
codec=None,
group="UNKNOWN",
tech_string="",
media_type=MediaTypeToken.UNKNOWN.value,
site_tag=site_tag,
parse_path=ParsePath.AI.value,
)
name = clean
tokens = _tokenize(name, kb)
season, episode, episode_end = _extract_season_episode(tokens)
quality, source, codec, group, tech_tokens = _extract_tech(tokens, kb)
languages, lang_tokens = _extract_languages(tokens, kb)
audio_codec, audio_channels, audio_tokens = _extract_audio(tokens, kb)
bit_depth, hdr_format, video_tokens = _extract_video_meta(tokens, kb)
edition, edition_tokens = _extract_edition(tokens, kb)
title = _extract_title(
tokens,
tech_tokens | lang_tokens | audio_tokens | video_tokens | edition_tokens,
kb,
)
year = _extract_year(tokens, title)
media_type = _infer_media_type(
season, quality, source, codec, year, edition, tokens, kb
)
tech_parts = [p for p in [quality, source, codec] if p]
tech_string = ".".join(tech_parts)
return ParsedRelease(
raw=name,
normalised=name,
title=title,
title_sanitized=kb.sanitize_for_fs(title),
year=year,
season=season,
episode=episode,
episode_end=episode_end,
quality=quality,
source=source,
codec=codec,
group=group,
tech_string=tech_string,
media_type=media_type,
site_tag=site_tag,
parse_path=parse_path,
languages=languages,
audio_codec=audio_codec,
audio_channels=audio_channels,
bit_depth=bit_depth,
hdr_format=hdr_format,
edition=edition,
)
def _infer_media_type(
season: int | None,
quality: str | None,
source: str | None,
codec: str | None,
year: int | None,
edition: str | None,
tokens: list[str],
kb: ReleaseKnowledge,
) -> str:
"""
Infer media_type from token-level evidence only (no filesystem access).
- documentary : DOC token present
- concert : CONCERT token present
- tv_complete : INTEGRALE/COMPLETE token, no season
- tv_show : season token found
- movie : no season, at least one tech marker
- unknown : no conclusive evidence
"""
upper_tokens = {t.upper() for t in tokens}
doc_tokens = {t.upper() for t in kb.media_type_tokens.get("doc", [])}
concert_tokens = {t.upper() for t in kb.media_type_tokens.get("concert", [])}
integrale_tokens = {t.upper() for t in kb.media_type_tokens.get("integrale", [])}
if upper_tokens & doc_tokens:
return MediaTypeToken.DOCUMENTARY.value
if upper_tokens & concert_tokens:
return MediaTypeToken.CONCERT.value
if (
edition in {"COMPLETE", "INTEGRALE", "COLLECTION"}
or upper_tokens & integrale_tokens
) and season is None:
return MediaTypeToken.TV_COMPLETE.value
if season is not None:
return MediaTypeToken.TV_SHOW.value
if any([quality, source, codec, year]):
return MediaTypeToken.MOVIE.value
return MediaTypeToken.UNKNOWN.value
def _is_well_formed(name: str, kb: ReleaseKnowledge) -> bool:
"""Return True if name contains no forbidden characters per scene naming rules.
Characters listed as token separators (spaces, brackets, parens, …) are NOT
considered malforming — the tokenizer handles them. Only truly broken chars
like '@', '#', '!', '%' make a name malformed.
"""
tokenizable = set(kb.separators)
return not any(c in name for c in kb.forbidden_chars if c not in tokenizable)
def _strip_site_tag(name: str) -> tuple[str, str | None]:
"""
Strip a site watermark tag from the release name and return (clean_name, tag).
Handles two positions:
- Prefix: "[ OxTorrent.vc ] The.Title.S01..."
- Suffix: "The.Title.S01...-NTb[TGx]"
Anything between [...] is treated as a site tag.
Returns (original_name, None) if no tag found.
"""
s = name.strip()
if s.startswith("["):
close = s.find("]")
if close != -1:
tag = s[1:close].strip()
remainder = s[close + 1 :].strip()
if tag and remainder:
return remainder, tag
if s.endswith("]"):
open_bracket = s.rfind("[")
if open_bracket != -1:
tag = s[open_bracket + 1 : -1].strip()
remainder = s[:open_bracket].strip()
if tag and remainder:
return remainder, tag
return s, None
def _parse_season_episode(tok: str) -> tuple[int, int | None, int | None] | None:
"""
Parse a single token as a season/episode marker.
Handles:
- SxxExx / SxxExxExx / Sxx (canonical scene form)
- NxNN / NxNNxNN (alt form: 1x05, 12x07x08)
Returns (season, episode, episode_end) or None if not a season token.
"""
upper = tok.upper()
# SxxExx form
if len(upper) >= 3 and upper[0] == "S" and upper[1:3].isdigit():
season = int(upper[1:3])
rest = upper[3:]
if not rest:
return season, None, None
episodes: list[int] = []
while rest.startswith("E") and len(rest) >= 3 and rest[1:3].isdigit():
episodes.append(int(rest[1:3]))
rest = rest[3:]
if not episodes:
return None # malformed token like "S03XYZ"
return season, episodes[0], episodes[1] if len(episodes) >= 2 else None
# NxNN form — split on "X" (uppercased), all parts must be digits
if "X" in upper:
parts = upper.split("X")
if len(parts) >= 2 and all(p.isdigit() and p for p in parts):
season = int(parts[0])
episode = int(parts[1])
episode_end = int(parts[2]) if len(parts) >= 3 else None
return season, episode, episode_end
return None
def _extract_season_episode(
tokens: list[str],
) -> tuple[int | None, int | None, int | None]:
for tok in tokens:
parsed = _parse_season_episode(tok)
if parsed is not None:
return parsed
return None, None, None
def _extract_tech(
tokens: list[str],
kb: ReleaseKnowledge,
) -> tuple[str | None, str | None, str | None, str, set[str]]:
"""
Extract quality, source, codec, group from tokens.
Returns (quality, source, codec, group, tech_token_set).
Group extraction strategy (in priority order):
1. Token where prefix is a known codec: x265-GROUP
2. Rightmost token with a dash that isn't a known source
"""
quality: str | None = None
source: str | None = None
codec: str | None = None
group = "UNKNOWN"
tech_tokens: set[str] = set()
for tok in tokens:
tl = tok.lower()
if tl in kb.resolutions:
quality = tok
tech_tokens.add(tok)
continue
if tl in kb.sources:
source = tok
tech_tokens.add(tok)
continue
if "-" in tok:
parts = tok.rsplit("-", 1)
# codec-GROUP (highest priority for group)
if parts[0].lower() in kb.codecs:
codec = parts[0]
group = parts[1] if parts[1] else "UNKNOWN"
tech_tokens.add(tok)
continue
# source with dash: Web-DL, WEB-DL, etc.
if parts[0].lower() in kb.sources or tok.lower().replace("-", "") in kb.sources:
source = tok
tech_tokens.add(tok)
continue
if tl in kb.codecs:
codec = tok
tech_tokens.add(tok)
# Fallback: rightmost token with a dash that isn't a known source
if group == "UNKNOWN":
for tok in reversed(tokens):
if "-" in tok:
parts = tok.rsplit("-", 1)
tl = tok.lower()
if tl in kb.sources or tok.lower().replace("-", "") in kb.sources:
continue
if parts[1]:
group = parts[1]
break
return quality, source, codec, group, tech_tokens
def _is_year_token(tok: str) -> bool:
"""Return True if tok is a 4-digit year between 1900 and 2099."""
return len(tok) == 4 and tok.isdigit() and 1900 <= int(tok) <= 2099
def _extract_title(
tokens: list[str], tech_tokens: set[str], kb: ReleaseKnowledge
) -> str:
"""Extract the title portion: everything before the first season/year/tech token."""
title_parts = []
known_tech = kb.resolutions | kb.sources | kb.codecs
for tok in tokens:
if _parse_season_episode(tok) is not None:
break
if _is_year_token(tok):
break
if tok in tech_tokens or tok.lower() in known_tech:
break
if "-" in tok and any(p.lower() in kb.codecs | kb.sources for p in tok.split("-")):
break
title_parts.append(tok)
return ".".join(title_parts) if title_parts else tokens[0]
def _extract_year(tokens: list[str], title: str) -> int | None:
"""Extract a 4-digit year from tokens (only after the title)."""
title_len = len(title.split("."))
for tok in tokens[title_len:]:
if _is_year_token(tok):
return int(tok)
return None
# ---------------------------------------------------------------------------
# Sequence matcher
# ---------------------------------------------------------------------------
def _match_sequences(
tokens: list[str],
sequences: list[dict],
key: str,
) -> tuple[str | None, set[str]]:
"""
Try to match multi-token sequences against consecutive tokens.
Returns (matched_value, set_of_matched_tokens) or (None, empty_set).
Sequences must be ordered most-specific first in the YAML.
"""
upper_tokens = [t.upper() for t in tokens]
for seq in sequences:
seq_upper = [s.upper() for s in seq["tokens"]]
n = len(seq_upper)
for i in range(len(upper_tokens) - n + 1):
if upper_tokens[i : i + n] == seq_upper:
matched = set(tokens[i : i + n])
return seq[key], matched
return None, set()
# ---------------------------------------------------------------------------
# Language extraction
# ---------------------------------------------------------------------------
def _extract_languages(
tokens: list[str], kb: ReleaseKnowledge
) -> tuple[list[str], set[str]]:
"""Extract language tokens. Returns (languages, matched_token_set)."""
languages = []
lang_tokens: set[str] = set()
for tok in tokens:
if tok.upper() in kb.language_tokens:
languages.append(tok.upper())
lang_tokens.add(tok)
return languages, lang_tokens
# ---------------------------------------------------------------------------
# Audio extraction
# ---------------------------------------------------------------------------
def _extract_audio(
tokens: list[str], kb: ReleaseKnowledge,
) -> tuple[str | None, str | None, set[str]]:
"""
Extract audio codec and channel layout.
Returns (audio_codec, audio_channels, matched_token_set).
Sequences are tried first (DTS.HD.MA, TrueHD.Atmos, …), then single tokens.
"""
audio_codec: str | None = None
audio_channels: str | None = None
audio_tokens: set[str] = set()
known_codecs = {c.upper() for c in kb.audio.get("codecs", [])}
known_channels = set(kb.audio.get("channels", []))
# Try multi-token sequences first
matched_codec, matched_set = _match_sequences(
tokens, kb.audio.get("sequences", []), "codec"
)
if matched_codec:
audio_codec = matched_codec
audio_tokens |= matched_set
# Channel layouts like "5.1" or "7.1" are split into two tokens by normalize —
# detect them as consecutive pairs "X" + "Y" where "X.Y" is a known channel.
# The second token may have a "-GROUP" suffix (e.g. "1-KTH" → strip it).
for i in range(len(tokens) - 1):
second = tokens[i + 1].split("-")[0]
candidate = f"{tokens[i]}.{second}"
if candidate in known_channels and audio_channels is None:
audio_channels = candidate
audio_tokens.add(tokens[i])
audio_tokens.add(tokens[i + 1])
for tok in tokens:
if tok in audio_tokens:
continue
if tok.upper() in known_codecs and audio_codec is None:
audio_codec = tok
audio_tokens.add(tok)
elif tok in known_channels and audio_channels is None:
audio_channels = tok
audio_tokens.add(tok)
return audio_codec, audio_channels, audio_tokens
# ---------------------------------------------------------------------------
# Video metadata extraction (bit depth, HDR)
# ---------------------------------------------------------------------------
def _extract_video_meta(
tokens: list[str], kb: ReleaseKnowledge,
) -> tuple[str | None, str | None, set[str]]:
"""
Extract bit depth and HDR format.
Returns (bit_depth, hdr_format, matched_token_set).
"""
bit_depth: str | None = None
hdr_format: str | None = None
video_tokens: set[str] = set()
known_hdr = {h.upper() for h in kb.video_meta.get("hdr", [])} | kb.hdr_extra
known_depth = {d.lower() for d in kb.video_meta.get("bit_depth", [])}
# Try HDR sequences first
matched_hdr, matched_set = _match_sequences(
tokens, kb.video_meta.get("sequences", []), "hdr"
)
if matched_hdr:
hdr_format = matched_hdr
video_tokens |= matched_set
for tok in tokens:
if tok in video_tokens:
continue
if tok.upper() in known_hdr and hdr_format is None:
hdr_format = tok.upper()
video_tokens.add(tok)
elif tok.lower() in known_depth and bit_depth is None:
bit_depth = tok.lower()
video_tokens.add(tok)
return bit_depth, hdr_format, video_tokens
# ---------------------------------------------------------------------------
# Edition extraction
# ---------------------------------------------------------------------------
def _extract_edition(
tokens: list[str], kb: ReleaseKnowledge
) -> tuple[str | None, set[str]]:
"""
Extract release edition (UNRATED, EXTENDED, DIRECTORS.CUT, …).
Returns (edition, matched_token_set).
"""
known_tokens = {t.upper() for t in kb.editions.get("tokens", [])}
# Try multi-token sequences first
matched_edition, matched_set = _match_sequences(
tokens, kb.editions.get("sequences", []), "edition"
)
if matched_edition:
return matched_edition, matched_set
for tok in tokens:
if tok.upper() in known_tokens:
return tok.upper(), {tok}
return None, set()
+223
View File
@@ -0,0 +1,223 @@
"""Release domain — value objects.
This module is **pure**: no I/O, no YAML loading, no knowledge-base
imports. All knowledge that the parser consumes is injected at runtime
via the ``ReleaseKnowledge`` port (see ``ports/knowledge.py``).
``ParsedRelease`` follows Option B of the snapshot-VO design: filesystem
sanitization is performed once at parse time and stored in
``title_sanitized``. The builder methods (``show_folder_name``,
``episode_filename``, etc.) are therefore pure string-formatting and do
**not** need access to any knowledge base — but they require the caller
to pass already-sanitized TMDB strings. The use case is responsible for
calling ``kb.sanitize_for_fs(tmdb_title)`` before invoking the builders.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from ..shared.exceptions import ValidationError
class MediaTypeToken(str, Enum):
"""
Canonical values for ``ParsedRelease.media_type``.
Inherits from ``str`` so existing string-based comparisons (``== "movie"``,
JSON serialization, TMDB DTO interop) keep working unchanged. The enum
serves both as documentation and as the set of valid values for
``__post_init__`` validation.
"""
MOVIE = "movie"
TV_SHOW = "tv_show"
TV_COMPLETE = "tv_complete"
DOCUMENTARY = "documentary"
CONCERT = "concert"
OTHER = "other"
UNKNOWN = "unknown"
class ParsePath(str, Enum):
"""How a ``ParsedRelease`` was produced. ``str``-backed for the same
reasons as :class:`MediaTypeToken`."""
DIRECT = "direct"
SANITIZED = "sanitized"
AI = "ai"
_VALID_MEDIA_TYPES: frozenset[str] = frozenset(m.value for m in MediaTypeToken)
_VALID_PARSE_PATHS: frozenset[str] = frozenset(p.value for p in ParsePath)
def _strip_episode_from_normalized(normalized: str) -> str:
"""
Remove all episode parts (Exx) from a normalized release name, keeping Sxx.
Oz.S03E01.1080p... → Oz.S03.1080p...
Archer.S14E09E10E11.1080p... → Archer.S14.1080p...
"""
tokens = normalized.split(".")
result = []
for tok in tokens:
upper = tok.upper()
# Token is SxxExx... — keep only the Sxx part
if len(upper) >= 3 and upper[0] == "S" and upper[1:3].isdigit():
result.append(tok[:3]) # "S" + two digits
else:
result.append(tok)
return ".".join(result)
@dataclass
class ParsedRelease:
"""Structured representation of a parsed release name.
``title_sanitized`` carries the filesystem-safe form of ``title`` (computed
by the parser at construction time using the injected knowledge base).
Builder methods rely on it being already-sanitized — see module docstring.
"""
raw: str # original release name (untouched)
normalised: str # dots instead of spaces
title: str # show/movie title (dots, no year/season/tech)
title_sanitized: str # title with filesystem-forbidden chars stripped
year: int | None # movie year or show start year (from TMDB)
season: int | None # season number (None for movies)
episode: int | None # first episode number (None if season-pack)
episode_end: int | None # last episode for multi-ep (None otherwise)
quality: str | None # 1080p, 2160p, …
source: str | None # WEBRip, BluRay, …
codec: str | None # x265, HEVC, …
group: str # release group, "UNKNOWN" if missing
tech_string: str # quality.source.codec joined with dots
media_type: MediaTypeToken = MediaTypeToken.UNKNOWN
site_tag: str | None = (
None # site watermark stripped from name, e.g. "TGx", "OxTorrent.vc"
)
parse_path: ParsePath = ParsePath.DIRECT
languages: list[str] = field(default_factory=list) # ["MULTI", "VFF"], ["FRENCH"], …
audio_codec: str | None = None # "DTS-HD.MA", "DDP", "EAC3", …
audio_channels: str | None = None # "5.1", "7.1", "2.0", …
bit_depth: str | None = None # "10bit", "8bit", …
hdr_format: str | None = None # "DV", "HDR10", "DV.HDR10", …
edition: str | None = None # "UNRATED", "EXTENDED", "DIRECTORS.CUT", …
def __post_init__(self) -> None:
if not self.raw:
raise ValidationError("ParsedRelease.raw cannot be empty")
if not self.group:
raise ValidationError("ParsedRelease.group cannot be empty")
if self.year is not None and not (1888 <= self.year <= 2100):
raise ValidationError(
f"ParsedRelease.year out of range: {self.year}"
)
if self.season is not None and not (0 <= self.season <= 100):
raise ValidationError(
f"ParsedRelease.season out of range: {self.season}"
)
if self.episode is not None and not (0 <= self.episode <= 9999):
raise ValidationError(
f"ParsedRelease.episode out of range: {self.episode}"
)
if self.episode_end is not None:
if not (0 <= self.episode_end <= 9999):
raise ValidationError(
f"ParsedRelease.episode_end out of range: {self.episode_end}"
)
if self.episode is not None and self.episode_end < self.episode:
raise ValidationError(
f"ParsedRelease.episode_end ({self.episode_end}) < "
f"episode ({self.episode})"
)
# Coerce raw strings into their enum form (tolerant constructor).
if not isinstance(self.media_type, MediaTypeToken):
try:
self.media_type = MediaTypeToken(self.media_type)
except ValueError:
raise ValidationError(
f"ParsedRelease.media_type invalid: {self.media_type!r} "
f"(expected one of {sorted(_VALID_MEDIA_TYPES)})"
) from None
if not isinstance(self.parse_path, ParsePath):
try:
self.parse_path = ParsePath(self.parse_path)
except ValueError:
raise ValidationError(
f"ParsedRelease.parse_path invalid: {self.parse_path!r} "
f"(expected one of {sorted(_VALID_PARSE_PATHS)})"
) from None
@property
def is_season_pack(self) -> bool:
return self.season is not None and self.episode is None
def show_folder_name(self, tmdb_title_safe: str, tmdb_year: int) -> str:
"""
Build the series root folder name.
Format: {Title}.{Year}.{Tech}-{Group}
Example: Oz.1997.1080p.WEBRip.x265-KONTRAST
``tmdb_title_safe`` must already be filesystem-safe (the caller is
expected to have run it through ``kb.sanitize_for_fs``).
"""
title_part = tmdb_title_safe.replace(" ", ".")
tech = self.tech_string or "Unknown"
return f"{title_part}.{tmdb_year}.{tech}-{self.group}"
def season_folder_name(self) -> str:
"""
Build the season subfolder name = normalized release name (no episode).
Example: Oz.S03.1080p.WEBRip.x265-KONTRAST
For a single-episode release we still strip the episode token so the
folder can hold the whole season.
"""
return _strip_episode_from_normalized(self.normalised)
def episode_filename(self, tmdb_episode_title_safe: str | None, ext: str) -> str:
"""
Build the episode filename.
Format: {Title}.{SxxExx}.{EpisodeTitle}.{Tech}-{Group}.{ext}
Example: Oz.S01E01.The.Routine.1080p.WEBRip.x265-KONTRAST.mkv
``tmdb_episode_title_safe`` must already be filesystem-safe; pass
``None`` to omit the episode title segment.
"""
title_part = self.title_sanitized
s = f"S{self.season:02d}" if self.season is not None else ""
e = f"E{self.episode:02d}" if self.episode is not None else ""
se = s + e
ep_title = ""
if tmdb_episode_title_safe:
ep_title = "." + tmdb_episode_title_safe.replace(" ", ".")
tech = self.tech_string or "Unknown"
ext_clean = ext.lstrip(".")
return f"{title_part}.{se}{ep_title}.{tech}-{self.group}.{ext_clean}"
def movie_folder_name(self, tmdb_title_safe: str, tmdb_year: int) -> str:
"""
Build the movie folder name.
Format: {Title}.{Year}.{Tech}-{Group}
Example: Inception.2010.1080p.BluRay.x265-GROUP
"""
return self.show_folder_name(tmdb_title_safe, tmdb_year)
def movie_filename(
self, tmdb_title_safe: str, tmdb_year: int, ext: str
) -> str:
"""
Build the movie filename (same as folder name + extension).
Example: Inception.2010.1080p.BluRay.x265-GROUP.mkv
"""
ext_clean = ext.lstrip(".")
return f"{self.movie_folder_name(tmdb_title_safe, tmdb_year)}.{ext_clean}"
+2 -1
View File
@@ -1,7 +1,7 @@
"""Shared kernel - Common domain concepts used across subdomains."""
from .exceptions import DomainException, ValidationError
from .value_objects import FilePath, FileSize, ImdbId
from .value_objects import FilePath, FileSize, ImdbId, Language
__all__ = [
"DomainException",
@@ -9,4 +9,5 @@ __all__ = [
"ImdbId",
"FilePath",
"FileSize",
"Language",
]
+21
View File
@@ -0,0 +1,21 @@
"""Media — file-level track types (video/audio/subtitle) and MediaInfo container.
These are the **container-view** dataclasses, populated from ffprobe output and
used across the project to describe the content of a media file.
"""
from .audio import AudioTrack
from .info import MediaInfo
from .matching import track_lang_matches
from .subtitle import SubtitleTrack
from .tracks_mixin import MediaWithTracks
from .video import VideoTrack
__all__ = [
"AudioTrack",
"MediaInfo",
"MediaWithTracks",
"SubtitleTrack",
"VideoTrack",
"track_lang_matches",
]
+17
View File
@@ -0,0 +1,17 @@
"""AudioTrack — a single audio stream as reported by ffprobe."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class AudioTrack:
"""A single audio track as reported by ffprobe."""
index: int
codec: str | None # aac, ac3, eac3, dts, truehd, flac, …
channels: int | None # 2, 6 (5.1), 8 (7.1), …
channel_layout: str | None # stereo, 5.1, 7.1, …
language: str | None # ISO 639-2: fre, eng, und, …
is_default: bool = False
+78
View File
@@ -0,0 +1,78 @@
"""MediaInfo — assembles video, audio and subtitle tracks for a media file."""
from __future__ import annotations
from dataclasses import dataclass, field
from .audio import AudioTrack
from .subtitle import SubtitleTrack
from .video import VideoTrack
@dataclass(frozen=True)
class MediaInfo:
"""
File-level media metadata extracted by ffprobe — immutable snapshot.
Symmetric design: every stream type is a tuple of typed track objects
(immutable on purpose — a MediaInfo is a frozen view of one ffprobe run,
not a mutable collection to append to).
Backwards-compatible flat accessors (``resolution``, ``width``, …) read
from the first video track when present.
"""
video_tracks: tuple[VideoTrack, ...] = field(default_factory=tuple)
audio_tracks: tuple[AudioTrack, ...] = field(default_factory=tuple)
subtitle_tracks: tuple[SubtitleTrack, ...] = field(default_factory=tuple)
# File-level (from ffprobe ``format`` block, not from any single stream)
duration_seconds: float | None = None
bitrate_kbps: int | None = None
# ──────────────────────────────────────────────────────────────────────
# Video conveniences — read the first video track
# ──────────────────────────────────────────────────────────────────────
@property
def primary_video(self) -> VideoTrack | None:
return self.video_tracks[0] if self.video_tracks else None
@property
def width(self) -> int | None:
v = self.primary_video
return v.width if v else None
@property
def height(self) -> int | None:
v = self.primary_video
return v.height if v else None
@property
def video_codec(self) -> str | None:
v = self.primary_video
return v.codec if v else None
@property
def resolution(self) -> str | None:
v = self.primary_video
return v.resolution if v else None
# ──────────────────────────────────────────────────────────────────────
# Audio conveniences
# ──────────────────────────────────────────────────────────────────────
@property
def audio_languages(self) -> list[str]:
"""Unique audio languages across all tracks (ISO 639-2)."""
seen: set[str] = set()
result: list[str] = []
for track in self.audio_tracks:
if track.language and track.language not in seen:
seen.add(track.language)
result.append(track.language)
return result
@property
def is_multi_audio(self) -> bool:
"""True if more than one audio language is present."""
return len(self.audio_languages) > 1
+33
View File
@@ -0,0 +1,33 @@
"""Language-matching helper shared by media-bearing entities.
Both ``Episode`` and ``Movie`` carry ``audio_tracks`` / ``subtitle_tracks`` and
need to answer "do I have audio in language X?". The matching contract is the
same in both cases — keep it in one place.
"""
from __future__ import annotations
from ..value_objects import Language
def track_lang_matches(track_lang: str | None, query: str | Language) -> bool:
"""
Match a track's language string against a query (contract "C+").
* ``Language`` query → matches if the track string is any known
representation of that Language (delegates to ``Language.matches``).
Powerful, cross-format mode.
* ``str`` query → case-insensitive direct comparison against
``track_lang``. Simple, no normalization, no registry lookup.
Callers needing cross-format resolution (``"fr"`` ↔ ``"fre"`` ↔
``"french"``) should resolve their string through a ``LanguageRegistry``
once and pass the resulting ``Language``.
"""
if track_lang is None:
return False
if isinstance(query, Language):
return query.matches(track_lang)
if isinstance(query, str):
return track_lang.lower().strip() == query.lower().strip()
return False
+25
View File
@@ -0,0 +1,25 @@
"""SubtitleTrack — a single embedded subtitle stream as reported by ffprobe.
This is the **container-view** representation (ffprobe output) used uniformly
across the project to describe a subtitle stream embedded in a media file.
Not to be confused with ``alfred.domain.subtitles.entities.SubtitleCandidate``
which models a subtitle being **scanned/matched** (with confidence, raw tokens,
file path, etc.). The two coexist by design — they describe the same real-world
concept seen from two different bounded contexts.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class SubtitleTrack:
"""A single embedded subtitle track as reported by ffprobe."""
index: int
codec: str | None # subrip, ass, hdmv_pgs_subtitle, …
language: str | None # ISO 639-2: fre, eng, und, …
is_default: bool = False
is_forced: bool = False
@@ -0,0 +1,77 @@
"""Mixin shared by entities that carry audio + subtitle tracks.
Both ``Movie`` and ``Episode`` carry a ``list[AudioTrack]`` plus a
``list[SubtitleTrack]`` and answer the same 5 queries about them (language
presence, unique languages, forced flag). Keep that behavior in one place so a
fix in one is a fix in both.
The mixin is plain Python (no dataclass machinery) so it composes cleanly with
``@dataclass`` entities — it only reads ``self.audio_tracks`` and
``self.subtitle_tracks`` which the host class provides as fields.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from ..value_objects import Language
from .matching import track_lang_matches
if TYPE_CHECKING:
from .audio import AudioTrack
from .subtitle import SubtitleTrack
class MediaWithTracks:
"""
Mixin providing audio/subtitle helpers for entities with track collections.
Hosts must expose two attributes:
* ``audio_tracks: list[AudioTrack]``
* ``subtitle_tracks: list[SubtitleTrack]``
The helpers follow the "C+" matching contract: pass a :class:`Language`
for cross-format matching, or a ``str`` for case-insensitive comparison.
"""
# These attributes are provided by the host entity (Movie, Episode, …).
# Declared here only for type-checkers and to make the contract explicit.
audio_tracks: list["AudioTrack"]
subtitle_tracks: list["SubtitleTrack"]
# ── Audio helpers ──────────────────────────────────────────────────────
def has_audio_in(self, lang: str | Language) -> bool:
"""True if at least one audio track is in the given language."""
return any(track_lang_matches(t.language, lang) for t in self.audio_tracks)
def audio_languages(self) -> list[str]:
"""Unique audio languages across all tracks, in track order."""
seen: set[str] = set()
result: list[str] = []
for t in self.audio_tracks:
if t.language and t.language not in seen:
seen.add(t.language)
result.append(t.language)
return result
# ── Subtitle helpers ───────────────────────────────────────────────────
def has_subtitles_in(self, lang: str | Language) -> bool:
"""True if at least one subtitle track is in the given language."""
return any(track_lang_matches(t.language, lang) for t in self.subtitle_tracks)
def has_forced_subs(self) -> bool:
"""True if at least one subtitle track is flagged as forced."""
return any(t.is_forced for t in self.subtitle_tracks)
def subtitle_languages(self) -> list[str]:
"""Unique subtitle languages across all tracks, in track order."""
seen: set[str] = set()
result: list[str] = []
for t in self.subtitle_tracks:
if t.language and t.language not in seen:
seen.add(t.language)
result.append(t.language)
return result
+62
View File
@@ -0,0 +1,62 @@
"""VideoTrack — a single video stream as reported by ffprobe."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class VideoTrack:
"""A single video track as reported by ffprobe.
A media file typically has one video track but can have several (alt
camera angles, attached thumbnail images reported as still-image streams,
etc.), hence the list[VideoTrack] on MediaInfo.
"""
index: int
codec: str | None # h264, hevc, av1, …
width: int | None
height: int | None
is_default: bool = False
@property
def resolution(self) -> str | None:
"""
Best-effort resolution string: 2160p, 1080p, 720p, …
Width takes priority over height to handle widescreen/cinema crops
(e.g. 1920×960 scope → 1080p, not 720p). Falls back to height when
width is unavailable.
"""
match (self.width, self.height):
case (None, None):
return None
case (w, h) if w is not None:
match True:
case _ if w >= 3840:
return "2160p"
case _ if w >= 1920:
return "1080p"
case _ if w >= 1280:
return "720p"
case _ if w >= 720:
return "576p"
case _ if w >= 640:
return "480p"
case _:
return f"{h}p" if h else f"{w}w"
case (None, h):
match True:
case _ if h >= 2160:
return "2160p"
case _ if h >= 1080:
return "1080p"
case _ if h >= 720:
return "720p"
case _ if h >= 576:
return "576p"
case _ if h >= 480:
return "480p"
case _:
return f"{h}p"
+17
View File
@@ -0,0 +1,17 @@
"""Ports — Protocol interfaces the domain depends on.
Adapters live in ``alfred/infrastructure/`` and implement these protocols.
Domain code never imports infrastructure; it accepts a port via constructor
injection and calls it. Tests can pass in-memory fakes that satisfy the
Protocol without going through real I/O.
"""
from .filesystem_scanner import FileEntry, FilesystemScanner
from .media_prober import MediaProber, SubtitleStreamInfo
__all__ = [
"FileEntry",
"FilesystemScanner",
"MediaProber",
"SubtitleStreamInfo",
]
@@ -0,0 +1,59 @@
"""FilesystemScanner port — abstracts filesystem inspection.
The domain never calls ``Path.iterdir``, ``Path.is_file``, ``Path.stat`` or
``open()`` directly. It asks the scanner for a ``FileEntry`` snapshot and
reasons from there. One scan = one I/O round-trip; no callbacks back to disk.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
@dataclass(frozen=True)
class FileEntry:
"""Frozen snapshot of one filesystem entry, taken at scan time.
The entry carries enough metadata for the domain to classify and order
files without re-querying the OS. ``size_kb`` is ``None`` for directories
and for files whose size could not be read.
"""
path: Path
is_file: bool
is_dir: bool
size_kb: float | None
@property
def name(self) -> str:
return self.path.name
@property
def stem(self) -> str:
return self.path.stem
@property
def suffix(self) -> str:
return self.path.suffix
class FilesystemScanner(Protocol):
"""Read-only filesystem inspection."""
def scan_dir(self, path: Path) -> list[FileEntry]:
"""Return sorted entries directly inside ``path``.
Returns an empty list when ``path`` is not a directory or is
unreadable. Adapters must not raise.
"""
...
def stat(self, path: Path) -> FileEntry | None:
"""Stat a single path; ``None`` when it doesn't exist or is unreadable."""
...
def read_text(self, path: Path, encoding: str = "utf-8") -> str | None:
"""Read a text file in one go; ``None`` on any error."""
...
@@ -0,0 +1,39 @@
"""MediaProber port — abstracts media stream inspection (e.g. ffprobe).
The adapter (typically wrapping ffprobe) maps low-level container metadata
into the small set of stream attributes the domain reasons about. Replacing
ffprobe with another tool only requires a new adapter — domain stays put.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
@dataclass(frozen=True)
class SubtitleStreamInfo:
"""A single embedded subtitle stream, as seen by the prober.
``language`` is the raw language tag emitted by the container (typically
ISO 639-2 like ``"fre"``, ``"eng"``); may be empty/None when the stream
has no language tag. The domain resolves it to a canonical ``Language``
via the knowledge base.
"""
language: str | None
is_hearing_impaired: bool
is_forced: bool
class MediaProber(Protocol):
"""Inspect a media file's stream metadata."""
def list_subtitle_streams(self, video: Path) -> list[SubtitleStreamInfo]:
"""Return all subtitle streams in ``video``.
Returns an empty list when the file is missing, unreadable, or has
no subtitle streams. Adapters must not raise.
"""
...
+94 -12
View File
@@ -67,18 +67,6 @@ class FilePath:
# Use object.__setattr__ because dataclass is frozen
object.__setattr__(self, "value", path_obj)
def exists(self) -> bool:
"""Check if the path exists."""
return self.value.exists()
def is_file(self) -> bool:
"""Check if the path is a file."""
return self.value.is_file()
def is_dir(self) -> bool:
"""Check if the path is a directory."""
return self.value.is_dir()
def __str__(self) -> str:
return str(self.value)
@@ -131,3 +119,97 @@ class FileSize:
def __repr__(self) -> str:
return f"FileSize({self.bytes})"
@dataclass(frozen=True)
class Language:
"""
Canonical language value object.
The primary identifier is the ISO 639-2/B code (3 letters, bibliographic form,
e.g. "fre", "eng", "ger"). This is what ffprobe emits and the project-wide
canonical form. All other representations (ISO 639-1 code, ISO 639-2/T
variant, english/native names, common spellings) live in ``aliases`` and are
used by ``matches()`` for case-insensitive lookup.
Equality and hashing are based solely on ``iso`` so two Language objects with
the same canonical code are interchangeable regardless of aliases.
"""
iso: str
english_name: str
native_name: str
aliases: tuple[str, ...] = ()
def __post_init__(self):
if not isinstance(self.iso, str) or not self.iso:
raise ValidationError(
f"Language.iso must be a non-empty string, got {self.iso!r}"
)
if len(self.iso) != 3:
raise ValidationError(
f"Language.iso must be a 3-letter ISO 639-2/B code, got {self.iso!r}"
)
# Normalize iso to lowercase
object.__setattr__(self, "iso", self.iso.lower())
# Normalize aliases to a tuple of lowercase strings (dedup, preserve order)
seen: set[str] = set()
normalized: list[str] = []
for alias in self.aliases:
if not isinstance(alias, str):
continue
a = alias.lower().strip()
if a and a not in seen:
seen.add(a)
normalized.append(a)
object.__setattr__(self, "aliases", tuple(normalized))
def matches(self, raw: str) -> bool:
"""
True if ``raw`` is any known representation of this language.
Comparison is case-insensitive and whitespace-trimmed. The match space is
the union of the canonical ``iso`` code, the english/native names, and
every alias.
"""
if not isinstance(raw, str):
return False
needle = raw.lower().strip()
if not needle:
return False
if needle == self.iso:
return True
if needle == self.english_name.lower():
return True
if needle == self.native_name.lower():
return True
return needle in self.aliases
def __eq__(self, other: object) -> bool:
if not isinstance(other, Language):
return NotImplemented
return self.iso == other.iso
def __hash__(self) -> int:
return hash(self.iso)
def __str__(self) -> str:
return self.iso
def __repr__(self) -> str:
return f"Language({self.iso!r}, {self.english_name!r})"
# Characters allowed in dot-separated folder/filename forms:
# alphanumerics, underscores, spaces (about to be replaced with dots),
# literal dots, and hyphens. Everything else is stripped.
_FS_SAFE_CHARS = re.compile(r"[^\w\s\.\-]")
def to_dot_folder_name(title: str) -> str:
"""Sanitize ``title`` for filesystem use and convert spaces to dots.
Produces e.g. ``Breaking.Bad`` from ``"Breaking Bad"`` or
``Spider.Man`` from ``"Spider-Man: No Way Home"``.
"""
return _FS_SAFE_CHARS.sub("", title).replace(" ", ".")
+27 -7
View File
@@ -1,14 +1,34 @@
"""Subtitles domain - Business logic for subtitle management (shared across movies and TV shows)."""
"""Subtitles domain — subtitle identification, classification and placement."""
from .entities import Subtitle
from .aggregates import SubtitleRuleSet
from .entities import MediaSubtitleMetadata, SubtitleCandidate
from .exceptions import SubtitleNotFound
from .services import SubtitleService
from .value_objects import Language, SubtitleFormat
from .services import PatternDetector, SubtitleIdentifier, SubtitleMatcher
from .value_objects import (
RuleScope,
ScanStrategy,
SubtitleFormat,
SubtitleLanguage,
SubtitleMatchingRules,
SubtitlePattern,
SubtitleType,
TypeDetectionMethod,
)
__all__ = [
"Subtitle",
"Language",
"SubtitleCandidate",
"MediaSubtitleMetadata",
"SubtitleRuleSet",
"SubtitleIdentifier",
"SubtitleMatcher",
"PatternDetector",
"SubtitleFormat",
"SubtitleLanguage",
"SubtitlePattern",
"SubtitleType",
"ScanStrategy",
"TypeDetectionMethod",
"SubtitleMatchingRules",
"RuleScope",
"SubtitleNotFound",
"SubtitleService",
]
+95
View File
@@ -0,0 +1,95 @@
"""Subtitle domain aggregates."""
from dataclasses import dataclass, field
from typing import Any
from ..shared.value_objects import ImdbId
from .value_objects import RuleScope, SubtitleMatchingRules
@dataclass
class SubtitleRuleSet:
"""
Rules for subtitle selection at a given scope level, with inheritance.
Only delta fields are stored — None means "inherit from parent".
Resolution order: global → release_group → show/movie → season → episode.
A RuleSet can also be pinned to a specific media item (imdb_id),
bypassing the scope hierarchy for that item.
"""
scope: RuleScope
parent: SubtitleRuleSet | None = None
pinned_to: ImdbId | None = None
# Deltas — None = inherit
_languages: list[str] | None = field(default=None, repr=False)
_formats: list[str] | None = field(default=None, repr=False)
_types: list[str] | None = field(default=None, repr=False)
_format_priority: list[str] | None = field(default=None, repr=False)
_min_confidence: float | None = field(default=None, repr=False)
def resolve(self, default_rules: SubtitleMatchingRules) -> SubtitleMatchingRules:
"""
Walk the parent chain and merge deltas into effective rules.
``default_rules`` seeds the top of the chain — it is the caller's
responsibility to load these from the knowledge base (infrastructure).
Keeping the default rules as a parameter keeps the domain free of
any I/O dependency.
"""
base = (
self.parent.resolve(default_rules) if self.parent else default_rules
)
return SubtitleMatchingRules(
preferred_languages=self._languages or base.preferred_languages,
preferred_formats=self._formats or base.preferred_formats,
allowed_types=self._types or base.allowed_types,
format_priority=self._format_priority or base.format_priority,
min_confidence=self._min_confidence
if self._min_confidence is not None
else base.min_confidence,
)
def override(
self,
languages: list[str] | None = None,
formats: list[str] | None = None,
types: list[str] | None = None,
format_priority: list[str] | None = None,
min_confidence: float | None = None,
) -> None:
"""Set delta overrides at this scope level."""
if languages is not None:
self._languages = languages
if formats is not None:
self._formats = formats
if types is not None:
self._types = types
if format_priority is not None:
self._format_priority = format_priority
if min_confidence is not None:
self._min_confidence = min_confidence
def to_dict(self) -> dict:
"""Serialize deltas only (for persistence in rules.yaml)."""
delta: dict[str, Any] = {}
if self._languages is not None:
delta["languages"] = self._languages
if self._formats is not None:
delta["formats"] = self._formats
if self._types is not None:
delta["types"] = self._types
if self._format_priority is not None:
delta["format_priority"] = self._format_priority
if self._min_confidence is not None:
delta["min_confidence"] = self._min_confidence
return {
"scope": {"level": self.scope.level, "identifier": self.scope.identifier},
"override": delta,
}
@classmethod
def global_default(cls) -> SubtitleRuleSet:
return cls(scope=RuleScope(level="global"))
+84 -77
View File
@@ -1,96 +1,103 @@
"""Subtitle domain entities."""
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from ..shared.value_objects import FilePath, ImdbId
from .value_objects import Language, SubtitleFormat, TimingOffset
from ..shared.value_objects import ImdbId
from .value_objects import (
SubtitleFormat,
SubtitleLanguage,
SubtitleType,
)
@dataclass
class Subtitle:
class SubtitleCandidate:
"""
Subtitle entity representing a subtitle file.
A subtitle being scanned and matched — either an external file or an embedded stream.
Can be associated with either a movie or a TV show episode.
Unlike ``alfred.domain.shared.media.SubtitleTrack`` (the pure container-view
populated from ffprobe), a SubtitleCandidate carries the **flow state** of the
subtitle matching pipeline: language/format are typed value objects that may
be ``None`` while classification is in progress, ``confidence`` reflects how
certain we are, and ``raw_tokens`` holds the filename fragments still under
analysis. State evolves: unknown → resolved after user clarification.
"""
media_imdb_id: ImdbId
language: Language
format: SubtitleFormat
file_path: FilePath
# Classification (may be None if not yet resolved)
language: SubtitleLanguage | None
format: SubtitleFormat | None
subtitle_type: SubtitleType = SubtitleType.UNKNOWN
# Optional: for TV shows
season_number: int | None = None
episode_number: int | None = None
# Source
is_embedded: bool = False
file_path: Path | None = None # None if embedded
file_size_kb: float | None = None
entry_count: int | None = None # number of subtitle cues in the file
# Subtitle metadata
timing_offset: TimingOffset = TimingOffset(0)
hearing_impaired: bool = False
forced: bool = False # Forced subtitles (for foreign language parts)
# Matching state
confidence: float = 0.0 # 0.0 → 1.0, not applicable for embedded
raw_tokens: list[str] = field(
default_factory=list
) # tokens extracted from filename
# Source information
source: str | None = None # e.g., "OpenSubtitles", "Subscene"
uploader: str | None = None
download_count: int | None = None
rating: float | None = None
def is_resolved(self) -> bool:
return self.language is not None
def __post_init__(self):
"""Validate subtitle entity."""
# Ensure ImdbId is actually an ImdbId instance
if not isinstance(self.media_imdb_id, ImdbId):
if isinstance(self.media_imdb_id, str):
object.__setattr__(self, "media_imdb_id", ImdbId(self.media_imdb_id))
# Ensure Language is actually a Language instance
if not isinstance(self.language, Language):
if isinstance(self.language, str):
object.__setattr__(self, "language", Language.from_code(self.language))
# Ensure SubtitleFormat is actually a SubtitleFormat instance
if not isinstance(self.format, SubtitleFormat):
if isinstance(self.format, str):
object.__setattr__(
self, "format", SubtitleFormat.from_extension(self.format)
)
# Ensure FilePath is actually a FilePath instance
if not isinstance(self.file_path, FilePath):
object.__setattr__(self, "file_path", FilePath(self.file_path))
def is_for_movie(self) -> bool:
"""Check if this subtitle is for a movie."""
return self.season_number is None and self.episode_number is None
def is_for_episode(self) -> bool:
"""Check if this subtitle is for a TV show episode."""
return self.season_number is not None and self.episode_number is not None
def get_filename(self) -> str:
@property
def destination_name(self) -> str:
"""
Get the suggested filename for this subtitle.
Format for movies: "Movie.Title.{lang}.{format}"
Format for episodes: "S01E05.{lang}.{format}"
Compute the output filename per naming convention:
{lang}.{ext}
{lang}.sdh.{ext}
{lang}.forced.{ext}
"""
if self.is_for_episode():
base = f"S{self.season_number:02d}E{self.episode_number:02d}"
else:
# For movies, use the file path stem
base = self.file_path.value.stem
parts = [base, self.language.value]
if self.hearing_impaired:
parts.append("hi")
if self.forced:
if not self.language or not self.format:
raise ValueError(
"Cannot compute destination_name: language or format missing"
)
ext = self.format.extensions[0].lstrip(".")
parts = [self.language.code]
if self.subtitle_type == SubtitleType.SDH:
parts.append("sdh")
elif self.subtitle_type == SubtitleType.FORCED:
parts.append("forced")
return f"{'.'.join(parts)}.{self.format.value}"
def __str__(self) -> str:
if self.is_for_episode():
return f"Subtitle S{self.season_number:02d}E{self.episode_number:02d} ({self.language.value})"
return f"Subtitle ({self.language.value})"
return ".".join(parts) + "." + ext
def __repr__(self) -> str:
return f"Subtitle(media={self.media_imdb_id}, lang={self.language.value})"
lang = self.language.code if self.language else "?"
fmt = self.format.id if self.format else "?"
src = (
"embedded"
if self.is_embedded
else str(self.file_path.name if self.file_path else "?")
)
return f"SubtitleCandidate({lang}, {self.subtitle_type.value}, {fmt}, src={src}, conf={self.confidence:.2f})"
@dataclass
class MediaSubtitleMetadata:
"""
Snapshot of all subtitle information known for a given media item.
Populated by the identifier service (ffprobe + filesystem scan).
"""
media_id: ImdbId | None
media_type: str # "movie" | "tv_show"
embedded_tracks: list[SubtitleCandidate] = field(default_factory=list)
external_tracks: list[SubtitleCandidate] = field(default_factory=list)
release_group: str | None = None
detected_pattern_id: str | None = None # pattern id from knowledge base
pattern_confirmed: bool = False
@property
def all_tracks(self) -> list[SubtitleCandidate]:
return self.embedded_tracks + self.external_tracks
@property
def total_count(self) -> int:
return len(self.embedded_tracks) + len(self.external_tracks)
@property
def unresolved_tracks(self) -> list[SubtitleCandidate]:
return [t for t in self.external_tracks if t.language is None]
@@ -0,0 +1,6 @@
"""Domain ports for the subtitles domain — Protocol-based abstractions
that decouple domain services from concrete infrastructure adapters."""
from .knowledge import SubtitleKnowledge
__all__ = ["SubtitleKnowledge"]
@@ -0,0 +1,38 @@
"""SubtitleKnowledge port — the query surface domain services need from the
subtitle knowledge base, expressed as a Protocol so the domain never imports
the infrastructure adapter that backs it.
The concrete implementation lives in
``alfred/infrastructure/knowledge/subtitles/base.py`` (the YAML-backed
``SubtitleKnowledgeBase``). Tests can supply any object that satisfies this
structural contract.
"""
from __future__ import annotations
from typing import Protocol
from ..value_objects import SubtitleFormat, SubtitleLanguage, SubtitlePattern, SubtitleType
class SubtitleKnowledge(Protocol):
"""Read-only query surface for subtitle knowledge consumed by the domain.
Only the methods that domain services actually call belong here — anything
else (defaults loading, reload, pattern groups, raw dicts) stays on the
concrete class and is reserved for the application layer.
"""
def known_extensions(self) -> set[str]: ...
def format_for_extension(self, ext: str) -> SubtitleFormat | None: ...
def language_for_token(self, token: str) -> SubtitleLanguage | None: ...
def is_known_lang_token(self, token: str) -> bool: ...
def type_for_token(self, token: str) -> SubtitleType | None: ...
def is_known_type_token(self, token: str) -> bool: ...
def patterns(self) -> dict[str, SubtitlePattern]: ...
-60
View File
@@ -1,60 +0,0 @@
"""Subtitle repository interfaces (abstract)."""
from abc import ABC, abstractmethod
from ..shared.value_objects import ImdbId
from .entities import Subtitle
from .value_objects import Language
class SubtitleRepository(ABC):
"""
Abstract repository for subtitle persistence.
This defines the interface that infrastructure implementations must follow.
"""
@abstractmethod
def save(self, subtitle: Subtitle) -> None:
"""
Save a subtitle to the repository.
Args:
subtitle: Subtitle entity to save
"""
pass
@abstractmethod
def find_by_media(
self,
media_imdb_id: ImdbId,
language: Language | None = None,
season: int | None = None,
episode: int | None = None,
) -> list[Subtitle]:
"""
Find subtitles for a media item.
Args:
media_imdb_id: IMDb ID of the media
language: Optional language filter
season: Optional season number (for TV shows)
episode: Optional episode number (for TV shows)
Returns:
List of matching subtitles
"""
pass
@abstractmethod
def delete(self, subtitle: Subtitle) -> bool:
"""
Delete a subtitle from the repository.
Args:
subtitle: Subtitle to delete
Returns:
True if deleted, False if not found
"""
pass
-149
View File
@@ -1,149 +0,0 @@
"""Subtitle domain services - Business logic."""
import logging
from ..shared.value_objects import FilePath, ImdbId
from .entities import Subtitle
from .exceptions import SubtitleNotFound
from .repositories import SubtitleRepository
from .value_objects import Language, SubtitleFormat
logger = logging.getLogger(__name__)
class SubtitleService:
"""
Domain service for subtitle-related business logic.
This service is SHARED between movies and TV shows domains.
Both can use this service to manage subtitles.
"""
def __init__(self, repository: SubtitleRepository):
"""
Initialize subtitle service.
Args:
repository: Subtitle repository for persistence
"""
self.repository = repository
def add_subtitle(self, subtitle: Subtitle) -> None:
"""
Add a subtitle to the library.
Args:
subtitle: Subtitle entity to add
"""
self.repository.save(subtitle)
logger.info(
f"Added subtitle: {subtitle.language.value} for {subtitle.media_imdb_id}"
)
def find_subtitles_for_movie(
self, imdb_id: ImdbId, languages: list[Language] | None = None
) -> list[Subtitle]:
"""
Find subtitles for a movie.
Args:
imdb_id: IMDb ID of the movie
languages: Optional list of languages to filter by
Returns:
List of matching subtitles
"""
if languages:
all_subtitles = []
for lang in languages:
subs = self.repository.find_by_media(imdb_id, language=lang)
all_subtitles.extend(subs)
return all_subtitles
else:
return self.repository.find_by_media(imdb_id)
def find_subtitles_for_episode(
self,
imdb_id: ImdbId,
season: int,
episode: int,
languages: list[Language] | None = None,
) -> list[Subtitle]:
"""
Find subtitles for a TV show episode.
Args:
imdb_id: IMDb ID of the TV show
season: Season number
episode: Episode number
languages: Optional list of languages to filter by
Returns:
List of matching subtitles
"""
if languages:
all_subtitles = []
for lang in languages:
subs = self.repository.find_by_media(
imdb_id, language=lang, season=season, episode=episode
)
all_subtitles.extend(subs)
return all_subtitles
else:
return self.repository.find_by_media(
imdb_id, season=season, episode=episode
)
def remove_subtitle(self, subtitle: Subtitle) -> None:
"""
Remove a subtitle from the library.
Args:
subtitle: Subtitle to remove
Raises:
SubtitleNotFound: If subtitle not found
"""
if not self.repository.delete(subtitle):
raise SubtitleNotFound(f"Subtitle not found: {subtitle}")
logger.info(f"Removed subtitle: {subtitle}")
def detect_format_from_file(self, file_path: FilePath) -> SubtitleFormat:
"""
Detect subtitle format from file extension.
Args:
file_path: Path to subtitle file
Returns:
Detected subtitle format
"""
extension = file_path.value.suffix
return SubtitleFormat.from_extension(extension)
def validate_subtitle_file(self, file_path: FilePath) -> bool:
"""
Validate that a file is a valid subtitle file.
Args:
file_path: Path to the file
Returns:
True if valid subtitle file, False otherwise
"""
if not file_path.exists():
logger.warning(f"File does not exist: {file_path}")
return False
if not file_path.is_file():
logger.warning(f"Path is not a file: {file_path}")
return False
# Check file extension
try:
self.detect_format_from_file(file_path)
return True
except Exception as e:
logger.warning(f"Invalid subtitle format: {e}")
return False
@@ -0,0 +1,9 @@
from .identifier import SubtitleIdentifier
from .matcher import SubtitleMatcher
from .pattern_detector import PatternDetector
__all__ = [
"SubtitleIdentifier",
"SubtitleMatcher",
"PatternDetector",
]
@@ -0,0 +1,311 @@
"""SubtitleIdentifier — finds and classifies all subtitle tracks for a video file."""
import logging
import re
from pathlib import Path
from ...shared.ports import FilesystemScanner, MediaProber
from ..ports import SubtitleKnowledge
from ...shared.value_objects import ImdbId
from ..entities import MediaSubtitleMetadata, SubtitleCandidate
from ..value_objects import ScanStrategy, SubtitlePattern, SubtitleType
logger = logging.getLogger(__name__)
def _tokenize(name: str) -> list[str]:
"""Split a filename stem into lowercase tokens, stripping parentheses."""
# Strip parenthesized qualifiers like (simplified), (canada), (brazil)
name = re.sub(r"\([^)]*\)", "", name)
return [t.lower() for t in re.split(r"[\.\s_\-]+", name) if t]
def _tokenize_suffix(stem: str, episode_stem: str) -> list[str]:
"""
For episode_subfolder pattern: the filename is {episode_stem}.{lang_tokens}.
Return only the tokens that come after the episode stem portion.
Falls back to full tokenization if the stem doesn't start with episode_stem.
"""
stem_lower = stem.lower()
prefix = episode_stem.lower()
if stem_lower.startswith(prefix):
suffix = stem[len(prefix) :]
tokens = _tokenize(suffix)
if tokens:
return tokens
return _tokenize(stem)
def _count_entries(text: str | None) -> int | None:
"""Return the entry count of an SRT body by finding the last cue number."""
if text is None:
return None
for line in reversed(text.splitlines()):
if line.strip().isdigit():
return int(line.strip())
return 0
class SubtitleIdentifier:
"""
Finds all subtitle tracks for a given video file using a known pattern,
then attempts to classify each track (language, type, format).
Returns a MediaSubtitleMetadata with embedded + external tracks.
External tracks with unknown language or low confidence are left as-is —
the caller (use case) decides whether to ask the user for clarification.
"""
def __init__(
self,
kb: SubtitleKnowledge,
prober: MediaProber,
scanner: FilesystemScanner,
):
self.kb = kb
self.prober = prober
self.scanner = scanner
def identify(
self,
video_path: Path,
pattern: SubtitlePattern,
media_id: ImdbId | None,
media_type: str,
release_group: str | None = None,
) -> MediaSubtitleMetadata:
metadata = MediaSubtitleMetadata(
media_id=media_id,
media_type=media_type,
release_group=release_group,
detected_pattern_id=pattern.id,
)
if pattern.scan_strategy == ScanStrategy.EMBEDDED:
metadata.embedded_tracks = self._scan_embedded(video_path)
else:
metadata.external_tracks = self._scan_external(video_path, pattern)
# Always also check for embedded tracks
metadata.embedded_tracks = self._scan_embedded(video_path)
return metadata
# ------------------------------------------------------------------
# Embedded tracks — via MediaProber
# ------------------------------------------------------------------
def _scan_embedded(self, video_path: Path) -> list[SubtitleCandidate]:
streams = self.prober.list_subtitle_streams(video_path)
tracks = []
for stream in streams:
lang = (
self.kb.language_for_token(stream.language) if stream.language else None
)
if stream.is_hearing_impaired:
stype = SubtitleType.SDH
elif stream.is_forced:
stype = SubtitleType.FORCED
else:
stype = SubtitleType.STANDARD
tracks.append(
SubtitleCandidate(
language=lang,
format=None,
subtitle_type=stype,
is_embedded=True,
raw_tokens=[stream.language] if stream.language else [],
)
)
logger.debug(
f"SubtitleIdentifier: {len(tracks)} embedded track(s) in {video_path.name}"
)
return tracks
# ------------------------------------------------------------------
# External tracks — filesystem scan per pattern strategy
# ------------------------------------------------------------------
def _scan_external(
self, video_path: Path, pattern: SubtitlePattern
) -> list[SubtitleCandidate]:
strategy = pattern.scan_strategy
episode_stem: str | None = None
if strategy == ScanStrategy.ADJACENT:
candidates = self._find_adjacent(video_path)
elif strategy == ScanStrategy.FLAT:
candidates = self._find_flat(video_path, pattern.root_folder or "Subs")
elif strategy == ScanStrategy.EPISODE_SUBFOLDER:
candidates, episode_stem = self._find_episode_subfolder(
video_path, pattern.root_folder or "Subs"
)
else:
return []
return self._classify_files(candidates, pattern, episode_stem=episode_stem)
def _find_adjacent(self, video_path: Path) -> list:
known = self.kb.known_extensions()
return [
entry
for entry in self.scanner.scan_dir(video_path.parent)
if entry.is_file
and entry.suffix.lower() in known
and entry.stem != video_path.stem
]
def _find_flat(self, video_path: Path, root_folder: str) -> list:
known = self.kb.known_extensions()
# Adjacent first, then release root (one level up)
for subs_dir in (
video_path.parent / root_folder,
video_path.parent.parent / root_folder,
):
entries = self.scanner.scan_dir(subs_dir)
if entries:
return [
e for e in entries if e.is_file and e.suffix.lower() in known
]
return []
def _find_episode_subfolder(
self, video_path: Path, root_folder: str
) -> tuple[list, str]:
"""Look for Subs/{episode_stem}/*.srt — adjacent or one level up."""
episode_stem = video_path.stem
known = self.kb.known_extensions()
for subs_dir in (
video_path.parent / root_folder / episode_stem,
video_path.parent.parent / root_folder / episode_stem,
):
entries = self.scanner.scan_dir(subs_dir)
files = [e for e in entries if e.is_file and e.suffix.lower() in known]
if files:
logger.debug(
f"SubtitleIdentifier: found {len(files)} file(s) in {subs_dir}"
)
return files, episode_stem
return [], episode_stem
# ------------------------------------------------------------------
# Classification
# ------------------------------------------------------------------
def _classify_files(
self,
entries: list,
pattern: SubtitlePattern,
episode_stem: str | None = None,
) -> list[SubtitleCandidate]:
tracks = [
self._classify_single(entry, episode_stem=episode_stem) for entry in entries
]
# Post-process: if multiple tracks share same language but type is ambiguous,
# apply size_and_count disambiguation
if pattern.type_detection.value == "size_and_count":
tracks = self._disambiguate_by_size(tracks)
return tracks
def _classify_single(
self, entry, episode_stem: str | None = None
) -> SubtitleCandidate:
fmt = self.kb.format_for_extension(entry.suffix)
tokens = (
_tokenize_suffix(entry.stem, episode_stem)
if episode_stem
else _tokenize(entry.stem)
)
language = None
subtitle_type = SubtitleType.UNKNOWN
unknown_tokens = []
matched_tokens = 0
for token in tokens:
if self.kb.is_known_lang_token(token):
language = self.kb.language_for_token(token)
matched_tokens += 1
elif self.kb.is_known_type_token(token):
subtitle_type = self.kb.type_for_token(token) or subtitle_type
matched_tokens += 1
elif token.isdigit():
pass # numeric prefix — ignore
elif len(token) > 1:
unknown_tokens.append(token)
# Confidence: proportion of meaningful tokens that were recognized
meaningful = [t for t in tokens if not t.isdigit() and len(t) > 1]
confidence = matched_tokens / max(len(meaningful), 1) if meaningful else 0.5
if unknown_tokens:
logger.debug(
f"SubtitleIdentifier: unknown tokens in '{entry.name}': {unknown_tokens}"
)
# Entry count: only meaningful for SRT files; read text on demand.
entry_count: int | None = None
if entry.suffix.lower() == ".srt":
entry_count = _count_entries(self.scanner.read_text(entry.path))
return SubtitleCandidate(
language=language,
format=fmt,
subtitle_type=subtitle_type,
is_embedded=False,
file_path=entry.path,
file_size_kb=entry.size_kb,
entry_count=entry_count,
confidence=confidence,
raw_tokens=tokens,
)
def _disambiguate_by_size(
self, tracks: list[SubtitleCandidate]
) -> list[SubtitleCandidate]:
"""
When multiple tracks share the same language and type is UNKNOWN/STANDARD,
the one with the most entries (lines) is SDH, the smallest is FORCED if
there are 3+, otherwise the smaller is STANDARD.
Only applied when type_detection = size_and_count.
"""
# Group by language code
lang_groups: dict[str, list[SubtitleCandidate]] = {}
for track in tracks:
key = track.language.code if track.language else "__unknown__"
lang_groups.setdefault(key, []).append(track)
result = []
for group in lang_groups.values():
if len(group) == 1:
result.extend(group)
continue
# Sort by entry_count ascending (None treated as 0)
sorted_group = sorted(group, key=lambda t: t.entry_count or 0)
if len(sorted_group) == 2:
# smaller = standard, larger = sdh
self._set_type(sorted_group[0], SubtitleType.STANDARD)
self._set_type(sorted_group[1], SubtitleType.SDH)
elif len(sorted_group) >= 3:
# smallest = forced, middle = standard, largest = sdh
self._set_type(sorted_group[0], SubtitleType.FORCED)
for t in sorted_group[1:-1]:
self._set_type(t, SubtitleType.STANDARD)
self._set_type(sorted_group[-1], SubtitleType.SDH)
result.extend(sorted_group)
return result
def _set_type(self, track: SubtitleCandidate, stype: SubtitleType) -> None:
"""Mutate track type in-place."""
track.subtitle_type = stype
+120
View File
@@ -0,0 +1,120 @@
"""SubtitleMatcher — filters tracks against resolved rules."""
import logging
from ..entities import SubtitleCandidate
from ..value_objects import SubtitleMatchingRules
logger = logging.getLogger(__name__)
class SubtitleMatcher:
"""
Filters a list of SubtitleCandidate against effective SubtitleMatchingRules.
Returns matched tracks (pass all filters, confidence >= min_confidence)
and unresolved tracks (need user clarification).
Conflict resolution: when two tracks share the same language + type,
format_priority decides which one to keep.
"""
def match(
self,
tracks: list[SubtitleCandidate],
rules: SubtitleMatchingRules,
) -> tuple[list[SubtitleCandidate], list[SubtitleCandidate]]:
"""
Returns (matched, unresolved).
"""
matched: list[SubtitleCandidate] = []
unresolved: list[SubtitleCandidate] = []
for track in tracks:
if track.is_embedded:
continue
if track.language is None or track.confidence < rules.min_confidence:
unresolved.append(track)
continue
if not self._passes_filters(track, rules):
logger.debug(f"SubtitleMatcher: filtered out {track}")
continue
matched.append(track)
matched = self._resolve_conflicts(matched, rules)
logger.info(
f"SubtitleMatcher: {len(matched)} matched, {len(unresolved)} unresolved"
)
return matched, unresolved
def _passes_filters(
self, track: SubtitleCandidate, rules: SubtitleMatchingRules
) -> bool:
# Language filter
if rules.preferred_languages:
if not track.language:
return False
if track.language.code not in rules.preferred_languages:
return False
# Format filter (only for external files)
if rules.preferred_formats and not track.is_embedded:
if not track.format:
return False
if track.format.id not in rules.preferred_formats:
return False
# Type filter
if rules.allowed_types:
if track.subtitle_type.value not in rules.allowed_types:
return False
return True
def _resolve_conflicts(
self,
tracks: list[SubtitleCandidate],
rules: SubtitleMatchingRules,
) -> list[SubtitleCandidate]:
"""
When multiple tracks have same language + type, keep only the best one
according to format_priority. If no format_priority applies, keep the first.
"""
seen: dict[tuple, SubtitleCandidate] = {}
for track in tracks:
lang = track.language.code if track.language else None
stype = track.subtitle_type.value
key = (lang, stype)
if key not in seen:
seen[key] = track
else:
existing = seen[key]
if self._prefer(track, existing, rules.format_priority):
logger.debug(
f"SubtitleMatcher: conflict {key}"
f"preferring {track.format.id if track.format else 'embedded'} "
f"over {existing.format.id if existing.format else 'embedded'}"
)
seen[key] = track
return list(seen.values())
def _prefer(
self,
candidate: SubtitleCandidate,
existing: SubtitleCandidate,
format_priority: list[str],
) -> bool:
"""Return True if candidate is preferable to existing."""
if not format_priority:
return False
c_fmt = candidate.format.id if candidate.format else ""
e_fmt = existing.format.id if existing.format else ""
c_rank = format_priority.index(c_fmt) if c_fmt in format_priority else 999
e_rank = format_priority.index(e_fmt) if e_fmt in format_priority else 999
return c_rank < e_rank
@@ -0,0 +1,208 @@
"""PatternDetector — discovers the subtitle structure of a release folder."""
import logging
from pathlib import Path
from ...shared.ports import FilesystemScanner, MediaProber
from ..ports import SubtitleKnowledge
from ..value_objects import ScanStrategy, SubtitlePattern
logger = logging.getLogger(__name__)
class PatternDetector:
"""
Inspects a release folder and returns the best matching known pattern,
plus a confidence score and a description of what was found.
Used for "pattern discovery" — when we don't yet know which pattern
a release follows. The result is proposed to the user for confirmation.
"""
def __init__(
self,
kb: SubtitleKnowledge,
prober: MediaProber,
scanner: FilesystemScanner,
):
self.kb = kb
self.prober = prober
self.scanner = scanner
def detect(self, release_root: Path, sample_video: Path) -> dict:
"""
Analyse the release folder and return:
{
"detected": SubtitlePattern | None,
"confidence": float,
"description": str, # human-readable description of what was found
"candidate_pattern_ids": list[str],
}
"""
findings = self._inspect(release_root, sample_video)
best, confidence = self._match_pattern(findings)
return {
"detected": best,
"confidence": confidence,
"description": self._describe(findings),
"candidate_pattern_ids": [best.id] if best else [],
"raw_findings": findings,
}
def _has_embedded_subtitles(self, video_path: Path) -> bool:
return len(self.prober.list_subtitle_streams(video_path)) > 0
def _inspect(self, release_root: Path, sample_video: Path) -> dict:
"""Gather structural facts about the release."""
known_exts = self.kb.known_extensions()
findings: dict = {
"has_subs_folder": False,
"subs_strategy": None, # "flat" | "episode_subfolder"
"subs_root": None,
"adjacent_subs": False,
"has_embedded": self._has_embedded_subtitles(sample_video),
"files_per_episode": 0,
"has_lang_tokens": False,
"has_numeric_prefix": False,
}
# Check for Subs/ folder — adjacent or at release root
for subs_candidate in (
sample_video.parent / "Subs",
release_root / "Subs",
):
children = self.scanner.scan_dir(subs_candidate)
if not children:
continue
findings["has_subs_folder"] = True
findings["subs_root"] = str(subs_candidate)
# Is it flat or episode_subfolder?
sub_files = [
c for c in children if c.is_file and c.suffix.lower() in known_exts
]
sub_dirs = [c for c in children if c.is_dir]
if sub_dirs and not sub_files:
findings["subs_strategy"] = "episode_subfolder"
# Count files in a sample subfolder
sample_files = [
f
for f in self.scanner.scan_dir(sub_dirs[0].path)
if f.is_file and f.suffix.lower() in known_exts
]
findings["files_per_episode"] = len(sample_files)
# Check naming conventions
for f in sample_files:
parts = f.stem.split("_")
if parts[0].isdigit():
findings["has_numeric_prefix"] = True
if any(
self.kb.is_known_lang_token(t.lower())
for t in f.stem.replace("_", ".").split(".")
):
findings["has_lang_tokens"] = True
else:
findings["subs_strategy"] = "flat"
findings["files_per_episode"] = len(sub_files)
for f in sub_files:
if any(
self.kb.is_known_lang_token(t.lower())
for t in f.stem.replace("_", ".").split(".")
):
findings["has_lang_tokens"] = True
break
# Check adjacent subs (next to the video)
if not findings["has_subs_folder"]:
adjacent = [
e
for e in self.scanner.scan_dir(sample_video.parent)
if e.is_file and e.suffix.lower() in known_exts
]
if adjacent:
findings["adjacent_subs"] = True
findings["files_per_episode"] = len(adjacent)
return findings
def _match_pattern(self, findings: dict) -> tuple[SubtitlePattern | None, float]:
"""Score all known patterns against the findings."""
scores: list[tuple[float, SubtitlePattern]] = []
for pattern in self.kb.patterns().values():
score = self._score(pattern, findings)
scores.append((score, pattern))
if not scores:
return None, 0.0
scores.sort(key=lambda x: x[0], reverse=True)
best_score, best_pattern = scores[0]
if best_score < 0.4:
return None, best_score
return best_pattern, best_score
def _score(self, pattern: SubtitlePattern, findings: dict) -> float:
"""Return a 0.01.0 match score for this pattern against the findings."""
score = 0.0
total = 0.0
strategy = pattern.scan_strategy
if strategy == ScanStrategy.EMBEDDED:
total += 1
if findings.get("has_embedded"):
score += 1.0
if not findings.get("has_subs_folder") and not findings.get(
"adjacent_subs"
):
score += 0.5
total += 0.5
elif strategy == ScanStrategy.EPISODE_SUBFOLDER:
total += 3
if findings.get("has_subs_folder"):
score += 1.0
if findings.get("subs_strategy") == "episode_subfolder":
score += 2.0
elif strategy == ScanStrategy.FLAT:
total += 2
if findings.get("has_subs_folder"):
score += 1.0
if findings.get("subs_strategy") == "flat":
score += 1.0
elif strategy == ScanStrategy.ADJACENT:
total += 2
if findings.get("adjacent_subs"):
score += 1.0
if not findings.get("has_subs_folder"):
score += 1.0
return score / total if total > 0 else 0.0
def _describe(self, findings: dict) -> str:
parts = []
if findings.get("has_subs_folder"):
strategy = findings.get("subs_strategy", "?")
n = findings.get("files_per_episode", 0)
parts.append(f"Subs/ folder found ({strategy}), ~{n} file(s) per episode")
if findings.get("has_numeric_prefix"):
parts.append("files have numeric prefix (e.g. 2_English.srt)")
if findings.get("has_lang_tokens"):
parts.append("language tokens found in filenames")
elif findings.get("adjacent_subs"):
parts.append("subtitle files adjacent to video")
else:
parts.append("no external subtitle files found")
if findings.get("has_embedded"):
parts.append("embedded tracks detected")
return "".join(parts) if parts else "nothing found"
+21
View File
@@ -0,0 +1,21 @@
"""Subtitle service utilities."""
from ..entities import SubtitleCandidate
def available_subtitles(tracks: list[SubtitleCandidate]) -> list[SubtitleCandidate]:
"""
Return the distinct subtitle tracks available, deduped by (language, type).
Useful to display what is available for a media item regardless of user
preferences — e.g. eng, eng.sdh, fra all show up as separate entries.
"""
seen: set[tuple] = set()
result: list[SubtitleCandidate] = []
for track in tracks:
lang = track.language.code if track.language else None
key = (lang, track.subtitle_type)
if key not in seen:
seen.add(key)
result.append(track)
return result
+72 -72
View File
@@ -1,91 +1,91 @@
"""Subtitle domain value objects."""
from dataclasses import dataclass
from dataclasses import dataclass, field
from enum import Enum
from ..shared.exceptions import ValidationError
class ScanStrategy(Enum):
"""How to locate subtitle files for a given release."""
ADJACENT = "adjacent" # .srt next to the video
FLAT = "flat" # Subs/*.srt
EPISODE_SUBFOLDER = "episode_subfolder" # Subs/{episode_name}/*.srt
EMBEDDED = "embedded" # tracks inside the video container
class Language(Enum):
"""Supported subtitle languages."""
class TypeDetectionMethod(Enum):
"""How to differentiate standard / SDH / forced when tokens are ambiguous."""
ENGLISH = "en"
FRENCH = "fr"
@classmethod
def from_code(cls, code: str) -> "Language":
"""
Get language from ISO 639-1 code.
Args:
code: Two-letter language code
Returns:
Language enum value
Raises:
ValidationError: If code is not supported
"""
code_lower = code.lower()
for lang in cls:
if lang.value == code_lower:
return lang
raise ValidationError(f"Unsupported language code: {code}")
TOKEN_IN_NAME = "token_in_name"
SIZE_AND_COUNT = "size_and_count"
FFPROBE_METADATA = "ffprobe_metadata"
class SubtitleFormat(Enum):
"""Supported subtitle formats."""
SRT = "srt" # SubRip
ASS = "ass" # Advanced SubStation Alpha
SSA = "ssa" # SubStation Alpha
VTT = "vtt" # WebVTT
SUB = "sub" # MicroDVD
@classmethod
def from_extension(cls, extension: str) -> "SubtitleFormat":
"""
Get format from file extension.
Args:
extension: File extension (with or without dot)
Returns:
SubtitleFormat enum value
Raises:
ValidationError: If extension is not supported
"""
ext = extension.lower().lstrip(".")
for fmt in cls:
if fmt.value == ext:
return fmt
raise ValidationError(f"Unsupported subtitle format: {extension}")
class SubtitleType(Enum):
STANDARD = "standard"
SDH = "sdh"
FORCED = "forced"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class TimingOffset:
"""
Value object representing subtitle timing offset in milliseconds.
class SubtitleFormat:
"""A known subtitle file format."""
Used for synchronizing subtitles with video.
id: str
extensions: list[str]
description: str = ""
def matches_extension(self, ext: str) -> bool:
return ext.lower() in [e.lower() for e in self.extensions]
@dataclass(frozen=True)
class SubtitleLanguage:
"""A known subtitle language with its recognition tokens."""
code: str # ISO 639-1
tokens: list[str] # lowercase
def matches_token(self, token: str) -> bool:
return token.lower() in self.tokens
@dataclass(frozen=True)
class SubtitlePattern:
"""
A known structural pattern for how a release group organises subtitle files.
Patterns are loaded from alfred/knowledge/patterns/*.yaml and are
independent of any specific release group — multiple groups can share
the same pattern.
"""
milliseconds: int
id: str
description: str
scan_strategy: ScanStrategy
root_folder: str | None # e.g. "Subs", None for adjacent/embedded
type_detection: TypeDetectionMethod
version: str = "1.0"
def __post_init__(self):
"""Validate timing offset."""
if not isinstance(self.milliseconds, int):
raise ValidationError(
f"Timing offset must be an integer, got {type(self.milliseconds)}"
)
def to_seconds(self) -> float:
"""Convert to seconds."""
return self.milliseconds / 1000.0
@dataclass(frozen=True)
class SubtitleMatchingRules:
"""
Effective rules after scope resolution (global → group → show → season → episode).
Only stores actual values — None means "inherited, not overridden at this level".
"""
def __str__(self) -> str:
return f"{self.milliseconds}ms"
preferred_languages: list[str] = field(default_factory=list) # ISO 639-1 codes
preferred_formats: list[str] = field(default_factory=list) # format ids
allowed_types: list[str] = field(default_factory=list) # SubtitleType ids
format_priority: list[str] = field(default_factory=list) # ordered format ids
min_confidence: float = 0.7
def __repr__(self) -> str:
return f"TimingOffset({self.milliseconds})"
@dataclass(frozen=True)
class RuleScope:
"""At which level a rule set applies."""
level: str # "global" | "release_group" | "movie" | "show" | "season" | "episode"
identifier: str | None = None # imdb_id, group name, "S01", "S01E03"…
+7 -3
View File
@@ -2,18 +2,22 @@
from .entities import Episode, Season, TVShow
from .exceptions import InvalidEpisode, SeasonNotFound, TVShowNotFound
from .services import TVShowService
from .value_objects import EpisodeNumber, SeasonNumber, ShowStatus
from .value_objects import (
CollectionStatus,
EpisodeNumber,
SeasonNumber,
ShowStatus,
)
__all__ = [
"TVShow",
"Season",
"Episode",
"ShowStatus",
"CollectionStatus",
"SeasonNumber",
"EpisodeNumber",
"TVShowNotFound",
"InvalidEpisode",
"SeasonNotFound",
"TVShowService",
]
+340 -130
View File
@@ -1,126 +1,242 @@
"""TV Show domain entities."""
"""TV Show domain entities.
This module implements the TVShow aggregate following DDD principles.
Aggregate ownership::
TVShow ← aggregate root (the repo returns this)
└── seasons: dict[SeasonNumber, Season]
└── Season
└── episodes: dict[EpisodeNumber, Episode]
└── Episode ← file metadata + audio/subtitle tracks
Rules:
* ``TVShow`` is the aggregate **root** — the only entity exposed by the
repository.
* ``Season`` is owned by TVShow. ``Episode`` is owned by Season.
* Children do not back-reference the root (no ``show_imdb_id`` on
Season/Episode): they are only ever reached *through* TVShow.
* Mutation invariants are enforced through aggregate-root methods such as
``TVShow.add_episode()`` — never reach into ``show.seasons[...].episodes``
to mutate without going through the root, otherwise invariants are not
guaranteed.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import datetime
from ..shared.value_objects import FilePath, FileSize, ImdbId
from .value_objects import EpisodeNumber, SeasonNumber, ShowStatus
from ..shared.media import AudioTrack, MediaWithTracks, SubtitleTrack
from ..shared.value_objects import (
FilePath,
FileSize,
ImdbId,
to_dot_folder_name,
)
from .value_objects import (
CollectionStatus,
EpisodeNumber,
SeasonNumber,
ShowStatus,
)
# ════════════════════════════════════════════════════════════════════════════
# Episode
# ════════════════════════════════════════════════════════════════════════════
@dataclass
class TVShow:
@dataclass(eq=False)
class Episode(MediaWithTracks):
"""
TV Show entity representing a TV show in the media library.
A single episode of a TV show — leaf of the TVShow aggregate.
This is the main aggregate root for the TV shows domain.
Migrated from agent/models/tv_show.py
Carries the file metadata (path, size) and the discovered tracks
(audio + subtitle). Track lists are populated by the ffprobe + subtitle
scan pipeline; they may be empty when the episode is known but not yet
scanned, or when no file is downloaded yet.
Equality is identity-based within the aggregate: two ``Episode`` instances
are equal iff they share the same ``(season_number, episode_number)``,
regardless of title/file/track contents. The root TVShow guarantees
cross-show uniqueness.
"""
imdb_id: ImdbId
season_number: SeasonNumber
episode_number: EpisodeNumber
title: str
seasons_count: int
status: ShowStatus
tmdb_id: int | None = None
first_air_date: str | None = None
added_at: datetime = field(default_factory=datetime.now)
file_path: FilePath | None = None
file_size: FileSize | None = None
audio_tracks: list[AudioTrack] = field(default_factory=list)
subtitle_tracks: list[SubtitleTrack] = field(default_factory=list)
def __post_init__(self):
"""Validate TV show entity."""
# Ensure ImdbId is actually an ImdbId instance
if not isinstance(self.imdb_id, ImdbId):
if isinstance(self.imdb_id, str):
object.__setattr__(self, "imdb_id", ImdbId(self.imdb_id))
else:
raise ValueError(
f"imdb_id must be ImdbId or str, got {type(self.imdb_id)}"
)
def __post_init__(self) -> None:
# Coerce numbers if raw ints were passed
if not isinstance(self.season_number, SeasonNumber):
if isinstance(self.season_number, int):
self.season_number = SeasonNumber(self.season_number)
if not isinstance(self.episode_number, EpisodeNumber):
if isinstance(self.episode_number, int):
self.episode_number = EpisodeNumber(self.episode_number)
# Ensure ShowStatus is actually a ShowStatus instance
if not isinstance(self.status, ShowStatus):
if isinstance(self.status, str):
object.__setattr__(self, "status", ShowStatus.from_string(self.status))
else:
raise ValueError(
f"status must be ShowStatus or str, got {type(self.status)}"
)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Episode):
return NotImplemented
return (
self.season_number == other.season_number
and self.episode_number == other.episode_number
)
# Validate seasons_count
if not isinstance(self.seasons_count, int) or self.seasons_count < 0:
raise ValueError(
f"seasons_count must be a non-negative integer, got {self.seasons_count}"
)
def __hash__(self) -> int:
return hash((self.season_number, self.episode_number))
def is_ongoing(self) -> bool:
"""Check if the show is still ongoing."""
return self.status == ShowStatus.ONGOING
# Track helpers (has_audio_in / audio_languages / has_subtitles_in /
# has_forced_subs / subtitle_languages) come from MediaWithTracks.
def is_ended(self) -> bool:
"""Check if the show has ended."""
return self.status == ShowStatus.ENDED
# ── Naming ─────────────────────────────────────────────────────────────
def get_folder_name(self) -> str:
"""
Get the folder name for this TV show.
Format: "Title"
Example: "Breaking.Bad"
"""
# Remove special characters and replace spaces with dots
cleaned = re.sub(r"[^\w\s\.\-]", "", self.title)
return cleaned.replace(" ", ".")
def get_filename(self) -> str:
"""Suggested filename: ``S01E05.Pilot``."""
season_str = f"S{self.season_number.value:02d}"
episode_str = f"E{self.episode_number.value:02d}"
clean_title = re.sub(r"[^\w\s\-]", "", self.title)
clean_title = clean_title.replace(" ", ".")
return f"{season_str}{episode_str}.{clean_title}"
def __str__(self) -> str:
return f"{self.title} ({self.status.value}, {self.seasons_count} seasons)"
return f"S{self.season_number.value:02d}E{self.episode_number.value:02d} - {self.title}"
def __repr__(self) -> str:
return f"TVShow(imdb_id={self.imdb_id}, title='{self.title}')"
return (
f"Episode(S{self.season_number.value:02d}E{self.episode_number.value:02d})"
)
# ════════════════════════════════════════════════════════════════════════════
# Season
# ════════════════════════════════════════════════════════════════════════════
@dataclass
class Season:
"""
Season entity representing a season of a TV show.
A season of a TV show — owned by ``TVShow``.
Owns its episodes via the ``episodes`` dict keyed by ``EpisodeNumber``.
Two TMDB-sourced counts shape the collection logic:
* ``expected_episodes`` — total episodes planned for the season
(``None`` if unknown).
* ``aired_episodes`` — episodes **already aired** as of the latest TMDB
refresh. ``None`` falls back to ``expected_episodes`` (best-effort).
The split matters: ``is_complete()`` checks owned against aired, so a season
in the middle of broadcasting can be "complete" today and become "partial"
later when new episodes air — that is correct behavior.
"""
show_imdb_id: ImdbId
season_number: SeasonNumber
episode_count: int
episodes: dict[EpisodeNumber, Episode] = field(default_factory=dict)
expected_episodes: int | None = None
aired_episodes: int | None = None
name: str | None = None
overview: str | None = None
air_date: str | None = None
poster_path: str | None = None
def __post_init__(self):
"""Validate season entity."""
# Ensure ImdbId is actually an ImdbId instance
if not isinstance(self.show_imdb_id, ImdbId):
if isinstance(self.show_imdb_id, str):
object.__setattr__(self, "show_imdb_id", ImdbId(self.show_imdb_id))
# Ensure SeasonNumber is actually a SeasonNumber instance
def __post_init__(self) -> None:
if not isinstance(self.season_number, SeasonNumber):
if isinstance(self.season_number, int):
object.__setattr__(
self, "season_number", SeasonNumber(self.season_number)
)
self.season_number = SeasonNumber(self.season_number)
# Validate episode_count
if not isinstance(self.episode_count, int) or self.episode_count < 0:
if self.expected_episodes is not None and self.expected_episodes < 0:
raise ValueError(
f"episode_count must be a non-negative integer, got {self.episode_count}"
f"expected_episodes must be >= 0, got {self.expected_episodes}"
)
if self.aired_episodes is not None and self.aired_episodes < 0:
raise ValueError(f"aired_episodes must be >= 0, got {self.aired_episodes}")
if (
self.expected_episodes is not None
and self.aired_episodes is not None
and self.aired_episodes > self.expected_episodes
):
raise ValueError(
f"aired_episodes ({self.aired_episodes}) cannot exceed "
f"expected_episodes ({self.expected_episodes})"
)
# ── Properties ─────────────────────────────────────────────────────────
@property
def episode_count(self) -> int:
"""Number of episodes currently owned in this season."""
return len(self.episodes)
# ── Collection state ───────────────────────────────────────────────────
def _effective_aired(self) -> int | None:
"""``aired_episodes`` if set, else fall back to ``expected_episodes``."""
return (
self.aired_episodes
if self.aired_episodes is not None
else self.expected_episodes
)
def is_complete(self) -> bool:
"""
True if every aired episode is owned.
Returns False (conservative) when the aired count is unknown — without
knowing how many episodes have aired we cannot claim completeness.
"""
aired = self._effective_aired()
if aired is None:
return False
if aired == 0:
# No episode has aired yet → trivially "complete"
return True
return len(self.episodes) >= aired
def is_fully_aired(self) -> bool:
"""True if all planned episodes have already aired."""
if self.expected_episodes is None or self.aired_episodes is None:
return False
return self.aired_episodes >= self.expected_episodes
def missing_episodes(self) -> list[EpisodeNumber]:
"""
List of episode numbers that have aired but are not owned.
Episodes beyond ``aired_episodes`` are **not** considered missing
(they have not aired yet). When the aired count is unknown, returns
an empty list — we cannot reason about gaps without a target.
"""
aired = self._effective_aired()
if aired is None or aired <= 0:
return []
present = {ep.value for ep in self.episodes}
return [EpisodeNumber(n) for n in range(1, aired + 1) if n not in present]
# ── Mutation (called through the aggregate root) ───────────────────────
def add_episode(self, episode: Episode) -> None:
"""
Insert an episode into this season. Replaces any episode with the same
number — callers wishing to detect conflicts should check beforehand.
"""
if episode.season_number != self.season_number:
raise ValueError(
f"Episode season ({episode.season_number}) does not match season "
f"({self.season_number})"
)
self.episodes[episode.episode_number] = episode
# ── Naming ─────────────────────────────────────────────────────────────
def is_special(self) -> bool:
"""Check if this is the specials season."""
return self.season_number.is_special()
def get_folder_name(self) -> str:
"""
Get the folder name for this season.
Format: "Season 01" or "Specials" for season 0
"""
"""``Season 01`` or ``Specials`` for season 0."""
if self.is_special():
return "Specials"
return f"Season {self.season_number.value:02d}"
@@ -131,74 +247,168 @@ class Season:
return f"Season {self.season_number.value}"
def __repr__(self) -> str:
return f"Season(show={self.show_imdb_id}, number={self.season_number.value})"
return (
f"Season(number={self.season_number.value}, episodes={len(self.episodes)})"
)
# ════════════════════════════════════════════════════════════════════════════
# TVShow — aggregate root
# ════════════════════════════════════════════════════════════════════════════
@dataclass
class Episode:
class TVShow:
"""
Episode entity representing an episode of a TV show.
Aggregate root for the TV shows domain.
Owns its seasons via the ``seasons`` dict keyed by ``SeasonNumber``.
All mutations (adding episodes, creating seasons) MUST go through the
methods on this class — that is how invariants are preserved.
Two axes describe the show, kept deliberately orthogonal:
* ``status`` (``ShowStatus``) — production state (TMDB-sourced).
* ``collection_status()`` — what the user owns vs what has aired today.
A third axis (upcoming/scheduled) will be added later as a separate flag
when scheduling support is introduced; for now we make no claim about
future episodes.
"""
show_imdb_id: ImdbId
season_number: SeasonNumber
episode_number: EpisodeNumber
imdb_id: ImdbId
title: str
file_path: FilePath | None = None
file_size: FileSize | None = None
overview: str | None = None
air_date: str | None = None
still_path: str | None = None
vote_average: float | None = None
runtime: int | None = None # in minutes
status: ShowStatus
seasons: dict[SeasonNumber, Season] = field(default_factory=dict)
expected_seasons: int | None = None
tmdb_id: int | None = None
def __post_init__(self):
"""Validate episode entity."""
# Ensure ImdbId is actually an ImdbId instance
if not isinstance(self.show_imdb_id, ImdbId):
if isinstance(self.show_imdb_id, str):
object.__setattr__(self, "show_imdb_id", ImdbId(self.show_imdb_id))
# Ensure SeasonNumber is actually a SeasonNumber instance
if not isinstance(self.season_number, SeasonNumber):
if isinstance(self.season_number, int):
object.__setattr__(
self, "season_number", SeasonNumber(self.season_number)
def __post_init__(self) -> None:
if not isinstance(self.imdb_id, ImdbId):
if isinstance(self.imdb_id, str):
self.imdb_id = ImdbId(self.imdb_id)
else:
raise ValueError(
f"imdb_id must be ImdbId or str, got {type(self.imdb_id)}"
)
# Ensure EpisodeNumber is actually an EpisodeNumber instance
if not isinstance(self.episode_number, EpisodeNumber):
if isinstance(self.episode_number, int):
object.__setattr__(
self, "episode_number", EpisodeNumber(self.episode_number)
if not isinstance(self.status, ShowStatus):
if isinstance(self.status, str):
self.status = ShowStatus.from_string(self.status)
else:
raise ValueError(
f"status must be ShowStatus or str, got {type(self.status)}"
)
def has_file(self) -> bool:
"""Check if the episode has an associated file."""
return self.file_path is not None and self.file_path.exists()
if self.expected_seasons is not None and self.expected_seasons < 0:
raise ValueError(
f"expected_seasons must be >= 0, got {self.expected_seasons}"
)
def is_downloaded(self) -> bool:
"""Check if the episode is downloaded."""
return self.has_file()
# ── Production-state queries ───────────────────────────────────────────
def get_filename(self) -> str:
def is_ongoing(self) -> bool:
return self.status == ShowStatus.ONGOING
def is_ended(self) -> bool:
return self.status == ShowStatus.ENDED
# ── Properties ─────────────────────────────────────────────────────────
@property
def seasons_count(self) -> int:
"""Number of seasons currently owned (any episode count, even 0)."""
return len(self.seasons)
@property
def episode_count(self) -> int:
"""Total episodes owned across all seasons."""
return sum(s.episode_count for s in self.seasons.values())
# ── Mutation — the sole entry point for adding content ─────────────────
def add_episode(self, episode: Episode) -> None:
"""
Get the suggested filename for this episode.
Add an episode to the appropriate season, creating the season if needed.
Format: "S01E01 - Episode Title.ext"
Example: "S01E05 - Pilot.mkv"
This is the **only** sanctioned way to add content to the aggregate —
it preserves the invariant that an episode is always reachable through
``show.seasons[s].episodes[e]``.
"""
season_str = f"S{self.season_number.value:02d}"
episode_str = f"E{self.episode_number.value:02d}"
season = self.seasons.get(episode.season_number)
if season is None:
season = Season(season_number=episode.season_number)
self.seasons[episode.season_number] = season
season.add_episode(episode)
# Clean title for filename
clean_title = re.sub(r"[^\w\s\-]", "", self.title)
clean_title = clean_title.replace(" ", ".")
def add_season(self, season: Season) -> None:
"""
Attach a (possibly already populated) Season to the show.
return f"{season_str}{episode_str}.{clean_title}"
Replaces any existing season with the same number.
"""
self.seasons[season.season_number] = season
# ── Collection state ───────────────────────────────────────────────────
def collection_status(self) -> CollectionStatus:
"""
High-level state of the user's collection for this show.
* ``EMPTY`` — no episode owned
* ``COMPLETE`` — every season is complete relative to its aired count
* ``PARTIAL`` — at least one aired episode is missing
Seasons with an unknown aired count are treated conservatively: if no
season has any episode, the show is EMPTY; otherwise the unknown
seasons cannot prove completeness, so the show is PARTIAL.
"""
if self.episode_count == 0:
return CollectionStatus.EMPTY
# Check completeness across all seasons we know about
for season in self.seasons.values():
if not season.is_complete():
return CollectionStatus.PARTIAL
# We also need to consider whether seasons themselves are missing.
# If expected_seasons is known and we have fewer seasons than expected,
# the missing seasons may have aired episodes → cannot claim COMPLETE.
if (
self.expected_seasons is not None
and len(self.seasons) < self.expected_seasons
):
return CollectionStatus.PARTIAL
return CollectionStatus.COMPLETE
def is_complete_series(self) -> bool:
"""
True if the show is finished (ENDED) **and** the collection is complete.
This is the strongest "I own the entire series, no more to come" claim
we can make today, before scheduling/upcoming-episode awareness lands.
"""
return self.is_ended() and self.collection_status() == CollectionStatus.COMPLETE
def missing_episodes(self) -> list[tuple[SeasonNumber, EpisodeNumber]]:
"""All aired-but-not-owned ``(season, episode)`` pairs across the show."""
result: list[tuple[SeasonNumber, EpisodeNumber]] = []
for season_number, season in sorted(
self.seasons.items(), key=lambda kv: kv[0].value
):
for ep_number in season.missing_episodes():
result.append((season_number, ep_number))
return result
# ── Naming ─────────────────────────────────────────────────────────────
def get_folder_name(self) -> str:
"""Dot-separated folder name (e.g. ``Breaking.Bad``)."""
return to_dot_folder_name(self.title)
def __str__(self) -> str:
return f"S{self.season_number.value:02d}E{self.episode_number.value:02d} - {self.title}"
return f"{self.title} ({self.status.value}, {self.seasons_count} seasons)"
def __repr__(self) -> str:
return f"Episode(show={self.show_imdb_id}, S{self.season_number.value:02d}E{self.episode_number.value:02d})"
return f"TVShow(imdb_id={self.imdb_id}, title='{self.title}')"
+15 -101
View File
@@ -1,126 +1,40 @@
"""TV Show repository interfaces (abstract)."""
"""TV Show repository interface.
A single repository for the aggregate root only — Season and Episode are
**inside** the TVShow aggregate and are never persisted independently. The
aggregate is always loaded and saved as a whole.
"""
from abc import ABC, abstractmethod
from ..shared.value_objects import ImdbId
from .entities import Episode, Season, TVShow
from .value_objects import EpisodeNumber, SeasonNumber
from .entities import TVShow
class TVShowRepository(ABC):
"""
Abstract repository for TV show persistence.
Abstract repository for the TVShow aggregate.
This defines the interface that infrastructure implementations must follow.
Implementations are responsible for persisting the full aggregate graph
(TVShow + all its Seasons + all their Episodes) atomically.
"""
@abstractmethod
def save(self, show: TVShow) -> None:
"""
Save a TV show to the repository.
Args:
show: TVShow entity to save
"""
pass
"""Persist the full TVShow aggregate."""
@abstractmethod
def find_by_imdb_id(self, imdb_id: ImdbId) -> TVShow | None:
"""
Find a TV show by its IMDb ID.
Args:
imdb_id: IMDb ID to search for
Returns:
TVShow if found, None otherwise
"""
pass
"""Load the full TVShow aggregate by IMDb ID, or None if absent."""
@abstractmethod
def find_all(self) -> list[TVShow]:
"""
Get all TV shows in the repository.
Returns:
List of all TV shows
"""
pass
"""Load all TVShow aggregates."""
@abstractmethod
def delete(self, imdb_id: ImdbId) -> bool:
"""
Delete a TV show from the repository.
Args:
imdb_id: IMDb ID of the show to delete
Returns:
True if deleted, False if not found
"""
pass
"""Remove the aggregate. Returns True if it existed and was deleted."""
@abstractmethod
def exists(self, imdb_id: ImdbId) -> bool:
"""
Check if a TV show exists in the repository.
Args:
imdb_id: IMDb ID to check
Returns:
True if exists, False otherwise
"""
pass
class SeasonRepository(ABC):
"""Abstract repository for season persistence."""
@abstractmethod
def save(self, season: Season) -> None:
"""Save a season."""
pass
@abstractmethod
def find_by_show_and_number(
self, show_imdb_id: ImdbId, season_number: SeasonNumber
) -> Season | None:
"""Find a season by show and season number."""
pass
@abstractmethod
def find_all_by_show(self, show_imdb_id: ImdbId) -> list[Season]:
"""Get all seasons for a show."""
pass
class EpisodeRepository(ABC):
"""Abstract repository for episode persistence."""
@abstractmethod
def save(self, episode: Episode) -> None:
"""Save an episode."""
pass
@abstractmethod
def find_by_show_season_episode(
self,
show_imdb_id: ImdbId,
season_number: SeasonNumber,
episode_number: EpisodeNumber,
) -> Episode | None:
"""Find an episode by show, season, and episode number."""
pass
@abstractmethod
def find_all_by_season(
self, show_imdb_id: ImdbId, season_number: SeasonNumber
) -> list[Episode]:
"""Get all episodes for a season."""
pass
@abstractmethod
def find_all_by_show(self, show_imdb_id: ImdbId) -> list[Episode]:
"""Get all episodes for a show."""
pass
"""True if the aggregate exists in the store."""
-234
View File
@@ -1,234 +0,0 @@
"""TV Show domain services - Business logic."""
import logging
import re
from ..shared.value_objects import ImdbId
from .entities import TVShow
from .exceptions import (
TVShowAlreadyExists,
TVShowNotFound,
)
from .repositories import EpisodeRepository, SeasonRepository, TVShowRepository
logger = logging.getLogger(__name__)
class TVShowService:
"""
Domain service for TV show-related business logic.
This service contains business rules that don't naturally fit
within a single entity.
"""
def __init__(
self,
show_repository: TVShowRepository,
season_repository: SeasonRepository | None = None,
episode_repository: EpisodeRepository | None = None,
):
"""
Initialize TV show service.
Args:
show_repository: TV show repository for persistence
season_repository: Optional season repository
episode_repository: Optional episode repository
"""
self.show_repository = show_repository
self.season_repository = season_repository
self.episode_repository = episode_repository
def track_show(self, show: TVShow) -> None:
"""
Start tracking a TV show.
Args:
show: TVShow entity to track
Raises:
TVShowAlreadyExists: If show is already being tracked
"""
if self.show_repository.exists(show.imdb_id):
raise TVShowAlreadyExists(
f"TV show with IMDb ID {show.imdb_id} is already tracked"
)
self.show_repository.save(show)
logger.info(f"Started tracking TV show: {show.title} ({show.imdb_id})")
def get_show(self, imdb_id: ImdbId) -> TVShow:
"""
Get a TV show by IMDb ID.
Args:
imdb_id: IMDb ID of the show
Returns:
TVShow entity
Raises:
TVShowNotFound: If show not found
"""
show = self.show_repository.find_by_imdb_id(imdb_id)
if not show:
raise TVShowNotFound(f"TV show with IMDb ID {imdb_id} not found")
return show
def get_all_shows(self) -> list[TVShow]:
"""
Get all tracked TV shows.
Returns:
List of all TV shows
"""
return self.show_repository.find_all()
def get_ongoing_shows(self) -> list[TVShow]:
"""
Get all ongoing TV shows.
Returns:
List of ongoing TV shows
"""
all_shows = self.show_repository.find_all()
return [show for show in all_shows if show.is_ongoing()]
def get_ended_shows(self) -> list[TVShow]:
"""
Get all ended TV shows.
Returns:
List of ended TV shows
"""
all_shows = self.show_repository.find_all()
return [show for show in all_shows if show.is_ended()]
def update_show(self, show: TVShow) -> None:
"""
Update an existing TV show.
Args:
show: TVShow entity with updated data
Raises:
TVShowNotFound: If show doesn't exist
"""
if not self.show_repository.exists(show.imdb_id):
raise TVShowNotFound(f"TV show with IMDb ID {show.imdb_id} not found")
self.show_repository.save(show)
logger.info(f"Updated TV show: {show.title} ({show.imdb_id})")
def untrack_show(self, imdb_id: ImdbId) -> None:
"""
Stop tracking a TV show.
Args:
imdb_id: IMDb ID of the show to untrack
Raises:
TVShowNotFound: If show not found
"""
if not self.show_repository.delete(imdb_id):
raise TVShowNotFound(f"TV show with IMDb ID {imdb_id} not found")
logger.info(f"Stopped tracking TV show with IMDb ID: {imdb_id}")
def parse_episode_from_filename(self, filename: str) -> tuple[int, int] | None:
"""
Parse season and episode numbers from filename.
Supports formats:
- S01E05
- 1x05
- Season 1 Episode 5
Args:
filename: Filename to parse
Returns:
Tuple of (season, episode) if found, None otherwise
"""
filename_lower = filename.lower()
# Pattern 1: S01E05
pattern1 = r"s(\d{1,2})e(\d{1,2})"
match = re.search(pattern1, filename_lower)
if match:
return (int(match.group(1)), int(match.group(2)))
# Pattern 2: 1x05
pattern2 = r"(\d{1,2})x(\d{1,2})"
match = re.search(pattern2, filename_lower)
if match:
return (int(match.group(1)), int(match.group(2)))
# Pattern 3: Season 1 Episode 5
pattern3 = r"season\s*(\d{1,2})\s*episode\s*(\d{1,2})"
match = re.search(pattern3, filename_lower)
if match:
return (int(match.group(1)), int(match.group(2)))
return None
def validate_episode_file(self, filename: str) -> bool:
"""
Validate that a file is a valid episode file.
Args:
filename: Filename to validate
Returns:
True if valid episode file, False otherwise
"""
# Check file extension
valid_extensions = {".mkv", ".mp4", ".avi", ".mov", ".wmv", ".flv", ".webm"}
extension = filename[filename.rfind(".") :].lower() if "." in filename else ""
if extension not in valid_extensions:
logger.warning(f"Invalid file extension: {extension}")
return False
# Check if we can parse episode info
episode_info = self.parse_episode_from_filename(filename)
if not episode_info:
logger.warning(f"Could not parse episode info from filename: {filename}")
return False
return True
def find_next_episode(
self, show: TVShow, last_season: int, last_episode: int
) -> tuple[int, int] | None:
"""
Find the next episode to download for a show.
Args:
show: TVShow entity
last_season: Last downloaded season number
last_episode: Last downloaded episode number
Returns:
Tuple of (season, episode) for next episode, or None if show is complete
"""
# If show has ended and we've watched all seasons, no next episode
if show.is_ended() and last_season >= show.seasons_count:
return None
# Simple logic: next episode in same season, or first episode of next season
# This could be enhanced with actual episode counts per season
next_episode = last_episode + 1
next_season = last_season
# Assume max 50 episodes per season (could be improved with actual data)
if next_episode > 50:
next_season += 1
next_episode = 1
# Don't go beyond known seasons
if next_season > show.seasons_count:
return None
return (next_season, next_episode)
+47 -8
View File
@@ -1,5 +1,7 @@
"""TV Show domain value objects."""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
@@ -7,28 +9,48 @@ from ..shared.exceptions import ValidationError
class ShowStatus(Enum):
"""Status of a TV show - whether it's still airing or has ended."""
"""
Production status of a TV show (real-world, source of truth = TMDB).
Describes the **production** state of the show, independently of what
the user owns. Orthogonal to ``CollectionStatus``.
"""
ONGOING = "ongoing"
ENDED = "ended"
UNKNOWN = "unknown"
@classmethod
def from_string(cls, status_str: str) -> "ShowStatus":
def from_string(cls, status_str: str) -> ShowStatus:
"""
Parse status from string.
Parse a production status string into a ShowStatus.
Args:
status_str: Status string (e.g., "ongoing", "ended")
Accepts our internal vocabulary ("ongoing", "ended") as well as the
statuses returned by TMDB ("Returning Series", "In Production",
"Pilot", "Ended", "Canceled"). The mapping is intentionally binary:
Returns:
ShowStatus enum value
* ONGOING — any state where new episodes may still ship
* ENDED — production has stopped (naturally or cancelled)
* UNKNOWN — anything else / unrecognized
Comparison is case-insensitive and whitespace-trimmed.
"""
if not status_str:
return cls.UNKNOWN
key = status_str.strip().lower()
status_map = {
# Internal
"ongoing": cls.ONGOING,
"ended": cls.ENDED,
# TMDB
"returning series": cls.ONGOING,
"in production": cls.ONGOING,
"pilot": cls.ONGOING,
"planned": cls.ONGOING,
"canceled": cls.ENDED,
"cancelled": cls.ENDED,
}
return status_map.get(status_str.lower(), cls.UNKNOWN)
return status_map.get(key, cls.UNKNOWN)
@dataclass(frozen=True)
@@ -70,6 +92,23 @@ class SeasonNumber:
return self.value
class CollectionStatus(Enum):
"""
State of the user's **collection** for a TV show (orthogonal to ShowStatus).
Compares possessed episodes against episodes **already aired** — never
against announced/upcoming ones. A returning show with all aired episodes
owned is ``COMPLETE``, not ``PARTIAL``, even if more seasons are upcoming.
Future scheduling info (upcoming seasons, next airing date) will live on
the TVShow aggregate as separate flags, not in this enum.
"""
EMPTY = "empty" # 0 episode owned
PARTIAL = "partial" # some aired episodes are missing
COMPLETE = "complete" # all aired-to-date episodes are owned
@dataclass(frozen=True)
class EpisodeNumber:
"""

Some files were not shown because too many files have changed in this diff Show More