1 Commits

Author SHA1 Message Date
Renovate Bot cdbc380b77 chore(deps): update dependency ruff to ~=0.15.16
renovate/artifacts Artifact file update failure
2026-06-08 04:00:32 +00:00
313 changed files with 4739 additions and 9986 deletions
-6
View File
@@ -74,11 +74,5 @@ docs/
# .md files (project-level Markdown is brol-y; allow-list the ones we track)
*.md
!CHANGELOG.md
!/README.md
!specs/
!specs/**/*.md
# Private dev docs (separate git repo inside; see .claude/CLAUDE.md)
/.claude/
#
-500
View File
@@ -15,506 +15,6 @@ callers).
## [Unreleased]
### Changed
- **`filesystem` infra + application rewritten as 5 atomic free
functions.** On branch `unfuck`. Replaces the monolithic
`FileManager` class + scattered helpers with five small, pure ops in
`alfred/infrastructure/filesystem/`: `list_dir`, `create_dir`,
`link_file`, `move_file`, `move_dir`. Each takes `pathlib.Path`
arguments and raises typed exceptions from a dedicated hierarchy
(`FilesystemError``SourceNotFound` / `DestinationExists` /
`NotADirectory` / `NotAFile` / `PermissionDenied` / `CrossDevice` /
`FilesystemOSError`) — no more `{"status": "ok" | "error"}` dicts at
the infra boundary, no more `get_memory()` reads.
- **`filesystem` application: 5 use cases as free functions.** A
matching `<op>_use_case(path, …, roots: DirectoryRoots)` wraps each
infra op, guards inputs against escaping a new `DirectoryRoots` VO
(downloads / torrents / movies / tv_shows), catches infra exceptions,
and returns a frozen `<Op>Response` DTO. Roots are now injected, not
pulled from the global memory singleton.
- **Agent tool wrappers partially re-wired** to the new use cases.
`list_folder` now delegates to `list_dir_use_case`; `move_media`
to `move_file_use_case`; `move_to_destination` chains
`create_dir_use_case` + `move_file_use_case`; a new
`create_directory` tool wraps `create_dir_use_case`. Roots are
loaded once via a module-level `_load_directory_roots()` helper
that reads the persisted memory (no more per-call singleton
reads inside the use cases themselves).
### Removed
- `FileManager` / `MediaOrganizer` / `create_folder` / `move` from the
public API of `alfred.infrastructure.filesystem`. Their files remain
on disk renamed with an `_OLD` suffix (e.g. `file_manager_OLD.py`) so
the migration can finish on a follow-up commit without losing
reference material. They are no longer re-exported from `__init__`.
- `CreateSeedLinksUseCase` / `ListFolderUseCase` / `MoveMediaUseCase` /
`ManageSubtitlesUseCase` / `resolve_destination` from the public API
of `alfred.application.filesystem`. Same `_OLD` rename treatment.
This intentionally breaks current tool wrappers and tests downstream
— re-wiring is the next chunk of work on this branch.
- **Agent tools dropped during the refactor** (to be reintroduced
when the matching domain/application code lands):
`manage_subtitles`, `set_path_for_folder`, `create_seed_links`,
`resolve_season_destination`, `resolve_episode_destination`,
`resolve_movie_destination`, `resolve_series_destination`.
Their wrappers are removed from `alfred.agent.tools.filesystem`;
`alfred.agent.tools.__init__` now re-exports only what still
imports cleanly. `find_media_imdb_id` (already broken before this
branch — name no longer exported by `tools.api`) was also dropped
from the package re-exports.
### Added
- **`.alfred` v2 — Phase 4: v2-shaped `rescan_show` + new
`rescan_movie` + index anchor-warning + `tmdb_cache_ttl_days`
setting.** Fourth and final structural phase of
`specs/dot_alfred_v2.md` on branch `refactor/dot-alfred-v2`. The TV
+ movie rescan orchestrators now write v2 release aggregates
(`SeriesRelease` / `MovieRelease`) via the concrete v2
repositories; the library index keeps auto-healing from the new
sidecars on its next read (no TMDB call from rescan — that stays
Phase 5).
- **`rescan_show`** moves from `alfred/application/library/` to
`alfred/application/tv_shows/` (symmetry with the new
`alfred/application/movies/`). New signature:
`(show_root, *, tmdb_id: TmdbId, imdb_id: ImdbId | None = None,
series_repo, scanner, prober, kb) -> SeriesRelease`.
- **`rescan_movie`** (new — `alfred/application/movies/rescan.py`)
locates the main video via `find_video_file`, runs
`inspect_release` once, and writes the per-movie `.alfred`
sidecar. `added_at = datetime.now(UTC)` on every rescan (the
sidecar records reconciliation time, not filesystem mtime).
Raises `MovieRescanFailed` when no video is found in the folder.
- **PACK semantics in `rescan_show`**: a single-video + no-episode
season becomes `SeasonRelease(mode=PACK, folder=…, episodes=())`.
The slot map stays empty until the Phase 5 TMDB sync supplies
`episode_count` — no fabricated `EpisodeRange` lands in the
sidecar. *(Superseded by Phase 4b — see Fixed.)*
- **`Settings.tmdb_cache_ttl_days: int = 14`** — placeholder for the
Phase 5 TTL policy on library-index entries (`fetched_at + TTL`
drives refresh decisions).
- **Library-index anchor-mismatch warning** — both
`DotAlfredTVShowLibraryIndex` and `DotAlfredMovieLibraryIndex` now
cross-check each entry's `metadata.path` against the on-disk
folder layout right after a successful parse. Drift is logged as a
`WARNING` (one per missing folder, with `tmdb_id`); the heal path
stays silent by construction (it always synthesizes from real
folder names).
- **`.alfred` v2 — Phase 5: TMDB sync orchestrators.** Fifth phase
of `specs/dot_alfred_v2.md` on branch `refactor/dot-alfred-v2`.
Two new orchestrators refresh the library-root index's
TMDB-cached fields from on-disk truth + a single TMDB call:
- **`sync_show`** (`alfred/application/tv_shows/sync.py`) calls
`TMDBClient.get_tv_show_info`, loads the release via
`DotAlfredSeriesReleaseRepository.load_by_tmdb_id`, and upserts
the result into `DotAlfredTVShowLibraryIndex`. Honors
`Settings.tmdb_cache_ttl_days`; placeholder entries (auto-healed,
`status == "unknown"`) always refresh; `force=True` overrides
both gates. Raises `ShowNotFoundInLibrary` when neither index nor
sidecar carry `tmdb_id`. Indexed shows with a missing per-show
sidecar still get a fresh TMDB pass — slot map clears until
rescan repopulates it.
- **`sync_movie`** (`alfred/application/movies/sync.py`) is the
movie-side parallel. Placeholder signature is `name ==
metadata.path` (auto-heal copies the folder name into `name`;
the sidecar schema requires `name` non-empty so we can't use
`name == ""`). When the per-movie sidecar is gone but the
index entry remains, sync warns and returns the existing entry
unchanged (no upsert possible without a release).
- **`TmdbMovieInfo` DTO + `TMDBClient.get_movie_info`** — symmetric
to the existing `TmdbShowInfo` / `get_tv_show_info` pair. Carries
`tmdb_id`, `imdb_id`, `title`, and `release_year` (parsed from
TMDB's `release_date`).
- **`load_by_tmdb_id` on the v2 release repositories.** The series
repo returns `(SeriesRelease, show_folder_name)` so the sync
orchestrator can feed `DotAlfredTVShowLibraryIndex.upsert(...,
path=...)`; the movie repo returns `MovieRelease` alone (folder is
on `release.folder` already) and is provided as a semantic alias
of `find_by_tmdb_id` for symmetry.
- **`alfred/application/exceptions.py`** — new module for the two
shared `*NotFoundInLibrary` exceptions raised by the sync
orchestrators (`ShowNotFoundInLibrary`, `MovieNotFoundInLibrary`).
### Fixed
- **PACK vs EPISODIC classification (Phase 4b).** The Phase 4
walker + `rescan_show` logic classified seasons by parser output
(does the filename carry `Exx`?), but PACK vs EPISODIC is a
*structural* distinction:
- **PACK** = season folder with N flat `SxxEyy` videos.
- **EPISODIC** = season folder with N subfolders, each holding
one video.
The walker now descends two levels under `show_root` and
classifies per season folder. Mixed (flat + subfolders) is
malformed — warn and skip. `rescan_show` trusts the walker's
mode and stops conflating "single un-numbered video" with PACK
(that case is now skipped as malformed too). Tests rewritten
against the real model. Supersedes the PACK-semantics bullet
above in Added.
### Removed
- **v1 dot_alfred stack and its abstract domain ports.** Deleted
`alfred/infrastructure/persistence/dot_alfred/{bridge,repository,
serializer,sidecar}.py`, plus the
`alfred/domain/{tv_shows,movies}/repositories.py` ABCs
(`TVShowRepository` / `MovieRepository`) — zero callers after
Phase 4. `dot_alfred/__init__.py` is rewritten as a v2-only
re-export (four concrete repositories + `ShowFolderUnknown`).
- **`alfred/application/library/` package** (rescan + walker moved
to `alfred/application/tv_shows/`).
- The two Phase 3 module-level test skips
(`test_repository.py`, `test_serializer.py`) are lifted by
deleting the quarantined files.
- **`MediaWithTracks` mixin + `track_lang_matches` helper** in
`alfred.domain.shared.media`. Parked in Phase 4 pending a
Phase 5 decision; zero callers across `alfred/` and `tests/`
after the v2 aggregates landed, so both go.
### Internal
- **Suite**: 1233 → 1277 passing; 10 → 8 skips (only LLM-not-running
skips remain — the Phase 3 quarantines are gone with their files).
- Phase 5 cleanup sweep returns zero hits for `MediaWithTracks`,
v1 dot_alfred symbols, v1 sidecar names, and `alfred.application.
library` — the v2 surface is the only one left.
### Changed
- **`.alfred` v2 — Phase 3: `TVShow` / `Movie` aggregates become
TMDB-only.** Third phase of `specs/dot_alfred_v2.md` on branch
`refactor/dot-alfred-v2`. Filesystem-side concerns (file paths,
tracks, quality, mode, `added_at`) move to the `releases/` domain
added in Phase 1; the TMDB aggregates now carry only identity +
TMDB catalog facts.
- **`TVShow`** — `tmdb_id: TmdbId` is now the **required primary
key**; `imdb_id: ImdbId | None` is the optional secondary anchor.
Added `status: str = "unknown"` (raw TMDB string, default matches
the v2 library-index auto-heal placeholder). `episode_count`
aggregates the TMDB-cached counts on each `Season` (was: sum of
materialized `Episode` objects).
- **`Season`** — added `episode_count: int = 0` (TMDB-cached,
authoritative). **Removed**: `audio_tracks`, `subtitle_tracks`,
and the `mode` property (release mode now lives only on
`SeasonRelease.mode` — single source of truth).
- **`Episode`** — slimmed to identity + title. **Removed**:
`file_path`, `file_size`, `audio_tracks`, `subtitle_tracks`. The
`MediaWithTracks` mixin is no longer in `Episode`'s MRO; on-disk
facts live on the matching `EpisodeRelease` keyed by
`(season_number, episode_number)`.
- **`Movie`** — `tmdb_id: TmdbId` required, `imdb_id` optional.
**Removed**: `file_path`, `file_size`, `quality`, `added_at`,
`audio_tracks`, `subtitle_tracks`. `get_filename()` now returns
`"Title.Year"` (quality lives on `MovieRelease` and is appended
by a release-aware caller — Phase 4 wires this through
`MediaOrganizer`).
- **`TVShowBuilder` / `SeasonBuilder`** — constructor requires
`tmdb_id: TmdbId`; `imdb_id` and `status` are optional.
`SeasonBuilder.set_episode_count(int)` replaces the old
`set_audio_tracks` / `set_subtitle_tracks` (tracks no longer
persisted on `Season`).
- **`MovieRelease` carries `added_at: datetime`** (required).
Bumped `dot_alfred/v2` `SCHEMA_VERSION` from `1``2` to add
`added_at: datetime` to `MovieReleaseSidecar`. Round-trip via
Pydantic `mode="json"` (datetime ↔ ISO 8601 string). No migration
code shipped — no v2.1 sidecars exist in the wild yet.
- **No-coercion `TmdbId` contract.** `TVShow(tmdb_id=1396)` now raises
— callers pass `TmdbId(1396)`. Same for `imdb_id: ImdbId | None`
on `TVShow`/`Movie`. Honest type contract, no ergonomic shim.
### Removed
- `Season.mode` property (derive from `SeasonRelease.mode` instead).
- `Episode.file_path` / `file_size` / `audio_tracks` /
`subtitle_tracks`.
- `Movie.file_path` / `file_size` / `quality` / `added_at` /
`audio_tracks` / `subtitle_tracks`.
### Internal
- v1 dot_alfred package (`bridge.py`, `repository.py`,
`serializer.py`, `sidecar.py`), the abstract `TVShowRepository` /
`MovieRepository` ports typed against the pre-Phase-3 aggregates,
and `alfred/application/library/rescan.py` are **intentionally
left in tree as a known-red island**. Their tests
(`tests/infrastructure/persistence/dot_alfred/test_repository.py`,
`test_serializer.py`, `tests/application/library/test_rescan.py`)
are module-level skipped with a Phase 4 reference. Phase 4 rewrites
`rescan_show` / introduces `rescan_movie` on top of the v2
release repositories + library index, then deletes the v1 stack +
the abstract ports + the quarantined tests in one swing.
- Test suite: 1216 passed, 11 skipped (8 pre-existing + 3 Phase-3
quarantines), 4 xfailed. v2 round-trip tests now reference
`SCHEMA_VERSION` instead of hard-coded `1` for future-proofing.
### Added
- **`.alfred` v2 — Phase 2: new persistence package + TMDB client
extensions.** Second phase of `specs/dot_alfred_v2.md` on branch
`refactor/dot-alfred-v2`. The new
`alfred/infrastructure/persistence/dot_alfred/v2/` package ships
the full v2 sidecar stack while leaving v1 (and the existing
`TVShow` aggregate) untouched — Phase 3 is the cutover.
- **Pydantic DTOs** — `SeriesReleaseSidecar` /
`MovieReleaseSidecar` (per-item), `TVShowLibraryIndexSidecar` /
`MovieLibraryIndexSidecar` (library-root index). All built on a
common `_Strict` base (`extra="forbid"`, `frozen=True`) with a
`@model_validator` enforcing `schema_version == 1`.
- **Track entries** — `AudioTrackEntry` / `SubtitleEntry` (sidecar
cache shape, slimmed from the domain track types). `SubtitleEntry`
carries `is_forced` + `is_sdh` as explicit booleans (v1's
`type: "sdh"` overload is gone).
- **Serializer** — `read_yaml` / `atomic_write_yaml` helpers
centralize YAML I/O and atomic writes (`.tmp + os.replace`).
`SidecarSchemaError` wraps both YAML parse errors and Pydantic
validation errors for uniform catch-and-skip semantics.
- **Bridge** — lossless `domain ↔ sidecar` conversion for
`SeriesRelease` / `MovieRelease` (round-trippable, including
multi-episode ranges and `is_sdh` subtitles); one-way projection
for library-index entries (`show_index_entry_from`,
`movie_index_entry_from`) that flattens multi-episode files into
per-TMDB-slot maps in `seasons[*].episodes`.
- **Repositories** —
`DotAlfredSeriesReleaseRepository` /
`DotAlfredMovieReleaseRepository` walk `library_root/*/` with
log+skip on corruption; **`DotAlfredTVShowLibraryIndex`** /
**`DotAlfredMovieLibraryIndex`** auto-heal silently on missing or
corrupt index files by rebuilding from the per-item sidecars
(healed entries keep TMDB-cached fields as placeholders until the
next sync repopulates them). Writes are atomic and never auto-heal
(read paths handle that).
- **TMDB client extensions** — `TmdbSeasonInfo` / `TmdbShowInfo`
DTOs + `TMDBClient.get_tv_show_info(tmdb_id)` aggregating
`/tv/{id}` + `/tv/{id}/external_ids`. The parsing logic is a pure
function (`parse_tv_show_info`) testable without HTTP, with an
injectable reference date for deterministic `aired` flag tests.
- **`is_sdh` flag on `SubtitleTrack`.** Added to
`alfred/domain/shared/media.py::SubtitleTrack` to mirror ffprobe's
`hearing_impaired` disposition. Wired through the ffprobe layer
(`ffprobe_prober.py`) and the v2 sidecar bridge so SDH information
round-trips end-to-end. Defaults to `False` — backwards-compatible
for every existing caller.
- **37 v2 integration tests** on `tmp_path` covering round-trips
(domain ↔ sidecar ↔ YAML ↔ domain), atomic writes (no `.tmp`
leftovers), per-item log+skip on corruption / schema mismatch,
movie anchor-mismatch warning, full upsert / find / delete on both
library indexes, and the auto-heal path on missing / corrupt /
schema-mismatched index files. **16 TMDB DTO tests** for the new
`parse_tv_show_info` pure function.
- **`.alfred` v2 — Phase 1: new `releases/` domain.** First step of
`specs/dot_alfred_v2.md` on branch `refactor/dot-alfred-v2`. The
new `alfred/domain/releases/` package introduces a filesystem-only
bounded context separated from TMDB identity (the existing
`tv_shows` / `movies` domains). It hosts:
- **`EpisodeRange` VO** — covers single-episode files
(`EpisodeRange(E02, E02)`) and multi-episode files
(`EpisodeRange(E02, E04)` for `SxxE02E03E04.mkv`), with
`count()` / `numbers()` / `is_single()` helpers.
- **`ReleaseMode` enum** — `PACK` (N video files directly in the
season folder) vs `EPISODIC` (N sub-folders, one episode each);
classified by the walker, never re-derived.
- **Aggregates** — `TrackProfile`, `EpisodeRelease`,
`SeasonRelease` (with `episode_count()` summing each file's
range), `SeriesRelease`, `MovieRelease`. All frozen
dataclasses; mutation via `SeasonReleaseBuilder` /
`SeriesReleaseBuilder` (mirror the v1 `TVShowBuilder` pattern,
including `from_existing()` round-trip).
- **Abstract ports** — `SeriesReleaseRepository`,
`MovieReleaseRepository` (concrete `DotAlfred*` arrive in
Phase 2).
- **`TmdbId` VO** added to `alfred/domain/shared/value_objects.py`
(positive int, rejects bool/str/float — symmetry with `ImdbId`).
- 73 unit tests covering VO validation, entity invariants, builder
sort + overlap detection, and `from_existing()` round-trips. v1
code paths untouched at this stage; new domain coexists.
- **`rescan_show` orchestrator
(`alfred/application/library/rescan.py`).** Step 4 of the
`specs/dot_alfred.md` plan. Walks an Alfred-managed show folder,
runs the existing `inspect_release` pipeline on every video file it
finds, and assembles a frozen `TVShow` aggregate persisted via the
injected `TVShowRepository`. Reuses the release parser + ffprobe
path verbatim — no duplicated parse/probe logic at the library
layer. PACK vs EPISODIC inferred per season folder from the
on-disk file count + parser output: a single video whose name
carries no `Exx` token becomes a PACK season (tracks lifted to the
season-level `audio_tracks` / `subtitle_tracks`), anything else
becomes EPISODIC (one `Episode` per file). Episode paths are
stored relative to the show root for portability. Files that fail
to parse a season/episode number, or seasons with mixed numbers,
are logged and skipped — the orchestrator never raises. Embedded
subtitle tracks are captured from `ffprobe`; adjacent `.srt`
files, multi-episode entries (`S01E01E02`), and TMDB-driven PACK
detection are tracked as tech debt for a dedicated subtitles /
ShowTracker session. 7 integration tests on `tmp_path` with the
Foundation layout (S01 EPISODIC + S02 PACK) cover the round-trip
through the real `.alfred` repository.
- **Show tree walker (`alfred/application/library/walker.py`).**
Step 4a foundation. `walk_show(show_root, scanner, kb)` returns a
`ShowTree(show_root, season_folders=tuple[SeasonFolder, ...])`
pure structural snapshot, no parsing, no probing. Season folders
are detected by a `\bS\d{1,2}\b` token anywhere in the directory
name (release-style naming, no Plex `Season 01` / `Specials`
conventions). Video files are filtered against
`kb.video_extensions`; no recursion into sub-sub-folders. 11 unit
tests on `tmp_path` cover detection (case-insensitive, in-word
rejection), filtering (subs, NFO, sample files), and edge cases
(empty / missing show root).
- **Season-level audio/subtitle tracks
(`alfred/domain/tv_shows/entities.py`,
`alfred/domain/tv_shows/builders.py`).** `Season` now inherits
from `MediaWithTracks` and carries `audio_tracks` /
`subtitle_tracks` tuples (empty by default). Populated only in
PACK mode (the single release covering the whole season); empty in
EPISODIC mode where tracks live per-episode. `SeasonBuilder`
gains `set_audio_tracks()` / `set_subtitle_tracks()` and forwards
them through `from_existing()`. The bridge writes / reads them in
the PACK branch via shared `_synth_audio_tracks` /
`_synth_subtitle_tracks` helpers used for episodes too.
- **`DotAlfredTVShowRepository` — filesystem-backed implementation of
the `TVShowRepository` port
(`alfred/infrastructure/persistence/dot_alfred/repository.py`).**
Step 3 of the `specs/dot_alfred.md` plan. Reads and writes one
`.alfred` YAML file per show under a configurable `library_root`.
`save(show)` writes atomically (`.alfred.tmp` + `os.replace`) into a
folder that **must already exist** — the repository never invents a
folder name (the upstream `MediaOrganizer` is in charge of placing
files; the repo writes the sidecar next to them). `find_by_imdb_id` /
`find_all` walk `library_root/*/`, loading each readable sidecar;
folders without a sidecar return `None` / are skipped (no implicit
cold scan — that is the job of the upcoming `rescan_show` tool).
Corrupted YAML and schema violations are logged and skipped, never
raised, so a single bad folder does not break the rest of the
library. The repo keeps a tiny in-memory `imdb_id → folder_name`
index populated on every successful read/save, so subsequent saves
find the right destination without re-walking — useful when the show
folder name diverges from `show.get_folder_name()` (custom 1080p / 4K
variants). 20 integration tests on `tmp_path` cover the round-trip,
cold folder / unknown id returns, multi-show `find_all`, corrupted /
wrong-schema skipping, atomic write (no `.alfred.tmp` left behind),
overwrite, and folder-name fallbacks.
- **Sidecar ↔ TVShow bridge
(`alfred/infrastructure/persistence/dot_alfred/bridge.py`).**
`to_sidecar(show, folder_paths=...)` summarizes the rich domain
`AudioTrack` / `SubtitleTrack` to the sidecar's compact form (unique
audio languages in track order; subtitle entries derived from
`is_forced` and assumed `source="embedded"`). `from_sidecar(sidecar,
title=...)` reconstructs the domain `TVShow` with synthesized tracks
— one `AudioTrack` per language, one `SubtitleTrack` per entry, with
ffprobe-only fields (`codec`, `channels`, `channel_layout`) left as
`None`. The bridge is intentionally lossy on probe minutiae the
sidecar does not store; this is the documented trade-off from the
factual-only spec.
- **`.alfred` sidecar serializer
(`alfred/infrastructure/persistence/dot_alfred/`).** Implements step 2
of the `specs/dot_alfred.md` plan. Pure-dict in/out
(`serialize(sidecar) -> dict`, `deserialize(data) -> ShowSidecar`) —
YAML I/O lives in the repository layer (step 3) and is kept out for
trivial testability. Ships the DTOs that mirror the YAML schema
field-for-field (`ShowSidecar`, `SeasonSidecar`, `EpisodeSidecar`,
`SubtitleEntry`). The sidecar acts as a **scan cache**: it stores
only what is genuinely costly to recompute — folder/file paths
(skipping the FS walk) and probed track metadata (skipping ffprobe).
Release identifiers (group, source, quality, codec) live in folder
and file names and are derived on demand by the parser — they are
deliberately absent from the schema and rejected on deserialize. The
serializer is **strict on schema**: unknown keys at any level raise
`SidecarSchemaError`, missing required fields raise clearly, and
`bool` cannot sneak in as a season/episode number. Optional fields
(`tmdb_id`, empty `audio`/`subtitles`/`episodes`) are omitted from
the output rather than emitted as `null` / `[]`. Tests cover
round-trip equivalence (DTO → dict → DTO and DTO → YAML text → DTO),
the Foundation S01 PACK case (real-world fixture with mixed sub
types — superset captured at season scope), and a Breaking Bad S05
EPISODIC case. An on-disk `tmp_path` fixture recreates the Foundation
folder structure with placeholder files, ready to be reused by the
upcoming repository walk tests in step 3.
- **`TVShowBuilder` / `SeasonBuilder` — sole construction surface for the
TVShow aggregate** (`alfred/domain/tv_shows/builders.py`). The aggregate
is now fully frozen; building goes through a mutable scratchpad that
emits an immutable `TVShow` via `build()`. Both builders offer a
`from_existing()` classmethod to seed from a current frozen aggregate
and apply modifications. Episodes are emitted sorted by number within a
season, seasons sorted by number within the show.
- **`SeasonMode` enum** (`PACK` / `EPISODIC`) in
`alfred/domain/tv_shows/value_objects.py`. Computed at read time from
the season's structural shape (`Season.mode` property): a season with
no explicit episodes is `PACK` (a single release covering the whole
season), a season with episodes is `EPISODIC` (currently airing, one
release per episode). Never stored — the YAML sidecar encodes the
mode via the presence/absence of the `episodes:` block.
### Changed
- **TVShow aggregate is now frozen all the way down.** `TVShow`,
`Season` and `Episode` are all `@dataclass(frozen=True)`. Children
are stored as ordered tuples (`tuple[Season, ...]`,
`tuple[Episode, ...]`) sorted by their respective numbers, replacing
the previous mutable dicts. Lookup helpers `TVShow.get_season(n)` and
`Season.get_episode(n)` traverse the tuple lazily via `next()`. The
former `add_episode` / `add_season` mutation methods are gone — all
construction goes through `TVShowBuilder` / `SeasonBuilder`.
### Removed
- **ShowTracker-territory fields stripped from the TVShow aggregate.**
The aggregate now models only what the `.alfred` sidecar stores
(filesystem-observable facts + immutable identity). Dropped from the
domain:
- `TVShow.status` (`ShowStatus`) and the `ShowStatus` enum entirely,
along with its TMDB string mapping (`from_string`).
- `TVShow.expected_seasons`, `Season.expected_episodes`,
`Season.aired_episodes`, `Season.name`.
- `TVShow.collection_status()`, `is_complete_series()`,
`missing_episodes()`, `is_ongoing()`, `is_ended()` and the
`CollectionStatus` enum.
- `Season.is_complete()`, `is_fully_aired()`, `missing_episodes()`
and the `aired ≤ expected` validation.
- `TVShow.add_episode()` / `TVShow.add_season()` /
`Season.add_episode()` — replaced by the builder API.
These concerns will reappear in a dedicated `ShowTracker` layer (to
be designed) that combines the `.alfred` sidecar with live TMDB data
to answer questions like "is this show complete?" or "are new
episodes out?". Keeping volatile/derived state out of the aggregate
matches the factuel-only philosophy locked in `specs/dot_alfred.md`.
### Internal
- **Test suite rewritten for the new aggregate shape.**
`tests/domain/test_tv_shows.py` now covers frozen invariants, builder
ordering, last-write-wins on duplicates, `from_existing` round-trip,
and `SeasonMode` derivation. `tests/infrastructure/test_filesystem_extras.py`
helper simplified (no more `ShowStatus.ENDED` / `expected_seasons` on
test shows). 1078 tests still green.
- **Design doc for `.alfred/` sidecar persistence
(`specs/dot_alfred.md`).** First entry in the new `specs/` directory.
Specifies a per-show `.alfred/` directory holding a `show.yaml` and
one `season_NN.yaml` per season, used by the upcoming concrete
`TVShowRepository` to cache parse/probe results and avoid full
rescans on every library read. Covers schema, naming conventions,
cache invalidation strategy (size + mtime), self-healing on
drift, atomicity (`os.replace`), edge cases (legacy folders,
corrupted sidecars, manual file removal), and a phased
implementation plan. No code yet — spec only.
### Internal
- **`specs/` is now tracked.** The repo-level `.gitignore` had a
blanket `*.md` rule with only `CHANGELOG.md` allow-listed. Added
explicit exceptions for `/README.md` (root only — avoids
unintentionally exposing fixture READMEs) and `specs/**/*.md` so the
new design-doc directory ships with the project. Also added an
explicit `/.claude/` ignore line for the private dev-docs sub-repo
that sits inside the working tree but is versioned separately.
### Fixed
- **Multi-episode chain (e.g. `S14E09E10E11`) now collapses to a full
+3 -3
View File
@@ -6,13 +6,13 @@ from collections.abc import AsyncGenerator
from pathlib import Path
from typing import Any
from alfred.infrastructure.metadata_TO_CHECK import MetadataStore
from alfred.infrastructure.persistence_TO_CHECK import get_memory
from alfred.infrastructure.metadata import MetadataStore
from alfred.infrastructure.persistence import get_memory
from alfred.settings import settings
from .prompt import PromptBuilder
from .registry import Tool, make_tools
from .workflows_TO_CHECK import WorkflowLoader
from .workflows import WorkflowLoader
logger = logging.getLogger(__name__)
+3 -3
View File
@@ -3,12 +3,12 @@
import json
from typing import Any
from alfred.infrastructure.persistence_TO_CHECK import get_memory
from alfred.infrastructure.persistence_TO_CHECK.memory import MemoryRegistry
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_TO_CHECK import WorkflowLoader
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
+6 -6
View File
@@ -6,8 +6,8 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from .tools_TO_CHECK.spec import ToolSpec, ToolSpecError
from .tools_TO_CHECK.spec_loader import load_tool_specs
from .tools.spec import ToolSpec, ToolSpecError
from .tools.spec_loader import load_tool_specs
logger = logging.getLogger(__name__)
@@ -130,10 +130,10 @@ def make_tools(settings) -> dict[str, Tool]:
Returns:
Dictionary mapping tool names to Tool objects.
"""
from .tools_TO_CHECK import api as api_tools # noqa: PLC0415
from .tools_TO_CHECK import filesystem as fs_tools # noqa: PLC0415
from .tools_TO_CHECK import language as lang_tools # noqa: PLC0415
from .tools_TO_CHECK import workflow as wf_tools # noqa: PLC0415
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
tool_functions = [
fs_tools.set_path_for_folder,
+22
View File
@@ -0,0 +1,22 @@
"""Tools module - filesystem and API tools for the agent."""
from .api import (
add_torrent_by_index,
add_torrent_to_qbittorrent,
find_media_imdb_id,
find_torrent,
get_torrent_by_index,
)
from .filesystem import list_folder, set_path_for_folder
from .language import set_language
__all__ = [
"set_path_for_folder",
"list_folder",
"find_media_imdb_id",
"find_torrent",
"get_torrent_by_index",
"add_torrent_to_qbittorrent",
"add_torrent_by_index",
"set_language",
]
@@ -3,47 +3,35 @@
import logging
from typing import Any
from alfred.application.movies_TO_CHECK import SearchMovieUseCase
from alfred.application.torrents_TO_CHECK import AddTorrentUseCase, SearchTorrentsUseCase
from alfred.application.tv_shows_TO_CHECK import SearchShowUseCase
from alfred.infrastructure.api_TO_CHECK.knaben import knaben_client
from alfred.infrastructure.api_TO_CHECK.qbittorrent import qbittorrent_client
from alfred.infrastructure.api_TO_CHECK.tmdb import tmdb_client
from alfred.infrastructure.persistence_TO_CHECK import get_memory
from alfred.application.movies import SearchMovieUseCase
from alfred.application.torrents import AddTorrentUseCase, SearchTorrentsUseCase
from alfred.infrastructure.api.knaben import knaben_client
from alfred.infrastructure.api.qbittorrent import qbittorrent_client
from alfred.infrastructure.api.tmdb import tmdb_client
from alfred.infrastructure.persistence import get_memory
logger = logging.getLogger(__name__)
def search_movies(media_title: str) -> dict[str, Any]:
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/search_movies.yaml."""
def find_media_imdb_id(media_title: str) -> dict[str, Any]:
"""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()
if result.get("status") == "ok":
memory = get_memory()
memory.stm.set_entity("last_movie_search", {"hits": result.get("hits", [])})
memory.stm.set_topic("searching_movie")
logger.debug(
f"Stored movie search result in STM: {len(result.get('hits', []))} hits"
)
return result
def search_shows(show_title: str) -> dict[str, Any]:
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/search_shows.yaml."""
use_case = SearchShowUseCase(tmdb_client)
response = use_case.execute(show_title)
result = response.to_dict()
if result.get("status") == "ok":
memory = get_memory()
memory.stm.set_entity("last_show_search", {"hits": result.get("hits", [])})
memory.stm.set_topic("searching_show")
logger.debug(
f"Stored show search result in STM: {len(result.get('hits', []))} hits"
memory.stm.set_entity(
"last_media_search",
{
"title": result.get("title"),
"imdb_id": result.get("imdb_id"),
"media_type": result.get("media_type"),
"tmdb_id": result.get("tmdb_id"),
},
)
memory.stm.set_topic("searching_media")
logger.debug(f"Stored media search result in STM: {result.get('title')}")
return result
@@ -1,20 +1,4 @@
"""Filesystem tools for folder management.
Thin wrappers around the 5 atomic filesystem use cases
(``alfred.application.filesystem``) plus a few self-contained tools
(``analyze_release``, ``probe_media``, ``learn``, ).
Tools removed during the ``unfuck`` filesystem refactor to be
rewired in a later step:
- ``manage_subtitles`` (depends on the rewritten subtitle services)
- ``set_path_for_folder`` (no replacement use case yet)
- ``create_seed_links`` (flow has changed: hard-link straight to
library, no copy back; will be re-introduced per-file when the
organize-release workflow lands)
- ``resolve_season_destination`` / ``resolve_episode_destination``
/ ``resolve_movie_destination`` / ``resolve_series_destination``
(their use cases moved to ``_OLD`` files pending a rewrite)
"""
"""Filesystem tools for folder management."""
from pathlib import Path
from typing import Any
@@ -23,15 +7,29 @@ import yaml
import alfred as _alfred_pkg
from alfred.application.filesystem import (
DirectoryRoots,
create_dir_use_case,
list_dir_use_case,
move_file_use_case,
CreateSeedLinksUseCase,
ListFolderUseCase,
ManageSubtitlesUseCase,
MoveMediaUseCase,
SetFolderPathUseCase,
)
from alfred.infrastructure.knowledge_TO_CHECK.release_kb import YamlReleaseKnowledge
from alfred.infrastructure.metadata_TO_CHECK import MetadataStore
from alfred.infrastructure.persistence_TO_CHECK import get_memory
from alfred.infrastructure.probe_TO_CHECK import FfprobeMediaProber
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.knowledge.release_kb import YamlReleaseKnowledge
from alfred.infrastructure.metadata import MetadataStore
from alfred.infrastructure.persistence import get_memory
from alfred.infrastructure.probe import FfprobeMediaProber
# Agent-tools frontier: this is the legitimate home for the singletons that
# back every LLM-exposed wrapper. The use cases below take ``kb`` / ``prober``
@@ -42,117 +40,107 @@ _PROBER = FfprobeMediaProber()
_LEARNED_ROOT = Path(_alfred_pkg.__file__).parent.parent / "data" / "knowledge"
class _RootsNotConfigured(Exception):
"""Raised when one of the 4 expected roots is missing from memory."""
def __init__(self, missing: list[str]):
super().__init__(f"Roots not configured: {missing}")
self.missing = missing
def _load_directory_roots() -> DirectoryRoots:
"""Build :class:`DirectoryRoots` from the persisted memory.
Reads:
- ``ltm.workspace.download`` ``downloads``
- ``ltm.workspace.torrent`` ``torrents``
- ``ltm.library_paths['movies']`` ``movies``
- ``ltm.library_paths['tv_shows']`` ``tv_shows``
Raises:
_RootsNotConfigured: if any of the four paths is unset.
"""
memory = get_memory()
downloads = memory.ltm.workspace.download
torrents = memory.ltm.workspace.torrent
movies = memory.ltm.library_paths.get("movies")
tv_shows = memory.ltm.library_paths.get("tv_shows")
missing: list[str] = []
if not downloads:
missing.append("downloads")
if not torrents:
missing.append("torrents")
if not movies:
missing.append("movies")
if not tv_shows:
missing.append("tv_shows")
if missing:
raise _RootsNotConfigured(missing)
return DirectoryRoots(
downloads=Path(downloads),
torrents=Path(torrents),
movies=Path(movies),
tv_shows=Path(tv_shows),
)
def _roots_error(exc: _RootsNotConfigured) -> dict[str, Any]:
return {
"status": "error",
"error": "roots_not_configured",
"message": (
f"Missing roots: {exc.missing}. "
"Configure them via /set_path before using filesystem tools."
),
}
# ---------------------------------------------------------------------------
# 5 atomic filesystem tools — thin wrappers over the use cases.
# ---------------------------------------------------------------------------
def list_folder(path: str) -> dict[str, Any]:
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/list_folder.yaml."""
try:
roots = _load_directory_roots()
except _RootsNotConfigured as e:
return _roots_error(e)
return list_dir_use_case(Path(path), roots).to_dict()
def create_directory(path: str) -> dict[str, Any]:
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/create_directory.yaml."""
try:
roots = _load_directory_roots()
except _RootsNotConfigured as e:
return _roots_error(e)
return create_dir_use_case(Path(path), roots).to_dict()
def move_media(source: str, destination: str) -> dict[str, Any]:
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/move_media.yaml."""
try:
roots = _load_directory_roots()
except _RootsNotConfigured as e:
return _roots_error(e)
return move_file_use_case(Path(source), Path(destination), roots).to_dict()
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.
Convenience tool that creates the destination's parent directory
if missing, then moves the file. Saves the LLM from having to
chain ``create_directory`` + ``move_media`` explicitly.
"""
try:
roots = _load_directory_roots()
except _RootsNotConfigured as e:
return _roots_error(e)
dst = Path(destination)
mkdir_resp = create_dir_use_case(dst.parent, roots)
if mkdir_resp.status != "ok":
return mkdir_resp.to_dict()
return move_file_use_case(Path(source), dst, roots).to_dict()
"""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)
# ---------------------------------------------------------------------------
# Self-contained tools — not impacted by the filesystem refactor.
# ---------------------------------------------------------------------------
def resolve_season_destination(
release_name: str,
tmdb_title: str,
tmdb_year: int,
confirmed_folder: str | None = None,
source_path: 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,
_KB,
_PROBER,
confirmed_folder,
source_path,
).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,
_KB,
_PROBER,
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, _KB, _PROBER
).to_dict()
def resolve_series_destination(
release_name: str,
tmdb_title: str,
tmdb_year: int,
confirmed_folder: str | None = None,
source_path: 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,
_KB,
_PROBER,
confirmed_folder,
source_path,
).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]:
@@ -212,9 +200,17 @@ def learn(pack: str, category: str, key: str, values: list[str]) -> dict[str, An
}
def set_path_for_folder(folder_name: str, path_value: str) -> dict[str, Any]:
"""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.release_TO_CHECK import inspect_release # noqa: PLC0415
from alfred.application.release import inspect_release # noqa: PLC0415
result = inspect_release(release_name, Path(source_path), _KB, _PROBER)
parsed = result.parsed
@@ -300,6 +296,14 @@ def probe_media(source_path: str) -> dict[str, Any]:
}
def list_folder(folder_type: str, path: str = ".") -> dict[str, Any]:
"""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)
@@ -3,7 +3,7 @@
import logging
from typing import Any
from alfred.infrastructure.persistence_TO_CHECK import get_memory
from alfred.infrastructure.persistence import get_memory
logger = logging.getLogger(__name__)
@@ -9,9 +9,9 @@ to reason over the full set.
import logging
from typing import Any
from alfred.infrastructure.persistence_TO_CHECK import get_memory
from alfred.infrastructure.persistence import get_memory
from ..workflows_TO_CHECK import WorkflowLoader
from ..workflows import WorkflowLoader
logger = logging.getLogger(__name__)
-23
View File
@@ -1,23 +0,0 @@
"""Tools module — agent-exposed wrappers.
Re-exports are intentionally minimal during the ``unfuck`` refactor.
Tool wiring (registry / specs / LLM-facing surface) is the last
chunk of work on this branch; until then, importers should reach
into the submodules directly (``alfred.agent.tools.filesystem``, …).
"""
from .api import (
add_torrent_by_index,
add_torrent_to_qbittorrent,
find_torrent,
get_torrent_by_index,
)
from .language import set_language
__all__ = [
"find_torrent",
"get_torrent_by_index",
"add_torrent_to_qbittorrent",
"add_torrent_by_index",
"set_language",
]
+1 -1
View File
@@ -15,7 +15,7 @@ from alfred.agent.agent import Agent
from alfred.agent.llm.deepseek import DeepSeekClient
from alfred.agent.llm.exceptions import LLMAPIError, LLMConfigurationError
from alfred.agent.llm.ollama import OllamaClient
from alfred.infrastructure.persistence_TO_CHECK import get_memory, init_memory
from alfred.infrastructure.persistence import get_memory, init_memory
from alfred.settings import settings
logging.basicConfig(
-26
View File
@@ -1,26 +0,0 @@
"""Application-layer exceptions shared across orchestrators.
Kept in a dedicated module (rather than inside each orchestrator's
file) because the sync flows for TV shows and movies raise structurally
identical "not found in library" errors — pulling them out makes the
shared semantics explicit and avoids cross-imports between the
``tv_shows`` and ``movies`` packages.
"""
from __future__ import annotations
class ShowNotFoundInLibrary(LookupError):
"""Raised when no on-disk TV show carries the requested ``tmdb_id``.
The sync orchestrator raises this when both the library index and
the per-show release repository return ``None`` for a lookup —
there is nothing on disk to refresh TMDB facts against.
"""
class MovieNotFoundInLibrary(LookupError):
"""Raised when no on-disk movie carries the requested ``tmdb_id``.
Symmetric to :class:`ShowNotFoundInLibrary` for the movies library.
"""
+41 -36
View File
@@ -1,42 +1,47 @@
"""Filesystem application layer — 5 atomic use cases as free functions.
"""Filesystem use cases."""
Each use case:
- accepts :class:`pathlib.Path` inputs plus a :class:`DirectoryRoots` VO,
- guards inputs against escaping configured roots,
- calls the matching infra op,
- catches :class:`~alfred.infrastructure.filesystem.FilesystemError` and
returns a frozen DTO with a normalized error code.
No global state, no ``get_memory()``. Roots are injected.
"""
from .create_dir import create_dir_use_case
from .directory_roots import DirectoryRoots
from .create_seed_links import CreateSeedLinksUseCase
from .dto import (
CreateDirResponse,
LinkFileResponse,
ListDirResponse,
MoveDirResponse,
MoveFileResponse,
CreateSeedLinksResponse,
ListFolderResponse,
ManageSubtitlesResponse,
MoveMediaResponse,
PlacedSubtitle,
SetFolderPathResponse,
)
from .link_file import link_file_use_case
from .list_dir import list_dir_use_case
from .move_dir import move_dir_use_case
from .move_file import move_file_use_case
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__ = [
# use cases
"list_dir_use_case",
"create_dir_use_case",
"link_file_use_case",
"move_file_use_case",
"move_dir_use_case",
# VO
"DirectoryRoots",
# DTOs
"ListDirResponse",
"CreateDirResponse",
"LinkFileResponse",
"MoveFileResponse",
"MoveDirResponse",
"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",
]
-41
View File
@@ -1,41 +0,0 @@
"""Internal helpers: mapping infra exceptions → error codes.
Kept private (``_errors``) — only the 5 use cases in this package use
it. Centralizes the exception → code translation so every use case
returns consistent error payloads.
"""
from __future__ import annotations
from alfred.infrastructure.filesystem import (
CrossDevice,
DestinationExists,
FilesystemError,
FilesystemOSError,
NotADirectory,
NotAFile,
PermissionDenied,
SourceNotFound,
)
# Application-layer error codes (guard violations, not infra).
PATH_NOT_ALLOWED = "path_not_allowed"
def code_for(exc: FilesystemError) -> str:
"""Return the snake-case error code for an infra exception."""
if isinstance(exc, SourceNotFound):
return "source_not_found"
if isinstance(exc, DestinationExists):
return "destination_exists"
if isinstance(exc, NotADirectory):
return "not_a_directory"
if isinstance(exc, NotAFile):
return "not_a_file"
if isinstance(exc, PermissionDenied):
return "permission_denied"
if isinstance(exc, CrossDevice):
return "cross_device"
if isinstance(exc, FilesystemOSError):
return "filesystem_os_error"
return "filesystem_error"
@@ -1,33 +0,0 @@
"""create_dir use case — create a directory under one of the configured roots."""
from __future__ import annotations
from pathlib import Path
from alfred.infrastructure.filesystem import FilesystemError, create_dir
from ._errors import PATH_NOT_ALLOWED, code_for
from .directory_roots import DirectoryRoots
from .dto import CreateDirResponse
def create_dir_use_case(path: Path, roots: DirectoryRoots) -> CreateDirResponse:
"""Create directory ``path`` (and any missing parents) provided it
lives under one of the configured roots.
Idempotent on the infra side: re-running on an existing directory
returns ``status="ok"``.
"""
if not roots.contains(path):
return CreateDirResponse(
status="error",
error=PATH_NOT_ALLOWED,
message=f"Path is outside configured roots: {path}",
)
try:
create_dir(path)
except FilesystemError as e:
return CreateDirResponse(status="error", error=code_for(e), message=str(e))
return CreateDirResponse(status="ok", path=path)
@@ -3,7 +3,7 @@
import logging
from alfred.infrastructure.filesystem import FileManager
from alfred.infrastructure.persistence_TO_CHECK import get_memory
from alfred.infrastructure.persistence import get_memory
from .dto import CreateSeedLinksResponse
@@ -1,56 +0,0 @@
"""DirectoryRoots — VO carrying the configured filesystem roots.
Replaces the ad-hoc ``get_memory().ltm.workspace.<x>`` lookups that were
sprinkled across the filesystem use cases. By making roots an explicit
input, use cases become pure (no global state read) and easy to test.
The roots are read once at the tool wrapper boundary (where the agent
config lives) and threaded through the use cases.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class DirectoryRoots:
"""Configured roots of Alfred's filesystem.
All paths must be absolute and existing directories — validation is
expected at the boundary that builds this VO.
Attributes:
downloads: where qBittorrent drops finished torrents.
torrents: where seeding hard-links live (mirrors downloads/).
movies: library root for movies.
tv_shows: library root for TV shows.
"""
downloads: Path
torrents: Path
movies: Path
tv_shows: Path
def all(self) -> tuple[Path, ...]:
"""Return every configured root, in declaration order."""
return (self.downloads, self.torrents, self.movies, self.tv_shows)
def contains(self, path: Path) -> bool:
"""Return True if ``path`` is inside one of the configured roots.
Uses ``Path.resolve()`` to handle symlinks and ``..`` segments,
then ``relative_to`` for an exact within-root check.
"""
try:
resolved = path.resolve()
except OSError:
return False
for root in self.all():
try:
resolved.relative_to(root.resolve())
return True
except (ValueError, OSError):
continue
return False
+164 -62
View File
@@ -1,28 +1,19 @@
"""DTOs for the 5 atomic filesystem use cases.
Each use case returns a small frozen dataclass tagged with a ``status``
field. On error, ``error`` (machine-readable code) and ``message``
(human-readable) are populated; on success, the relevant payload
fields are.
Error codes mirror the infrastructure exception types (lowercased,
snake-cased) — e.g. ``SourceNotFound`` → ``"source_not_found"`` — plus
the application-layer ``"path_not_allowed"`` for guard violations.
"""
"""Filesystem application DTOs."""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from dataclasses import dataclass
@dataclass(frozen=True)
class ListDirResponse:
"""Response from ``list_dir_use_case``."""
@dataclass
class CopyMediaResponse:
"""Response from copying a media file."""
status: str # "ok" | "error"
path: Path | None = None
entries: tuple[Path, ...] = ()
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
@@ -31,33 +22,22 @@ class ListDirResponse:
return {"status": self.status, "error": self.error, "message": self.message}
return {
"status": self.status,
"path": str(self.path) if self.path else None,
"entries": [str(p) for p in self.entries],
"source": self.source,
"destination": self.destination,
"filename": self.filename,
"size": self.size,
}
@dataclass(frozen=True)
class CreateDirResponse:
"""Response from ``create_dir_use_case``."""
@dataclass
class MoveMediaResponse:
"""Response from moving a media file."""
status: str
path: Path | 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, "path": str(self.path) if self.path else None}
@dataclass(frozen=True)
class LinkFileResponse:
"""Response from ``link_file_use_case``."""
status: str
source: Path | None = None
destination: Path | None = None
source: str | None = None
destination: str | None = None
filename: str | None = None
size: int | None = None
error: str | None = None
message: str | None = None
@@ -66,18 +46,125 @@ class LinkFileResponse:
return {"status": self.status, "error": self.error, "message": self.message}
return {
"status": self.status,
"source": str(self.source) if self.source else None,
"destination": str(self.destination) if self.destination else None,
"source": self.source,
"destination": self.destination,
"filename": self.filename,
"size": self.size,
}
@dataclass(frozen=True)
class MoveFileResponse:
"""Response from ``move_file_use_case``."""
@dataclass
class SetFolderPathResponse:
"""Response from setting a folder path."""
status: str
source: Path | None = None
destination: Path | None = None
folder_name: str | None = None
path: str | None = None
error: str | None = None
message: str | None = None
def to_dict(self):
"""Convert to dict for agent compatibility."""
result = {"status": self.status}
if self.error:
result["error"] = self.error
result["message"] = self.message
else:
if self.folder_name:
result["folder_name"] = self.folder_name
if self.path:
result["path"] = self.path
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
@@ -86,26 +173,41 @@ class MoveFileResponse:
return {"status": self.status, "error": self.error, "message": self.message}
return {
"status": self.status,
"source": str(self.source) if self.source else None,
"destination": str(self.destination) if self.destination else None,
"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(frozen=True)
class MoveDirResponse:
"""Response from ``move_dir_use_case``."""
@dataclass
class ListFolderResponse:
"""Response from listing a folder."""
status: str
source: Path | None = None
destination: Path | None = None
folder_type: str | None = None
path: str | None = None
entries: list[str] | None = None
count: int | None = None
error: str | None = None
message: str | None = None
def to_dict(self) -> dict:
def to_dict(self):
"""Convert to dict for agent compatibility."""
result = {"status": self.status}
if self.error:
return {"status": self.status, "error": self.error, "message": self.message}
return {
"status": self.status,
"source": str(self.source) if self.source else None,
"destination": str(self.destination) if self.destination else None,
}
result["error"] = self.error
result["message"] = self.message
else:
if self.folder_type:
result["folder_type"] = self.folder_type
if self.path:
result["path"] = self.path
if self.entries is not None:
result["entries"] = self.entries
if self.count is not None:
result["count"] = self.count
return result
-188
View File
@@ -1,188 +0,0 @@
"""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 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."""
status: str
folder_type: str | None = None # SHOULD BE A PROPERTY
path: str | None = None # NOT NONE - Should be path
entries: list[str] | None = None # NOT NONE - Empty list of path
count: int | None = None # USELESS
error: str | None = None
message: str | None = None
def to_dict(self):
"""Convert to dict for agent compatibility."""
result = {"status": self.status}
if self.error:
result["error"] = self.error
result["message"] = self.message
else:
if self.folder_type:
result["folder_type"] = self.folder_type
if self.path:
result["path"] = self.path
if self.entries is not None:
result["entries"] = self.entries
if self.count is not None:
result["count"] = self.count
return result
@@ -1,40 +0,0 @@
"""link_file use case — hard-link a file from one root to another."""
from __future__ import annotations
from pathlib import Path
from alfred.infrastructure.filesystem import FilesystemError, link_file
from ._errors import PATH_NOT_ALLOWED, code_for
from .directory_roots import DirectoryRoots
from .dto import LinkFileResponse
def link_file_use_case(
src: Path, dst: Path, roots: DirectoryRoots
) -> LinkFileResponse:
"""Hard-link ``src`` to ``dst``. Both must be under configured roots.
The destination parent must already exist — the caller is expected
to have created it via ``create_dir_use_case`` if needed.
"""
if not roots.contains(src):
return LinkFileResponse(
status="error",
error=PATH_NOT_ALLOWED,
message=f"Source is outside configured roots: {src}",
)
if not roots.contains(dst):
return LinkFileResponse(
status="error",
error=PATH_NOT_ALLOWED,
message=f"Destination is outside configured roots: {dst}",
)
try:
link_file(src, dst)
except FilesystemError as e:
return LinkFileResponse(status="error", error=code_for(e), message=str(e))
return LinkFileResponse(status="ok", source=src, destination=dst)
-34
View File
@@ -1,34 +0,0 @@
"""list_dir use case — list a directory after guarding it within roots."""
from __future__ import annotations
from pathlib import Path
from alfred.infrastructure.filesystem import FilesystemError, list_dir
from ._errors import PATH_NOT_ALLOWED, code_for
from .directory_roots import DirectoryRoots
from .dto import ListDirResponse
def list_dir_use_case(path: Path, roots: DirectoryRoots) -> ListDirResponse:
"""List the immediate children of ``path`` if it lives under one of
the configured roots.
Returns a :class:`ListDirResponse`. On guard failure, status is
``"error"`` with ``error="path_not_allowed"``. On infra failure,
status is ``"error"`` with a code mapped from the raised exception.
"""
if not roots.contains(path):
return ListDirResponse(
status="error",
error=PATH_NOT_ALLOWED,
message=f"Path is outside configured roots: {path}",
)
try:
entries = list_dir(path)
except FilesystemError as e:
return ListDirResponse(status="error", error=code_for(e), message=str(e))
return ListDirResponse(status="ok", path=path, entries=tuple(entries))
@@ -3,25 +3,25 @@
import logging
from pathlib import Path
from alfred.application.subtitles_TO_CHECK.placer import (
from alfred.domain.shared.value_objects import ImdbId
from alfred.domain.subtitles.entities import SubtitleScanResult
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.shared_TO_CHECK.value_objects import ImdbId
from alfred.domain.subtitles_TO_CHECK.entities import SubtitleScanResult
from alfred.domain.subtitles_TO_CHECK.services.identifier import SubtitleIdentifier
from alfred.domain.subtitles_TO_CHECK.services.matcher import SubtitleMatcher
from alfred.domain.subtitles_TO_CHECK.services.pattern_detector import PatternDetector
from alfred.domain.subtitles_TO_CHECK.services.utils import available_subtitles
from alfred.domain.subtitles_TO_CHECK.value_objects import ScanStrategy
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_TO_CHECK.subtitles.base import SubtitleKnowledgeBase
from alfred.infrastructure.knowledge_TO_CHECK.subtitles.loader import KnowledgeLoader
from alfred.infrastructure.persistence_TO_CHECK.context import get_memory
from alfred.infrastructure.probe_TO_CHECK.ffprobe_prober import FfprobeMediaProber
from alfred.infrastructure.subtitle_TO_CHECK.metadata_store import SubtitleMetadataStore
from alfred.infrastructure.subtitle_TO_CHECK.rule_repository import RuleSetRepository
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,
-36
View File
@@ -1,36 +0,0 @@
"""move_dir use case — move a directory tree between configured roots."""
from __future__ import annotations
from pathlib import Path
from alfred.infrastructure.filesystem import FilesystemError, move_dir
from ._errors import PATH_NOT_ALLOWED, code_for
from .directory_roots import DirectoryRoots
from .dto import MoveDirResponse
def move_dir_use_case(
src: Path, dst: Path, roots: DirectoryRoots
) -> MoveDirResponse:
"""Move directory ``src`` to ``dst``. Both must be under configured roots."""
if not roots.contains(src):
return MoveDirResponse(
status="error",
error=PATH_NOT_ALLOWED,
message=f"Source is outside configured roots: {src}",
)
if not roots.contains(dst):
return MoveDirResponse(
status="error",
error=PATH_NOT_ALLOWED,
message=f"Destination is outside configured roots: {dst}",
)
try:
move_dir(src, dst)
except FilesystemError as e:
return MoveDirResponse(status="error", error=code_for(e), message=str(e))
return MoveDirResponse(status="ok", source=src, destination=dst)
@@ -1,36 +0,0 @@
"""move_file use case — move a file between configured roots."""
from __future__ import annotations
from pathlib import Path
from alfred.infrastructure.filesystem import FilesystemError, move_file
from ._errors import PATH_NOT_ALLOWED, code_for
from .directory_roots import DirectoryRoots
from .dto import MoveFileResponse
def move_file_use_case(
src: Path, dst: Path, roots: DirectoryRoots
) -> MoveFileResponse:
"""Move file ``src`` to ``dst``. Both must be under configured roots."""
if not roots.contains(src):
return MoveFileResponse(
status="error",
error=PATH_NOT_ALLOWED,
message=f"Source is outside configured roots: {src}",
)
if not roots.contains(dst):
return MoveFileResponse(
status="error",
error=PATH_NOT_ALLOWED,
message=f"Destination is outside configured roots: {dst}",
)
try:
move_file(src, dst)
except FilesystemError as e:
return MoveFileResponse(status="error", error=code_for(e), message=str(e))
return MoveFileResponse(status="ok", source=src, destination=dst)
@@ -22,12 +22,12 @@ import logging
from dataclasses import dataclass
from pathlib import Path
from alfred.application.release_TO_CHECK import inspect_release
from alfred.application.release import inspect_release
from alfred.domain.release import parse_release
from alfred.domain.releases_TO_CHECK.ports import ReleaseKnowledge
from alfred.domain.release.ports import ReleaseKnowledge
from alfred.domain.release.value_objects import ParsedRelease
from alfred.domain.shared_TO_CHECK.ports import MediaProber
from alfred.infrastructure.persistence_TO_CHECK import get_memory
from alfred.domain.shared.ports import MediaProber
from alfred.infrastructure.persistence import get_memory
logger = logging.getLogger(__name__)
@@ -0,0 +1,50 @@
"""Set folder path use case."""
import logging
from alfred.infrastructure.filesystem import FileManager
from .dto import SetFolderPathResponse
logger = logging.getLogger(__name__)
class SetFolderPathUseCase:
"""
Use case for setting a folder path in configuration.
This orchestrates the FileManager to set folder paths.
"""
def __init__(self, file_manager: FileManager):
"""
Initialize use case.
Args:
file_manager: FileManager instance
"""
self.file_manager = file_manager
def execute(self, folder_name: str, path_value: str) -> SetFolderPathResponse:
"""
Set a folder path in configuration.
Args:
folder_name: Name of folder to set (download, tvshow, movie, torrent)
path_value: Absolute path to the folder
Returns:
SetFolderPathResponse with success or error information
"""
result = self.file_manager.set_folder_path(folder_name, path_value)
if result.get("status") == "ok":
return SetFolderPathResponse(
status="ok",
folder_name=result.get("folder_name"),
path=result.get("path"),
)
else:
return SetFolderPathResponse(
status="error", error=result.get("error"), message=result.get("message")
)
@@ -1,10 +1,9 @@
"""Movie use cases."""
from .dto import MovieHit, SearchMovieResponse
from .dto import SearchMovieResponse
from .search_movie import SearchMovieUseCase
__all__ = [
"MovieHit",
"SearchMovieResponse",
"SearchMovieUseCase",
"SearchMovieResponse",
]
+44
View File
@@ -0,0 +1,44 @@
"""Movie application DTOs."""
from dataclasses import dataclass
@dataclass
class SearchMovieResponse:
"""Response from searching for a movie."""
status: str
imdb_id: str | None = None
title: str | None = None
media_type: str | None = None
tmdb_id: int | None = None
overview: str | None = None
release_date: str | None = None
vote_average: float | None = None
error: str | None = None
message: str | None = None
def to_dict(self):
"""Convert to dict for agent compatibility."""
result = {"status": self.status}
if self.error:
result["error"] = self.error
result["message"] = self.message
else:
if self.imdb_id:
result["imdb_id"] = self.imdb_id
if self.title:
result["title"] = self.title
if self.media_type:
result["media_type"] = self.media_type
if self.tmdb_id:
result["tmdb_id"] = self.tmdb_id
if self.overview:
result["overview"] = self.overview
if self.release_date:
result["release_date"] = self.release_date
if self.vote_average:
result["vote_average"] = self.vote_average
return result
+93
View File
@@ -0,0 +1,93 @@
"""Search movie use case."""
import logging
from alfred.infrastructure.api.tmdb import (
TMDBAPIError,
TMDBClient,
TMDBConfigurationError,
TMDBNotFoundError,
)
from .dto import SearchMovieResponse
logger = logging.getLogger(__name__)
class SearchMovieUseCase:
"""
Use case for searching a movie and retrieving its IMDb ID.
This orchestrates the TMDB API client to find movie information.
"""
def __init__(self, tmdb_client: TMDBClient):
"""
Initialize use case.
Args:
tmdb_client: TMDB API client
"""
self.tmdb_client = tmdb_client
def execute(self, media_title: str) -> SearchMovieResponse:
"""
Search for a movie by title.
Args:
media_title: Title of the movie to search for
Returns:
SearchMovieResponse with movie information or error
"""
try:
# Use the TMDB client to search for media
result = self.tmdb_client.search_media(media_title)
# Check if IMDb ID was found
if result.imdb_id:
logger.info(f"IMDb ID found for '{media_title}': {result.imdb_id}")
return SearchMovieResponse(
status="ok",
imdb_id=result.imdb_id,
title=result.title,
media_type=result.media_type,
tmdb_id=result.tmdb_id,
overview=result.overview,
release_date=result.release_date,
vote_average=result.vote_average,
)
else:
logger.warning(f"No IMDb ID available for '{media_title}'")
return SearchMovieResponse(
status="ok",
title=result.title,
media_type=result.media_type,
tmdb_id=result.tmdb_id,
error="no_imdb_id",
message=f"No IMDb ID available for '{result.title}'",
)
except TMDBNotFoundError as e:
logger.info(f"Media not found: {e}")
return SearchMovieResponse(
status="error", error="not_found", message=str(e)
)
except TMDBConfigurationError as e:
logger.error(f"TMDB configuration error: {e}")
return SearchMovieResponse(
status="error", error="configuration_error", message=str(e)
)
except TMDBAPIError as e:
logger.error(f"TMDB API error: {e}")
return SearchMovieResponse(
status="error", error="api_error", message=str(e)
)
except ValueError as e:
logger.error(f"Validation error: {e}")
return SearchMovieResponse(
status="error", error="validation_failed", message=str(e)
)
-40
View File
@@ -1,40 +0,0 @@
"""Movie application DTOs."""
from dataclasses import dataclass, field
@dataclass(frozen=True)
class MovieHit:
"""One movie hit, flattened for transport to the agent."""
tmdb_id: int
title: str
release_year: int | None = None
def to_dict(self) -> dict:
out: dict = {"tmdb_id": self.tmdb_id, "title": self.title}
if self.release_year is not None:
out["release_year"] = self.release_year
return out
@dataclass
class SearchMovieResponse:
"""Response from searching for a movie."""
status: str
hits: list[MovieHit] = field(default_factory=list)
error: str | None = None
message: str | None = None
def to_dict(self):
"""Convert to dict for agent compatibility."""
result: dict = {"status": self.status}
if self.error:
result["error"] = self.error
result["message"] = self.message
else:
result["hits"] = [h.to_dict() for h in self.hits]
return result
@@ -1,60 +0,0 @@
"""Search movie use case."""
import logging
from alfred.infrastructure.api_TO_CHECK.tmdb import (
TMDBAPIError,
TMDBClient,
TMDBConfigurationError,
)
from .dto import MovieHit, SearchMovieResponse
logger = logging.getLogger(__name__)
class SearchMovieUseCase:
"""List movies matching a free-text query via TMDB ``/search/movie``.
The use case is a thin orchestrator: it asks the client for hits,
flattens domain VOs into agent-friendly primitives, and wraps
errors. It deliberately does **not** look up ``imdb_id`` —
enrichment is the caller's job (via :meth:`TMDBClient.get_movie_info`
on a chosen ``tmdb_id``).
"""
def __init__(self, tmdb_client: TMDBClient):
self.tmdb_client = tmdb_client
def execute(self, media_title: str) -> SearchMovieResponse:
try:
results = self.tmdb_client.search_movies(media_title)
hits = [
MovieHit(
tmdb_id=r.tmdb_id.value,
title=str(r.title),
release_year=r.release_year.value if r.release_year else None,
)
for r in results
]
logger.info(f"search_movies({media_title!r}) → {len(hits)} hits")
return SearchMovieResponse(status="ok", hits=hits)
except TMDBConfigurationError as e:
logger.error(f"TMDB configuration error: {e}")
return SearchMovieResponse(
status="error", error="configuration_error", message=str(e)
)
except TMDBAPIError as e:
logger.error(f"TMDB API error: {e}")
return SearchMovieResponse(
status="error", error="api_error", message=str(e)
)
except ValueError as e:
logger.error(f"Validation error: {e}")
return SearchMovieResponse(
status="error", error="validation_failed", message=str(e)
)
@@ -19,7 +19,7 @@ from __future__ import annotations
from pathlib import Path
from alfred.domain.releases_TO_CHECK.ports import ReleaseKnowledge
from alfred.domain.release.ports import ReleaseKnowledge
from alfred.domain.release.value_objects import ParsedRelease
@@ -4,9 +4,9 @@ from __future__ import annotations
from dataclasses import replace
from alfred.domain.releases_TO_CHECK.ports import ReleaseKnowledge
from alfred.domain.release.ports import ReleaseKnowledge
from alfred.domain.release.value_objects import ParsedRelease
from alfred.domain.shared_TO_CHECK.media import MediaInfo
from alfred.domain.shared.media import MediaInfo
def enrich_from_probe(
@@ -48,18 +48,19 @@ from __future__ import annotations
from dataclasses import dataclass, replace
from pathlib import Path
from alfred.application.release_TO_CHECK.detect_media_type import detect_media_type
from alfred.application.release_TO_CHECK.enrich_from_probe import enrich_from_probe
from alfred.application.release_TO_CHECK.supported_media import find_main_video
from alfred.domain.releases_TO_CHECK.ports import ReleaseKnowledge
from alfred.domain.releases_TO_CHECK.parser.services import parse_release
from alfred.application.release.detect_media_type import detect_media_type
from alfred.application.release.enrich_from_probe import enrich_from_probe
from alfred.application.release.supported_media import find_main_video
from alfred.domain.release.ports import ReleaseKnowledge
from alfred.domain.release.services import parse_release
from alfred.domain.release.value_objects import (
MediaTypeToken,
ParsedRelease,
ParseReport,
)
from alfred.domain.shared_TO_CHECK.media import MediaInfo
from alfred.domain.shared_TO_CHECK.ports import MediaProber
from alfred.domain.shared.media import MediaInfo
from alfred.domain.shared.ports import MediaProber
# Media types for which a probe carries no useful information.
_NON_PROBABLE_MEDIA_TYPES = frozenset({"unknown", "other"})
@@ -32,7 +32,7 @@ from __future__ import annotations
from pathlib import Path
from alfred.domain.releases_TO_CHECK.ports.knowledge import ReleaseKnowledge
from alfred.domain.release.ports.knowledge import ReleaseKnowledge
def is_supported_video(path: Path, kb: ReleaseKnowledge) -> bool:
@@ -5,8 +5,8 @@ import os
from dataclasses import dataclass
from pathlib import Path
from alfred.domain.subtitles_TO_CHECK.entities import SubtitleScanResult
from alfred.domain.subtitles_TO_CHECK.value_objects import SubtitleType
from alfred.domain.subtitles.entities import SubtitleScanResult
from alfred.domain.subtitles.value_objects import SubtitleType
logger = logging.getLogger(__name__)
@@ -2,7 +2,7 @@
import logging
from alfred.infrastructure.api_TO_CHECK.qbittorrent import (
from alfred.infrastructure.api.qbittorrent import (
QBittorrentAPIError,
QBittorrentAuthError,
QBittorrentClient,
@@ -2,7 +2,7 @@
import logging
from alfred.infrastructure.api_TO_CHECK.knaben import (
from alfred.infrastructure.api.knaben import (
KnabenAPIError,
KnabenClient,
KnabenNotFoundError,
@@ -1,21 +0,0 @@
"""TV-show orchestrators — operate on the Alfred-managed TV library tree.
The TV library is a directory of show folders (one per TV show), each
holding season folders containing video files. Modules here walk this
tree and reconstruct on-disk :class:`SeriesRelease` aggregates by
reusing the existing release pipeline (``inspect_release``) rather
than duplicating its parse/probe logic.
"""
from .dto import SearchShowResponse, ShowHit
from .search_show import SearchShowUseCase
from .walker import SeasonFolder, ShowTree, walk_show
__all__ = [
"SearchShowResponse",
"SearchShowUseCase",
"SeasonFolder",
"ShowHit",
"ShowTree",
"walk_show",
]
@@ -1,39 +0,0 @@
"""TV show application DTOs."""
from dataclasses import dataclass, field
@dataclass(frozen=True)
class ShowHit:
"""One TV-show hit, flattened for transport to the agent."""
tmdb_id: int
name: str
first_air_year: int | None = None
def to_dict(self) -> dict:
out: dict = {"tmdb_id": self.tmdb_id, "name": self.name}
if self.first_air_year is not None:
out["first_air_year"] = self.first_air_year
return out
@dataclass
class SearchShowResponse:
"""Response from searching for a TV show."""
status: str
hits: list[ShowHit] = field(default_factory=list)
error: str | None = None
message: str | None = None
def to_dict(self):
result: dict = {"status": self.status}
if self.error:
result["error"] = self.error
result["message"] = self.message
else:
result["hits"] = [h.to_dict() for h in self.hits]
return result
@@ -1,59 +0,0 @@
"""Search TV show use case."""
import logging
from alfred.infrastructure.api_TO_CHECK.tmdb import (
TMDBAPIError,
TMDBClient,
TMDBConfigurationError,
)
from .dto import SearchShowResponse, ShowHit
logger = logging.getLogger(__name__)
class SearchShowUseCase:
"""List TV shows matching a free-text query via TMDB ``/search/tv``.
Symmetric to :class:`alfred.application.movies.SearchMovieUseCase`:
thin orchestrator, flattens domain VOs into agent-friendly
primitives, no ``imdb_id`` enrichment (caller follows up with
:meth:`TMDBClient.get_tv_show_info` on a chosen ``tmdb_id``).
"""
def __init__(self, tmdb_client: TMDBClient):
self.tmdb_client = tmdb_client
def execute(self, show_title: str) -> SearchShowResponse:
try:
results = self.tmdb_client.search_shows(show_title)
hits = [
ShowHit(
tmdb_id=r.tmdb_id.value,
name=r.name,
first_air_year=r.first_air_year,
)
for r in results
]
logger.info(f"search_shows({show_title!r}) → {len(hits)} hits")
return SearchShowResponse(status="ok", hits=hits)
except TMDBConfigurationError as e:
logger.error(f"TMDB configuration error: {e}")
return SearchShowResponse(
status="error", error="configuration_error", message=str(e)
)
except TMDBAPIError as e:
logger.error(f"TMDB API error: {e}")
return SearchShowResponse(
status="error", error="api_error", message=str(e)
)
except ValueError as e:
logger.error(f"Validation error: {e}")
return SearchShowResponse(
status="error", error="validation_failed", message=str(e)
)
@@ -1,208 +0,0 @@
"""Show tree walker — minimal filesystem traversal of a TV show folder.
The walker is intentionally dumb: it lists season folders, classifies
each one as PACK or EPISODIC by **inspecting its filesystem
structure**, and hands the orchestrator a flat list of video files
per season. It does not parse release names, run ffprobe, or
classify subtitle files. All of that intelligence lives in the
existing release pipeline (``inspect_release`` + downstream
services); the walker just hands the orchestrator the paths to feed
into that pipeline.
Folder convention
-----------------
Inside an Alfred-managed library, a show root looks like::
Foundation/
Foundation.S01.1080p.WEB-DL.x265-GROUP/ ← PACK season
Foundation.S01E01.1080p.WEB-DL.x265.mkv ← flat video
Foundation.S01E02.1080p.WEB-DL.x265.mkv
...
Foundation.S02/ ← EPISODIC season
Foundation.S02E01.1080p.WEB-DL.x265-GROUP/ ← episode subfolder
Foundation.S02E01.1080p.WEB-DL.x265-GROUP.mkv
Foundation.S02E02.1080p.WEB-DL.x265-OTHER/
Foundation.S02E02.1080p.WEB-DL.x265-OTHER.mkv
The walker recognizes a season folder by a ``Sxx`` token anywhere in
its name (case-insensitive). It does **not** care about Plex-style
names (``Season 01``, ``Specials``) — the Alfred library uses
release-style folder names only.
PACK vs EPISODIC is a **structural distinction**, not a naming one:
* **PACK** — season folder contains N flat video files. No
subfolders.
* **EPISODIC** — season folder contains N subfolders, each holding
exactly one video.
A season folder that mixes the two layouts (some flat videos AND
some subfolders) is malformed: the walker reports
``mode=None`` and an empty ``video_files`` tuple so the
orchestrator can warn and skip it.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from alfred.domain.releases_TO_CHECK.ports import ReleaseKnowledge
from alfred.domain.releases_TO_CHECK.value_objects import ReleaseMode
from alfred.domain.shared_TO_CHECK.ports import FilesystemScanner
_LOG = logging.getLogger(__name__)
# Matches any ``Sxx`` token (1-2 digits) bounded by non-alphanumerics.
# Examples that match: ``Foundation.S01.1080p`` , ``S2.Pack`` , ``BBC.s10.bluray``.
# Examples that don't: ``Sample`` , ``Soundtrack`` , ``2024.S0E1`` (no S+digits boundary).
_SEASON_TOKEN_RE = re.compile(r"(?<![A-Za-z0-9])s(\d{1,2})(?![A-Za-z0-9])", re.IGNORECASE)
@dataclass(frozen=True)
class SeasonFolder:
"""One season folder discovered inside a show root.
``mode`` is set by the walker from the FS structure:
* :attr:`ReleaseMode.PACK` — ``video_files`` lists the season
folder's flat videos.
* :attr:`ReleaseMode.EPISODIC` — ``video_files`` lists each
episode subfolder's single video.
* ``None`` — the folder is empty, malformed (mixed layout), or
otherwise unclassifiable. ``video_files`` is empty. The
orchestrator decides whether to warn/skip.
"""
season_dir: Path
mode: ReleaseMode | None
video_files: tuple[Path, ...]
@dataclass(frozen=True)
class ShowTree:
"""The full structural snapshot of a show on disk."""
show_root: Path
season_folders: tuple[SeasonFolder, ...]
def walk_show(
show_root: Path,
*,
scanner: FilesystemScanner,
kb: ReleaseKnowledge,
) -> ShowTree:
"""Walk ``show_root`` and return its structural tree.
The walker:
* lists direct children of ``show_root``,
* keeps the directories whose name contains a ``Sxx`` token,
* classifies each season folder as PACK / EPISODIC / unknown by
inspecting its direct children (videos vs subfolders),
* for EPISODIC, descends one extra level into each episode
subfolder to collect its single video,
* sorts season folders by name and video files by name within
each folder.
The walker never raises — empty / unreadable / malformed
directories surface as a ``SeasonFolder`` with ``mode=None`` and
an empty ``video_files`` tuple.
"""
video_exts = {ext.lower() for ext in kb.video_extensions}
season_folders: list[SeasonFolder] = []
for entry in scanner.scan_dir(show_root):
if not entry.is_dir or not _SEASON_TOKEN_RE.search(entry.name):
continue
season_folders.append(
_classify_season(entry.path, scanner=scanner, video_exts=video_exts)
)
return ShowTree(
show_root=show_root, season_folders=tuple(season_folders)
)
# --------------------------------------------------------------------------- #
# Season-folder classification #
# --------------------------------------------------------------------------- #
def _classify_season(
season_dir: Path,
*,
scanner: FilesystemScanner,
video_exts: set[str],
) -> SeasonFolder:
"""Inspect one season folder and decide PACK / EPISODIC / unknown.
Looks only at direct children. For EPISODIC, descends one extra
level into each subfolder to collect its single video. Mixed
layouts (flat videos + subfolders) are reported as ``mode=None``
so the orchestrator can skip them with a warning.
"""
flat_videos: list[Path] = []
subdirs: list[Path] = []
for child in scanner.scan_dir(season_dir):
if child.is_file and child.suffix.lower() in video_exts:
flat_videos.append(child.path)
elif child.is_dir:
subdirs.append(child.path)
# Anything else (non-video files like .nfo, .srt at the season
# root) is ignored — it doesn't affect classification.
has_flat = bool(flat_videos)
has_subdirs = bool(subdirs)
if has_flat and has_subdirs:
_LOG.warning(
"walker: season folder %s mixes flat videos and subfolders — "
"malformed layout, skipping",
season_dir,
)
return SeasonFolder(season_dir=season_dir, mode=None, video_files=())
if has_flat:
return SeasonFolder(
season_dir=season_dir,
mode=ReleaseMode.PACK,
video_files=tuple(sorted(flat_videos)),
)
if has_subdirs:
episode_videos: list[Path] = []
for sub in sorted(subdirs):
videos_in_sub = [
child.path
for child in scanner.scan_dir(sub)
if child.is_file and child.suffix.lower() in video_exts
]
if len(videos_in_sub) == 0:
_LOG.warning(
"walker: episode subfolder %s contains no video — skipping",
sub,
)
continue
if len(videos_in_sub) > 1:
_LOG.warning(
"walker: episode subfolder %s contains %d videos — "
"malformed, skipping season %s",
sub,
len(videos_in_sub),
season_dir,
)
return SeasonFolder(
season_dir=season_dir, mode=None, video_files=()
)
episode_videos.append(videos_in_sub[0])
return SeasonFolder(
season_dir=season_dir,
mode=ReleaseMode.EPISODIC,
video_files=tuple(episode_videos),
)
# No flat videos, no subdirs → empty season folder.
return SeasonFolder(season_dir=season_dir, mode=None, video_files=())
+107
View File
@@ -0,0 +1,107 @@
"""Movie domain entities."""
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(frozen=True, eq=False)
class Movie(MediaWithTracks):
"""
Movie aggregate root for the movies domain.
Carries file metadata (path, size) and the tracks discovered by the
ffprobe + subtitle scan pipeline. The track tuples 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.
Frozen: rebuild via ``dataclasses.replace`` to project enrichment results
(audio/subtitle tracks, file metadata) onto a new instance.
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
title: MovieTitle
release_year: ReleaseYear | None = None
quality: Quality = Quality.UNKNOWN
file_path: FilePath | None = None
file_size: FileSize | None = None
tmdb_id: int | None = None
added_at: datetime = field(default_factory=datetime.now)
audio_tracks: tuple[AudioTrack, ...] = field(default_factory=tuple)
subtitle_tracks: tuple[SubtitleTrack, ...] = field(default_factory=tuple)
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))
else:
raise ValueError(
f"imdb_id must be ImdbId or str, got {type(self.imdb_id)}"
)
# 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))
else:
raise ValueError(
f"title must be MovieTitle or str, got {type(self.title)}"
)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Movie):
return NotImplemented
return self.imdb_id == other.imdb_id
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:
"""
Get the folder name for this movie.
Format: "Title (Year)"
Example: "Inception (2010)"
"""
if self.release_year:
return f"{self.title.value} ({self.release_year.value})"
return self.title.value
def get_filename(self) -> str:
"""
Get the suggested filename for this movie.
Format: "Title.Year.Quality.ext"
Example: "Inception.2010.1080p.mkv"
"""
parts = [self.title.normalized()]
if self.release_year:
parts.append(str(self.release_year.value))
if self.quality != Quality.UNKNOWN:
parts.append(self.quality.value)
# Extension will be added based on actual file
return ".".join(parts)
def __str__(self) -> str:
return f"{self.title.value} ({self.release_year.value if self.release_year else 'Unknown'})"
def __repr__(self) -> str:
return f"Movie(imdb_id={self.imdb_id}, title='{self.title.value}')"
@@ -1,6 +1,6 @@
"""Movie domain exceptions."""
from ..shared_TO_CHECK.exceptions import DomainException, NotFoundError
from ..shared.exceptions import DomainException, NotFoundError
class MovieNotFound(NotFoundError):
+73
View File
@@ -0,0 +1,73 @@
"""Movie repository interfaces (abstract)."""
from abc import ABC, abstractmethod
from ..shared.value_objects import ImdbId
from .entities import Movie
class MovieRepository(ABC):
"""
Abstract repository for movie persistence.
This defines the interface that infrastructure implementations must follow.
"""
@abstractmethod
def save(self, movie: Movie) -> None:
"""
Save a movie to the repository.
Args:
movie: Movie entity to save
"""
pass
@abstractmethod
def find_by_imdb_id(self, imdb_id: ImdbId) -> Movie | None:
"""
Find a movie by its IMDb ID.
Args:
imdb_id: IMDb ID to search for
Returns:
Movie if found, None otherwise
"""
pass
@abstractmethod
def find_all(self) -> list[Movie]:
"""
Get all movies in the repository.
Returns:
List of all movies
"""
pass
@abstractmethod
def delete(self, imdb_id: ImdbId) -> bool:
"""
Delete a movie from the repository.
Args:
imdb_id: IMDb ID of the movie to delete
Returns:
True if deleted, False if not found
"""
pass
@abstractmethod
def exists(self, imdb_id: ImdbId) -> bool:
"""
Check if a movie exists in the repository.
Args:
imdb_id: IMDb ID to check
Returns:
True if exists, False otherwise
"""
pass
@@ -3,7 +3,8 @@
from dataclasses import dataclass
from enum import Enum
from ..shared_TO_CHECK.exceptions import ValidationError
from ..shared.exceptions import ValidationError
from ..shared.value_objects import to_dot_folder_name
class Quality(Enum):
@@ -55,11 +56,18 @@ class MovieTitle:
f"Movie title must be a string, got {type(self.value)}"
)
if len(self.value) > 150:
if len(self.value) > 500:
raise ValidationError(
f"Movie title too long: {len(self.value)} characters (max 150)"
f"Movie title too long: {len(self.value)} characters (max 500)"
)
def normalized(self) -> str:
"""
Return normalized title for file system usage.
Removes special characters and replaces spaces with dots.
"""
return to_dot_folder_name(self.value)
def __str__(self) -> str:
return self.value
@@ -85,6 +93,10 @@ class ReleaseYear:
f"Release year must be an integer, got {type(self.value)}"
)
# Movies started around 1888, and we shouldn't have movies from the future
if self.value < 1888 or self.value > 2100:
raise ValidationError(f"Invalid release year: {self.value}")
def __str__(self) -> str:
return str(self.value)
-91
View File
@@ -1,91 +0,0 @@
"""Movie domain entities."""
from dataclasses import dataclass
from ..shared_TO_CHECK.value_objects import ImdbId, TmdbId
from .value_objects import MovieTitle, ReleaseYear
@dataclass(frozen=True, eq=False)
class Movie:
"""
Movie aggregate root for the movies domain.
TMDB-only aggregate: carries identity (``tmdb_id`` + optional
``imdb_id``) plus the catalog facts that come from TMDB (``title``,
``release_year``). Filesystem-side concerns (file path, quality,
tracks, ``added_at``) live on :class:`alfred.domain.releases.entities.
MovieRelease`, the per-movie release aggregate persisted alongside.
Frozen: rebuild via ``dataclasses.replace`` to project metadata
updates (e.g. a TMDB refresh) onto a new instance.
Equality is identity-based on ``tmdb_id``: two ``Movie`` instances
are equal iff they share the same primary key. ``imdb_id`` is a
secondary anchor and not part of the identity.
"""
tmdb_id: TmdbId
title: MovieTitle
imdb_id: ImdbId | None = None
release_year: ReleaseYear | None = None
def __post_init__(self) -> None:
if not isinstance(self.tmdb_id, TmdbId):
raise ValueError(
f"tmdb_id must be TmdbId, got {type(self.tmdb_id)}"
)
if not isinstance(self.title, MovieTitle):
if isinstance(self.title, str):
object.__setattr__(self, "title", MovieTitle(self.title))
else:
raise ValueError(
f"title must be MovieTitle or str, got {type(self.title)}"
)
if self.imdb_id is not None and not isinstance(self.imdb_id, ImdbId):
raise ValueError(
f"imdb_id must be ImdbId or None, got {type(self.imdb_id)}"
)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Movie):
return NotImplemented
return self.tmdb_id == other.tmdb_id
def __hash__(self) -> int:
return hash(self.tmdb_id)
# WRONG
def get_folder_name(self) -> str:
"""
Get the folder name for this movie.
Format: "Title (Year)"
Example: "Inception (2010)"
"""
if self.release_year:
return f"{self.title.value} ({self.release_year.value})"
return self.title.value
# WRONG
def get_filename(self) -> str:
"""
Get the suggested base filename (without extension) for this movie.
Format: ``Title.Year`` (quality lives on
:class:`alfred.domain.releases.entities.MovieRelease` now and is
appended by the release-aware caller — typically the rescan /
organize flow, after Phase 4).
Example: ``Inception.2010``.
"""
parts = [self.title.normalized()]
if self.release_year:
parts.append(str(self.release_year.value))
return ".".join(parts)
def __str__(self) -> str:
return f"{self.title.value} ({self.release_year.value if self.release_year else 'Unknown'})"
def __repr__(self) -> str:
return f"Movie(tmdb_id={self.tmdb_id}, title='{self.title.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, ParseReport
__all__ = ["ParsedRelease", "ParseReport", "parse_release"]
@@ -17,6 +17,10 @@ The pipeline has three internal paths driven by the detected release group:
knowledge sets, with a 0-100 confidence score.
- **PATH OF PAIN**: score below threshold OR critical chunks missing
signaled to the caller, who decides whether to involve the LLM/user.
Today the package exposes scaffolding only (token VOs and a thin pipeline
stub). The legacy ``parse_release`` in ``release.services`` keeps serving
production until each piece of the v2 pipeline is wired in.
"""
from __future__ import annotations
@@ -29,10 +29,11 @@ arrives through ``kb: ReleaseKnowledge``.
from __future__ import annotations
from ..ports.knowledge import ReleaseKnowledge
from alfred.domain.releases_TO_CHECK.value_objects_old_question_mark import MediaTypeToken
from ..value_objects import MediaTypeToken
from .schema import GroupSchema
from .tokens import Token, TokenRole
# ---------------------------------------------------------------------------
# Stage 1 — tokenize
# ---------------------------------------------------------------------------
@@ -27,7 +27,7 @@ from __future__ import annotations
from enum import Enum
from ..ports.knowledge import ReleaseKnowledge
from alfred.domain.releases_TO_CHECK.value_objects_old_question_mark import ParsedRelease
from ..value_objects import ParsedRelease
from .tokens import Token, TokenRole
@@ -18,9 +18,10 @@ score, the road, and diagnostic info for downstream callers.
from __future__ import annotations
from alfred.domain.releases_TO_CHECK.parser import scoring as _scoring, pipeline as _v2
from alfred.domain.releases_TO_CHECK.ports import ReleaseKnowledge
from alfred.domain.releases_TO_CHECK.value_objects_old_question_mark import MediaTypeToken, ParsedRelease, ParseReport, TokenizationRoute
from .parser import pipeline as _v2
from .parser import scoring as _scoring
from .ports import ReleaseKnowledge
from .value_objects import MediaTypeToken, ParsedRelease, ParseReport, TokenizationRoute
def parse_release(
@@ -18,7 +18,7 @@ from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from alfred.domain.shared_TO_CHECK.exceptions import ValidationError
from ..shared.exceptions import ValidationError
class MediaTypeToken(str, Enum):
@@ -128,6 +128,7 @@ class ParsedRelease:
"""
raw: str # original release name (untouched)
clean: str # raw minus site_tag and apostrophes — used by season_folder_name()
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)
@@ -156,6 +157,18 @@ class ParsedRelease:
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(
@@ -181,3 +194,78 @@ class ParsedRelease:
def is_season_pack(self) -> bool:
return self.season is not None and self.episode is None
@property
def tech_string(self) -> str:
"""``quality.source.codec`` joined by dots, skipping ``None`` parts.
Derived on every access so it stays in sync with the underlying
fields no manual refresh needed after enrichment.
"""
return ".".join(p for p in (self.quality, self.source, self.codec) if p)
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.clean)
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}"
@@ -1,38 +0,0 @@
"""Filesystem release aggregates — what the user owns on disk.
This bounded context is intentionally separated from
``alfred.domain.tv_shows`` / ``alfred.domain.movies`` (TMDB identity).
A :class:`SeriesRelease` describes the physical files on disk for one
show; a :class:`TVShow` describes the work as catalogued by TMDB. The
two are linked by :class:`~alfred.domain.shared.value_objects.TmdbId`
in the persistence layer, never by direct reference.
Not to be confused with ``alfred.domain.release`` (singular) which
parses release **names** (strings → tokens). The two packages may be
merged later; for now they coexist as separate concerns.
"""
from .builders import SeasonReleaseBuilder, SeriesReleaseBuilder
from .entities import (
EpisodeRelease,
MovieRelease,
SeasonRelease,
SeriesRelease,
TrackProfile,
)
from .repositories import MovieReleaseRepository, SeriesReleaseRepository
from .value_objects import EpisodeRange, ReleaseMode
__all__ = [
"EpisodeRange",
"EpisodeRelease",
"MovieRelease",
"MovieReleaseRepository",
"ReleaseMode",
"SeasonRelease",
"SeasonReleaseBuilder",
"SeriesRelease",
"SeriesReleaseBuilder",
"SeriesReleaseRepository",
"TrackProfile",
]
-243
View File
@@ -1,243 +0,0 @@
"""Builders for the filesystem release aggregates.
The aggregates are frozen — :class:`SeriesRelease`, :class:`SeasonRelease`,
and :class:`EpisodeRelease` are ``@dataclass(frozen=True)`` and offer no
mutation methods. All construction goes through these builders, which
assemble the aggregate piece by piece and emit a frozen instance via
``build()``.
Typical usage during a filesystem walk::
builder = SeriesReleaseBuilder(tmdb_id=TmdbId(84958), imdb_id=ImdbId("tt0804484"))
sb = builder.season_builder(SeasonNumber(1), folder="Show.S01", mode=ReleaseMode.PACK)
sb.add_episode(EpisodeRelease(
episodes=EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)),
file_path=FilePath("Show.S01/Show.S01E01.mkv"),
tracks=TrackProfile(),
))
release = builder.build()
Builders are **single-use scratchpads**: they hold mutable state during
construction, then produce an immutable aggregate.
Invariants enforced at ``build()`` time:
* Seasons are emitted sorted by ``season_number``.
* Episodes within each season are emitted sorted by their
``EpisodeRange.start`` (so a season with ``E01-E03`` + ``E04`` is
emitted in that order).
* No two ``EpisodeRelease`` within a season may overlap (same TMDB
episode covered by two distinct files) — raises ``ValidationError``.
"""
from __future__ import annotations
from ..shared_TO_CHECK.exceptions import ValidationError
from ..shared_TO_CHECK.value_objects import ImdbId, TmdbId
from ..tv_shows.value_objects import SeasonNumber
from .entities import (
EpisodeRelease,
SeasonRelease,
SeriesRelease,
)
from .value_objects import ReleaseMode
# ════════════════════════════════════════════════════════════════════════════
# MovieReleaseBuilder
# ════════════════════════════════════════════════════════════════════════════
# ...
# ════════════════════════════════════════════════════════════════════════════
# SeasonReleaseBuilder
# ════════════════════════════════════════════════════════════════════════════
class SeasonReleaseBuilder:
"""
Mutable scratchpad for a :class:`SeasonRelease`.
Episodes are appended in arbitrary order; ``build()`` sorts them by
their range start before emitting the frozen aggregate and verifies
there are no overlapping ranges.
"""
def __init__(
self,
season_number: SeasonNumber | int,
*,
folder: str,
mode: ReleaseMode,
) -> None:
if isinstance(season_number, int):
season_number = SeasonNumber(season_number)
self._season_number: SeasonNumber = season_number
self._folder: str = folder
self._mode: ReleaseMode = mode
self._episodes: list[EpisodeRelease] = []
@classmethod
def from_existing(cls, season: SeasonRelease) -> SeasonReleaseBuilder:
"""Seed a builder from an existing frozen :class:`SeasonRelease`."""
builder = cls(
season.season_number,
folder=season.folder,
mode=season.mode,
)
builder._episodes = list(season.episodes)
return builder
@property
def season_number(self) -> SeasonNumber:
return self._season_number
@property
def mode(self) -> ReleaseMode:
return self._mode
def set_folder(self, folder: str) -> SeasonReleaseBuilder:
self._folder = folder
return self
def set_mode(self, mode: ReleaseMode) -> SeasonReleaseBuilder:
self._mode = mode
return self
def add_episode(self, episode: EpisodeRelease) -> SeasonReleaseBuilder:
"""Append a physical-file :class:`EpisodeRelease` to this season."""
self._episodes.append(episode)
return self
def build(self) -> SeasonRelease:
"""Emit a frozen :class:`SeasonRelease` with episodes sorted.
Raises :class:`ValidationError` if any two episode ranges overlap
(same TMDB slot claimed by two distinct files).
"""
ordered = tuple(
sorted(self._episodes, key=lambda ep: ep.episodes.start.value)
)
# Overlap check — ranges are inclusive on both ends, sorted by start.
for prev, curr in zip(ordered, ordered[1:], strict=False):
if curr.episodes.start.value <= prev.episodes.end.value:
raise ValidationError(
f"SeasonRelease season {self._season_number}: overlapping "
f"episode ranges {prev.episodes} and {curr.episodes}"
)
return SeasonRelease(
season_number=self._season_number,
folder=self._folder,
mode=self._mode,
episodes=ordered,
)
# ════════════════════════════════════════════════════════════════════════════
# SeriesReleaseBuilder
# ════════════════════════════════════════════════════════════════════════════
class SeriesReleaseBuilder:
"""
Mutable scratchpad for the :class:`SeriesRelease` aggregate root.
Seasons are tracked via internal :class:`SeasonReleaseBuilder`
instances keyed by :class:`SeasonNumber`.
"""
def __init__(
self,
*,
tmdb_id: TmdbId | int,
imdb_id: ImdbId | str | None = None,
) -> None:
if isinstance(tmdb_id, int):
tmdb_id = TmdbId(tmdb_id)
if isinstance(imdb_id, str):
imdb_id = ImdbId(imdb_id)
self._tmdb_id: TmdbId = tmdb_id
self._imdb_id: ImdbId | None = imdb_id
self._season_builders: dict[SeasonNumber, SeasonReleaseBuilder] = {}
@classmethod
def from_existing(cls, release: SeriesRelease) -> SeriesReleaseBuilder:
"""Seed a builder from an existing frozen :class:`SeriesRelease`."""
builder = cls(
tmdb_id=release.tmdb_id,
imdb_id=release.imdb_id,
)
for season in release.seasons:
builder._season_builders[season.season_number] = (
SeasonReleaseBuilder.from_existing(season)
)
return builder
# ── Top-level mutators ─────────────────────────────────────────────────
def set_imdb_id(self, imdb_id: ImdbId | str | None) -> SeriesReleaseBuilder:
if isinstance(imdb_id, str):
imdb_id = ImdbId(imdb_id)
self._imdb_id = imdb_id
return self
# ── Content ────────────────────────────────────────────────────────────
def season_builder(
self,
season_number: SeasonNumber | int,
*,
folder: str | None = None,
mode: ReleaseMode | None = None,
) -> SeasonReleaseBuilder:
"""
Return (creating if needed) the :class:`SeasonReleaseBuilder` for a
season.
``folder`` and ``mode`` are required when the builder does not yet
exist for this season; subsequent calls may pass them to override.
"""
if isinstance(season_number, int):
season_number = SeasonNumber(season_number)
sb = self._season_builders.get(season_number)
if sb is None:
if folder is None or mode is None:
raise ValidationError(
f"season_builder({season_number}): folder and mode "
f"are required to create a new season builder"
)
sb = SeasonReleaseBuilder(season_number, folder=folder, mode=mode)
self._season_builders[season_number] = sb
else:
if folder is not None:
sb.set_folder(folder)
if mode is not None:
sb.set_mode(mode)
return sb
def add_season(self, season: SeasonRelease) -> SeriesReleaseBuilder:
"""
Attach (or replace) a fully-built :class:`SeasonRelease`.
Replaces any existing season with the same number.
"""
self._season_builders[season.season_number] = (
SeasonReleaseBuilder.from_existing(season)
)
return self
# ── Emit ───────────────────────────────────────────────────────────────
def build(self) -> SeriesRelease:
"""Emit a frozen :class:`SeriesRelease` with seasons sorted by number."""
ordered_seasons = tuple(
self._season_builders[n].build()
for n in sorted(self._season_builders, key=lambda x: x.value)
)
return SeriesRelease(
tmdb_id=self._tmdb_id,
imdb_id=self._imdb_id,
seasons=ordered_seasons,
)
-217
View File
@@ -1,217 +0,0 @@
"""Filesystem release aggregates.
The release domain models what the user owns on disk — one
:class:`SeriesRelease` per show, one :class:`MovieRelease` per movie.
TMDB identity (title, status, episode_count, …) lives in the
``tv_shows`` / ``movies`` domains and is linked via the
:class:`~alfred.domain.shared.value_objects.TmdbId` natural key.
All entities are frozen. Mutation goes through the builders in
:mod:`alfred.domain.releases.builders`.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from ..shared_TO_CHECK.exceptions import ValidationError
from ..shared_TO_CHECK.media import AudioTrack, SubtitleTrack
from ..shared_TO_CHECK.value_objects import FilePath, ImdbId, TmdbId
from ..tv_shows.value_objects import SeasonNumber
from .value_objects import EpisodeRange, ReleaseMode
__all__ = [
"EpisodeRelease",
"MovieRelease",
"SeasonRelease",
"SeriesRelease",
"TrackProfile",
]
@dataclass(frozen=True)
class TrackProfile:
"""
Audio + subtitle tracks of one physical file.
Tracks live per-file (not per-season): every ``EpisodeRelease`` and
``MovieRelease`` carries its own ``TrackProfile``. Season-level
aggregation is computed by the caller when needed.
"""
audio_tracks: tuple[AudioTrack, ...] = ()
subtitle_tracks: tuple[SubtitleTrack, ...] = ()
@dataclass(frozen=True)
class EpisodeRelease:
"""
One physical episode file (or multi-episode file) on disk.
:attr:`episodes` is an :class:`EpisodeRange` — a single ``.mkv``
that covers ``S01E02E03`` carries ``EpisodeRange(start=E02, end=E03)``
and is recorded once. The library index lists it under each covered
slot (``E02``, ``E03``) for symmetric lookups.
:attr:`file_path` is **relative to the show root** (e.g.
``"Show.S01/Show.S01E02.mkv"`` for PACK,
``"Show.S01/Show.S01E02-RG/Show.S01E02-RG.mkv"`` for EPISODIC).
The caller (repository) prepends the absolute show root when
needed.
"""
episodes: EpisodeRange
file_path: FilePath
tracks: TrackProfile = TrackProfile()
@dataclass(frozen=True)
class SeasonRelease:
"""
All physical files on disk for one season of a show.
The :attr:`mode` flag records the filesystem layout:
* :attr:`ReleaseMode.PACK` — the season folder contains N video
files directly. ``episodes`` lists each ``.mkv`` in the folder.
* :attr:`ReleaseMode.EPISODIC` — the season folder contains N
sub-folders, each with one episode. ``episodes`` lists each
``(subfolder, file)`` pair.
:attr:`folder` is the season folder name, relative to the show root.
Invariant: every ``EpisodeRelease.episodes`` range stays within
sane bounds (validated at construction). Cross-episode duplicate
detection (two files claiming the same TMDB slot) is the
builder's job, not the entity's.
"""
season_number: SeasonNumber
folder: str
mode: ReleaseMode
episodes: tuple[EpisodeRelease, ...] = ()
def __post_init__(self) -> None:
if not isinstance(self.season_number, SeasonNumber):
raise ValidationError(
f"SeasonRelease.season_number must be SeasonNumber, "
f"got {type(self.season_number)}"
)
if not isinstance(self.mode, ReleaseMode):
raise ValidationError(
f"SeasonRelease.mode must be ReleaseMode, got {type(self.mode)}"
)
if not isinstance(self.folder, str) or not self.folder:
raise ValidationError(
f"SeasonRelease.folder must be a non-empty string, "
f"got {self.folder!r}"
)
def episode_count(self) -> int:
"""
Total number of TMDB episode slots covered by all physical files.
Sums each :meth:`EpisodeRange.count` — a season with two files
``E01`` + ``E02-E03`` returns ``3`` (one slot from the first
file, two from the second).
Compared by the caller against the library index's TMDB
``episode_count`` to detect incomplete seasons.
"""
return sum(ep.episodes.count() for ep in self.episodes)
@dataclass(frozen=True)
class SeriesRelease:
"""
All physical seasons on disk for one show.
Anchored to TMDB by :attr:`tmdb_id` (primary key). :attr:`imdb_id`
is optional and stored as a secondary anchor — useful for the
occasional show without TMDB coverage, and for cross-checking
when both ids are known.
Seasons are exposed sorted by ``season_number`` (the builder
enforces this on emit). No duplicate ``season_number`` is
permitted across :attr:`seasons`.
"""
tmdb_id: TmdbId
imdb_id: ImdbId | None
seasons: tuple[SeasonRelease, ...] = ()
def __post_init__(self) -> None:
if not isinstance(self.tmdb_id, TmdbId):
raise ValidationError(
f"SeriesRelease.tmdb_id must be TmdbId, got {type(self.tmdb_id)}"
)
if self.imdb_id is not None and not isinstance(self.imdb_id, ImdbId):
raise ValidationError(
f"SeriesRelease.imdb_id must be ImdbId or None, "
f"got {type(self.imdb_id)}"
)
seen: set[int] = set()
for s in self.seasons:
if s.season_number.value in seen:
raise ValidationError(
f"SeriesRelease has duplicate season "
f"{s.season_number}"
)
seen.add(s.season_number.value)
def get_season(self, season_number: SeasonNumber) -> SeasonRelease | None:
"""Return the :class:`SeasonRelease` for ``season_number`` or ``None``."""
for s in self.seasons:
if s.season_number == season_number:
return s
return None
@dataclass(frozen=True)
class MovieRelease:
"""
A single physical movie file on disk.
Anchored to TMDB by :attr:`tmdb_id`; :attr:`imdb_id` optional
secondary anchor.
:attr:`folder` is the movie folder name relative to the
``movies/`` library root. :attr:`file_path` is the video file
name relative to the folder (movies are one folder, one file in
Alfred's layout — no sub-folders).
:attr:`added_at` is the UTC timestamp at which the release was
first observed in the library — set by the caller (organizer /
rescan) when the aggregate is built. Persisted by the v2 movie
sidecar; not derived from the filesystem (mtime drifts across
moves and hard-links).
"""
tmdb_id: TmdbId
imdb_id: ImdbId | None
folder: str
file_path: FilePath
added_at: datetime
tracks: TrackProfile = TrackProfile()
def __post_init__(self) -> None:
if not isinstance(self.tmdb_id, TmdbId):
raise ValidationError(
f"MovieRelease.tmdb_id must be TmdbId, got {type(self.tmdb_id)}"
)
if self.imdb_id is not None and not isinstance(self.imdb_id, ImdbId):
raise ValidationError(
f"MovieRelease.imdb_id must be ImdbId or None, "
f"got {type(self.imdb_id)}"
)
if not isinstance(self.folder, str) or not self.folder:
raise ValidationError(
f"MovieRelease.folder must be a non-empty string, "
f"got {self.folder!r}"
)
if not isinstance(self.added_at, datetime):
raise ValidationError(
f"MovieRelease.added_at must be datetime, "
f"got {type(self.added_at)}"
)

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