feat: generic MetadataStore + read_release_metadata + query_library
- Extract MetadataStore from SubtitleMetadataStore (alfred/infrastructure/metadata/).
Generic load/save + typed update helpers (update_parse, update_probe, update_tmdb)
for the per-release .alfred/metadata.yaml.
- SubtitleMetadataStore becomes a thin facade — owns subtitle_history shape,
delegates I/O to MetadataStore.
- Agent._execute_tool_call auto-persists successful analyze_release / probe_media /
find_media_imdb_id results to the release's .alfred file. find_media_imdb_id
follows release_focus when it has no path argument.
- New tools:
· read_release_metadata(release_path) — cacheable, key=release_path.
Returns the .alfred content or has_metadata=false.
· query_library(name) — substring scan across configured library roots.
- Both new tools added to CORE_TOOLS (always visible).
This commit is contained in:
@@ -3,8 +3,10 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from alfred.infrastructure.metadata import MetadataStore
|
||||||
from alfred.infrastructure.persistence import get_memory
|
from alfred.infrastructure.persistence import get_memory
|
||||||
from alfred.settings import settings
|
from alfred.settings import settings
|
||||||
|
|
||||||
@@ -243,6 +245,7 @@ class Agent:
|
|||||||
|
|
||||||
Today:
|
Today:
|
||||||
- Update release_focus when a path-keyed inspector runs.
|
- Update release_focus when a path-keyed inspector runs.
|
||||||
|
- Persist inspector results into the release's `.alfred/metadata.yaml`.
|
||||||
- Refresh episodic.last_search_results on find_torrent cache hits so
|
- Refresh episodic.last_search_results on find_torrent cache hits so
|
||||||
get_torrent_by_index keeps pointing at the right list.
|
get_torrent_by_index keeps pointing at the right list.
|
||||||
"""
|
"""
|
||||||
@@ -255,6 +258,11 @@ class Agent:
|
|||||||
if isinstance(path, str) and path:
|
if isinstance(path, str) and path:
|
||||||
memory.stm.release_focus.focus(path)
|
memory.stm.release_focus.focus(path)
|
||||||
|
|
||||||
|
# Persist inspector results to .alfred/metadata.yaml (skip on cache
|
||||||
|
# hit — the file is already up to date from the original run).
|
||||||
|
if not from_cache:
|
||||||
|
self._maybe_update_alfred(tool_name, args, result)
|
||||||
|
|
||||||
# Episodic refresh when find_torrent's cache short-circuits the call.
|
# Episodic refresh when find_torrent's cache short-circuits the call.
|
||||||
if from_cache and tool_name == "find_torrent":
|
if from_cache and tool_name == "find_torrent":
|
||||||
torrents = result.get("torrents") or []
|
torrents = result.get("torrents") or []
|
||||||
@@ -263,6 +271,62 @@ class Agent:
|
|||||||
query=query, results=torrents, search_type="torrent"
|
query=query, results=torrents, search_type="torrent"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _maybe_update_alfred(
|
||||||
|
self,
|
||||||
|
tool_name: str,
|
||||||
|
args: dict[str, Any],
|
||||||
|
result: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Persist a successful inspector result into the release's
|
||||||
|
`.alfred/metadata.yaml`. No-op when the release root can't be resolved.
|
||||||
|
"""
|
||||||
|
if tool_name not in {"analyze_release", "probe_media", "find_media_imdb_id"}:
|
||||||
|
return
|
||||||
|
|
||||||
|
release_root = self._resolve_release_root(tool_name, args)
|
||||||
|
if release_root is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
store = MetadataStore(release_root)
|
||||||
|
if tool_name == "analyze_release":
|
||||||
|
store.update_parse(result)
|
||||||
|
elif tool_name == "probe_media":
|
||||||
|
store.update_probe(result)
|
||||||
|
elif tool_name == "find_media_imdb_id":
|
||||||
|
store.update_tmdb(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to update .alfred for {tool_name} at {release_root}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_release_root(
|
||||||
|
tool_name: str,
|
||||||
|
args: dict[str, Any],
|
||||||
|
) -> Path | None:
|
||||||
|
"""
|
||||||
|
Figure out which release folder owns this call.
|
||||||
|
|
||||||
|
- analyze_release / probe_media: derived from source_path
|
||||||
|
(folder kept as-is, file walked up to its parent).
|
||||||
|
- find_media_imdb_id: follow the current release focus in STM.
|
||||||
|
"""
|
||||||
|
if tool_name in {"analyze_release", "probe_media"}:
|
||||||
|
raw = args.get("source_path")
|
||||||
|
if not isinstance(raw, str) or not raw:
|
||||||
|
return None
|
||||||
|
path = Path(raw)
|
||||||
|
return path if path.is_dir() else path.parent
|
||||||
|
|
||||||
|
# find_media_imdb_id has no path arg — rely on release focus.
|
||||||
|
focus = get_memory().stm.release_focus.current_release_path
|
||||||
|
if not focus:
|
||||||
|
return None
|
||||||
|
path = Path(focus)
|
||||||
|
return path if path.is_dir() else path.parent
|
||||||
|
|
||||||
async def step_streaming(
|
async def step_streaming(
|
||||||
self, user_input: str, completion_id: str, created_ts: int, model: str
|
self, user_input: str, completion_id: str, created_ts: int, model: str
|
||||||
) -> AsyncGenerator[dict[str, Any]]:
|
) -> AsyncGenerator[dict[str, Any]]:
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ CORE_TOOLS: tuple[str, ...] = (
|
|||||||
"set_language",
|
"set_language",
|
||||||
"set_path_for_folder",
|
"set_path_for_folder",
|
||||||
"list_folder",
|
"list_folder",
|
||||||
|
"read_release_metadata",
|
||||||
|
"query_library",
|
||||||
"start_workflow",
|
"start_workflow",
|
||||||
"end_workflow",
|
"end_workflow",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ def make_tools(settings) -> dict[str, Tool]:
|
|||||||
tool_functions = [
|
tool_functions = [
|
||||||
fs_tools.set_path_for_folder,
|
fs_tools.set_path_for_folder,
|
||||||
fs_tools.list_folder,
|
fs_tools.list_folder,
|
||||||
|
fs_tools.read_release_metadata,
|
||||||
|
fs_tools.query_library,
|
||||||
fs_tools.analyze_release,
|
fs_tools.analyze_release,
|
||||||
fs_tools.probe_media,
|
fs_tools.probe_media,
|
||||||
fs_tools.resolve_season_destination,
|
fs_tools.resolve_season_destination,
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ from alfred.application.filesystem.resolve_destination import (
|
|||||||
from alfred.infrastructure.filesystem import FileManager, create_folder, move
|
from alfred.infrastructure.filesystem import FileManager, create_folder, move
|
||||||
from alfred.infrastructure.filesystem.ffprobe import probe
|
from alfred.infrastructure.filesystem.ffprobe import probe
|
||||||
from alfred.infrastructure.filesystem.find_video import find_video_file
|
from alfred.infrastructure.filesystem.find_video import find_video_file
|
||||||
|
from alfred.infrastructure.metadata import MetadataStore
|
||||||
|
from alfred.infrastructure.persistence import get_memory
|
||||||
|
|
||||||
_LEARNED_ROOT = Path(_alfred_pkg.__file__).parent.parent / "data" / "knowledge"
|
_LEARNED_ROOT = Path(_alfred_pkg.__file__).parent.parent / "data" / "knowledge"
|
||||||
|
|
||||||
@@ -288,3 +290,76 @@ def list_folder(folder_type: str, path: str = ".") -> dict[str, Any]:
|
|||||||
use_case = ListFolderUseCase(file_manager)
|
use_case = ListFolderUseCase(file_manager)
|
||||||
response = use_case.execute(folder_type, path)
|
response = use_case.execute(folder_type, path)
|
||||||
return response.to_dict()
|
return response.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def read_release_metadata(release_path: str) -> dict[str, Any]:
|
||||||
|
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/read_release_metadata.yaml."""
|
||||||
|
path = Path(release_path)
|
||||||
|
if not path.exists():
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "not_found",
|
||||||
|
"message": f"{release_path} does not exist",
|
||||||
|
}
|
||||||
|
root = path if path.is_dir() else path.parent
|
||||||
|
store = MetadataStore(root)
|
||||||
|
if not store.exists():
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"release_path": str(root),
|
||||||
|
"has_metadata": False,
|
||||||
|
"metadata": {},
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"release_path": str(root),
|
||||||
|
"has_metadata": True,
|
||||||
|
"metadata": store.load(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def query_library(name: str) -> dict[str, Any]:
|
||||||
|
"""Thin tool wrapper — semantics live in alfred/agent/tools/specs/query_library.yaml."""
|
||||||
|
needle = name.strip().lower()
|
||||||
|
if not needle:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "empty_name",
|
||||||
|
"message": "name must be a non-empty string",
|
||||||
|
}
|
||||||
|
|
||||||
|
memory = get_memory()
|
||||||
|
roots = memory.ltm.library_paths.to_dict() or {}
|
||||||
|
if not roots:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "no_libraries",
|
||||||
|
"message": "No library paths configured — call set_path_for_folder first.",
|
||||||
|
}
|
||||||
|
|
||||||
|
matches: list[dict[str, Any]] = []
|
||||||
|
for collection, root in roots.items():
|
||||||
|
root_path = Path(root)
|
||||||
|
if not root_path.is_dir():
|
||||||
|
continue
|
||||||
|
for entry in root_path.iterdir():
|
||||||
|
if not entry.is_dir():
|
||||||
|
continue
|
||||||
|
if needle not in entry.name.lower():
|
||||||
|
continue
|
||||||
|
store = MetadataStore(entry)
|
||||||
|
matches.append(
|
||||||
|
{
|
||||||
|
"collection": collection,
|
||||||
|
"name": entry.name,
|
||||||
|
"path": str(entry),
|
||||||
|
"has_metadata": store.exists(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"query": name,
|
||||||
|
"match_count": len(matches),
|
||||||
|
"matches": matches,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: query_library
|
||||||
|
|
||||||
|
summary: >
|
||||||
|
Find release folders across all configured library roots whose name
|
||||||
|
contains a substring (case-insensitive).
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Scans every configured library root (movies, tv_shows, …) at depth 1
|
||||||
|
and returns folders whose name contains the query. For each match,
|
||||||
|
reports whether a `.alfred/metadata.yaml` exists — handy to spot
|
||||||
|
releases that have not been inspected yet. Does not recurse into
|
||||||
|
seasons / episodes; one entry per release folder.
|
||||||
|
|
||||||
|
when_to_use: |
|
||||||
|
- To answer "do I already have X?" without listing whole library
|
||||||
|
roots one by one.
|
||||||
|
- To pick the release_path to feed read_release_metadata or any
|
||||||
|
inspector tool.
|
||||||
|
|
||||||
|
when_not_to_use: |
|
||||||
|
- To list the *whole* library — that scan should live behind a
|
||||||
|
dedicated tool (not implemented yet).
|
||||||
|
- To browse a single root — use list_folder instead, it's cheaper
|
||||||
|
and doesn't open every library.
|
||||||
|
|
||||||
|
next_steps: |
|
||||||
|
- When one match is found: feed its path to read_release_metadata or
|
||||||
|
analyze_release.
|
||||||
|
- When several match: surface the indexed list to the user and ask
|
||||||
|
which one they mean.
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
name:
|
||||||
|
description: Case-insensitive substring of the release name to look for.
|
||||||
|
why_needed: |
|
||||||
|
Library folders are named after the release (Title.Year.... or
|
||||||
|
Title (Year)). A substring is enough to catch typical user
|
||||||
|
phrasings ("foundation", "inception 2010").
|
||||||
|
example: foundation
|
||||||
|
|
||||||
|
returns:
|
||||||
|
ok:
|
||||||
|
description: Scan completed (possibly zero matches).
|
||||||
|
fields:
|
||||||
|
status: "'ok'"
|
||||||
|
query: The query string as received.
|
||||||
|
match_count: Number of matching folders.
|
||||||
|
matches: "List of {collection, name, path, has_metadata}."
|
||||||
|
|
||||||
|
error:
|
||||||
|
description: Scan could not run.
|
||||||
|
fields:
|
||||||
|
error: Short error code (no_libraries, empty_name).
|
||||||
|
message: Human-readable explanation.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
name: read_release_metadata
|
||||||
|
|
||||||
|
summary: >
|
||||||
|
Read the `.alfred/metadata.yaml` file for a release folder.
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Returns whatever has been previously persisted by inspector tools
|
||||||
|
(analyze_release, probe_media, find_media_imdb_id) and by the subtitle
|
||||||
|
pipeline. Works for any folder — download or library — as long as the
|
||||||
|
release has been touched at least once. Missing metadata is not an
|
||||||
|
error: the tool returns `has_metadata=false` with an empty dict.
|
||||||
|
|
||||||
|
when_to_use: |
|
||||||
|
- Before re-running analyze_release / probe_media on a release you
|
||||||
|
might have already seen — saves a full re-inspection.
|
||||||
|
- To answer "what do we know about X?" without scanning.
|
||||||
|
- To list which releases in a library have no `.alfred` yet (loop +
|
||||||
|
`has_metadata`).
|
||||||
|
|
||||||
|
when_not_to_use: |
|
||||||
|
- To search a library by name — use query_library.
|
||||||
|
- When you need a fresh probe/parse — call the inspector directly,
|
||||||
|
the result will be persisted automatically.
|
||||||
|
|
||||||
|
next_steps: |
|
||||||
|
- If `has_metadata=false`, decide whether to inspect now
|
||||||
|
(analyze_release / probe_media).
|
||||||
|
- If `has_metadata=true`, read `metadata.parse`, `metadata.probe`,
|
||||||
|
`metadata.tmdb` blocks before deciding next actions.
|
||||||
|
|
||||||
|
cache:
|
||||||
|
key: release_path
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
release_path:
|
||||||
|
description: Absolute path to the release folder (or any file inside it).
|
||||||
|
why_needed: |
|
||||||
|
The store lives at `<release_root>/.alfred/metadata.yaml`. A file
|
||||||
|
path is auto-resolved to its parent folder.
|
||||||
|
example: /mnt/library/tv_shows/Foundation.2021.1080p.WEBRip.x265-RARBG
|
||||||
|
|
||||||
|
returns:
|
||||||
|
ok:
|
||||||
|
description: Release inspected (file may or may not exist).
|
||||||
|
fields:
|
||||||
|
status: "'ok'"
|
||||||
|
release_path: Absolute path of the release folder.
|
||||||
|
has_metadata: True if `.alfred/metadata.yaml` exists.
|
||||||
|
metadata: Full content of the file, or empty dict.
|
||||||
|
|
||||||
|
error:
|
||||||
|
description: Path does not exist on disk.
|
||||||
|
fields:
|
||||||
|
error: Short error code (not_found).
|
||||||
|
message: Human-readable explanation.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Per-release `.alfred/metadata.yaml` persistence."""
|
||||||
|
|
||||||
|
from .store import MetadataStore
|
||||||
|
|
||||||
|
__all__ = ["MetadataStore"]
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""
|
||||||
|
MetadataStore — reads/writes the `.alfred/metadata.yaml` file colocated with
|
||||||
|
a release folder.
|
||||||
|
|
||||||
|
The store is intentionally domain-agnostic: it knows how to atomically
|
||||||
|
load/save the YAML and exposes typed update helpers for the broad facts a
|
||||||
|
release carries (parse, probe, TMDB lookup, detected pattern). Subtitle
|
||||||
|
history lives next to the same file but is appended through a dedicated
|
||||||
|
helper kept under `alfred/infrastructure/subtitle/` so the subtitle pipeline
|
||||||
|
keeps full ownership of its payload shape.
|
||||||
|
|
||||||
|
The file layout:
|
||||||
|
|
||||||
|
<release_root>/
|
||||||
|
.alfred/
|
||||||
|
metadata.yaml
|
||||||
|
|
||||||
|
The store never raises on a missing file — it returns empty defaults. Writes
|
||||||
|
are atomic (write to .tmp then rename).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataStore:
|
||||||
|
"""Manages `.alfred/metadata.yaml` for one release folder."""
|
||||||
|
|
||||||
|
def __init__(self, release_root: str | Path):
|
||||||
|
self._root = Path(release_root)
|
||||||
|
self._alfred_dir = self._root / ".alfred"
|
||||||
|
self._metadata_path = self._alfred_dir / "metadata.yaml"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Identity
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def release_root(self) -> Path:
|
||||||
|
return self._root
|
||||||
|
|
||||||
|
@property
|
||||||
|
def metadata_path(self) -> Path:
|
||||||
|
return self._metadata_path
|
||||||
|
|
||||||
|
def exists(self) -> bool:
|
||||||
|
return self._metadata_path.exists()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Load / Save
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def load(self) -> dict:
|
||||||
|
"""Return the full metadata dict. Empty dict if file absent."""
|
||||||
|
if not self._metadata_path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(self._metadata_path, encoding="utf-8") as f:
|
||||||
|
return yaml.safe_load(f) or {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"MetadataStore: could not read {self._metadata_path}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save(self, data: dict) -> None:
|
||||||
|
"""Atomically write metadata.yaml. Creates .alfred/ if needed."""
|
||||||
|
self._alfred_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = self._metadata_path.with_suffix(".yaml.tmp")
|
||||||
|
try:
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
yaml.safe_dump(
|
||||||
|
data,
|
||||||
|
f,
|
||||||
|
allow_unicode=True,
|
||||||
|
default_flow_style=False,
|
||||||
|
sort_keys=False,
|
||||||
|
)
|
||||||
|
tmp.rename(self._metadata_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"MetadataStore: could not write {self._metadata_path}: {e}")
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Generic update helper
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def update_section(self, section: str, payload: dict[str, Any]) -> None:
|
||||||
|
"""
|
||||||
|
Merge `payload` into the top-level `section` block and stamp it.
|
||||||
|
|
||||||
|
The section is replaced wholesale (not deep-merged) so the last
|
||||||
|
successful tool run reflects the current truth. A `_updated_at`
|
||||||
|
ISO-8601 timestamp is added inside the section.
|
||||||
|
"""
|
||||||
|
data = self.load()
|
||||||
|
stamped = dict(payload)
|
||||||
|
stamped["_updated_at"] = datetime.now(UTC).isoformat()
|
||||||
|
data[section] = stamped
|
||||||
|
self.save(data)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Typed update helpers — one per inspector tool
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def update_parse(self, parse_result: dict[str, Any]) -> None:
|
||||||
|
"""Persist the result of analyze_release."""
|
||||||
|
clean = {k: v for k, v in parse_result.items() if k != "status"}
|
||||||
|
self.update_section("parse", clean)
|
||||||
|
|
||||||
|
def update_probe(self, probe_result: dict[str, Any]) -> None:
|
||||||
|
"""Persist the result of probe_media."""
|
||||||
|
clean = {k: v for k, v in probe_result.items() if k != "status"}
|
||||||
|
self.update_section("probe", clean)
|
||||||
|
|
||||||
|
def update_tmdb(self, tmdb_result: dict[str, Any]) -> None:
|
||||||
|
"""Persist the result of find_media_imdb_id."""
|
||||||
|
clean = {k: v for k, v in tmdb_result.items() if k != "status"}
|
||||||
|
self.update_section("tmdb", clean)
|
||||||
|
# Also promote core identity fields to the top level so they are
|
||||||
|
# cheap to read without parsing the full tmdb block.
|
||||||
|
data = self.load()
|
||||||
|
for key in ("imdb_id", "tmdb_id", "media_type"):
|
||||||
|
if key in clean and clean[key] is not None:
|
||||||
|
data[key] = clean[key]
|
||||||
|
if "title" in clean and clean["title"]:
|
||||||
|
data.setdefault("title", clean["title"])
|
||||||
|
self.save(data)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Pattern (used by the subtitle pipeline)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def confirmed_pattern(self) -> str | None:
|
||||||
|
"""Return the confirmed pattern_id, or None."""
|
||||||
|
data = self.load()
|
||||||
|
if data.get("pattern_confirmed"):
|
||||||
|
return data.get("detected_pattern")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def mark_pattern_confirmed(
|
||||||
|
self, pattern_id: str, media_info: dict | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Persist detected_pattern + pattern_confirmed=true."""
|
||||||
|
data = self.load()
|
||||||
|
data["detected_pattern"] = pattern_id
|
||||||
|
data["pattern_confirmed"] = True
|
||||||
|
if media_info:
|
||||||
|
data.setdefault("media_type", media_info.get("media_type"))
|
||||||
|
data.setdefault("imdb_id", media_info.get("imdb_id"))
|
||||||
|
data.setdefault("title", media_info.get("title"))
|
||||||
|
self.save(data)
|
||||||
|
logger.info(
|
||||||
|
f"MetadataStore: confirmed pattern '{pattern_id}' for {self._root.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Subtitle history (kept for backwards compatibility with the
|
||||||
|
# subtitle pipeline — payload shape is owned by the caller).
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def append_subtitle_history_entry(self, entry: dict[str, Any]) -> None:
|
||||||
|
"""Append one entry (raw dict) to subtitle_history."""
|
||||||
|
data = self.load()
|
||||||
|
history = data.setdefault("subtitle_history", [])
|
||||||
|
history.append(entry)
|
||||||
|
rg = entry.get("release_group")
|
||||||
|
if rg:
|
||||||
|
groups = data.setdefault("release_groups", [])
|
||||||
|
if rg not in groups:
|
||||||
|
groups.append(rg)
|
||||||
|
self.save(data)
|
||||||
|
|
||||||
|
def subtitle_history(self) -> list[dict]:
|
||||||
|
"""Return the raw subtitle history list."""
|
||||||
|
return self.load().get("subtitle_history", [])
|
||||||
@@ -1,98 +1,47 @@
|
|||||||
"""SubtitleMetadataStore — reads/writes .alfred/metadata.yaml colocated with media."""
|
"""
|
||||||
|
SubtitleMetadataStore — subtitle-specific helper on top of MetadataStore.
|
||||||
|
|
||||||
|
Owns the shape of `subtitle_history` entries (track-level fields, type
|
||||||
|
inference from the destination filename) and delegates all I/O to the
|
||||||
|
generic MetadataStore.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from alfred.domain.subtitles.entities import SubtitleTrack
|
from alfred.domain.subtitles.entities import SubtitleTrack
|
||||||
from alfred.domain.subtitles.services.placer import PlacedTrack
|
from alfred.domain.subtitles.services.placer import PlacedTrack
|
||||||
|
from alfred.infrastructure.metadata.store import MetadataStore
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SubtitleMetadataStore:
|
class SubtitleMetadataStore:
|
||||||
"""
|
"""
|
||||||
Manages the .alfred/metadata.yaml file that lives inside the media library folder.
|
Subtitle-pipeline view of the per-release `.alfred/metadata.yaml`.
|
||||||
|
|
||||||
For TV shows: /media/tv_shows/The X-Files/.alfred/metadata.yaml
|
Backed by a generic MetadataStore; this class only knows how to build
|
||||||
For movies: /media/movies/Inception (2010)/.alfred/metadata.yaml
|
a subtitle_history entry from PlacedTrack/SubtitleTrack pairs.
|
||||||
|
|
||||||
The store never raises on a missing file — it returns empty defaults.
|
|
||||||
Writes are atomic (write to .tmp then rename).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, library_root: Path):
|
def __init__(self, library_root: Path):
|
||||||
self._root = library_root
|
self._store = MetadataStore(library_root)
|
||||||
self._alfred_dir = library_root / ".alfred"
|
|
||||||
self._metadata_path = self._alfred_dir / "metadata.yaml"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ---- Pattern -----------------------------------------------------
|
||||||
# Load / Save
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def load(self) -> dict:
|
|
||||||
"""Return the full metadata dict. Empty dict if file absent."""
|
|
||||||
if not self._metadata_path.exists():
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
with open(self._metadata_path, encoding="utf-8") as f:
|
|
||||||
return yaml.safe_load(f) or {}
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"MetadataStore: could not read {self._metadata_path}: {e}")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def save(self, data: dict) -> None:
|
|
||||||
"""Atomically write metadata.yaml. Creates .alfred/ if needed."""
|
|
||||||
self._alfred_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
tmp = self._metadata_path.with_suffix(".yaml.tmp")
|
|
||||||
try:
|
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
|
||||||
yaml.safe_dump(
|
|
||||||
data,
|
|
||||||
f,
|
|
||||||
allow_unicode=True,
|
|
||||||
default_flow_style=False,
|
|
||||||
sort_keys=False,
|
|
||||||
)
|
|
||||||
tmp.rename(self._metadata_path)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"MetadataStore: could not write {self._metadata_path}: {e}")
|
|
||||||
tmp.unlink(missing_ok=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Pattern
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def confirmed_pattern(self) -> str | None:
|
def confirmed_pattern(self) -> str | None:
|
||||||
"""Return the confirmed pattern_id, or None."""
|
return self._store.confirmed_pattern()
|
||||||
data = self.load()
|
|
||||||
if data.get("pattern_confirmed"):
|
|
||||||
return data.get("detected_pattern")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def mark_pattern_confirmed(
|
def mark_pattern_confirmed(
|
||||||
self, pattern_id: str, media_info: dict | None = None
|
self, pattern_id: str, media_info: dict | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Persist detected_pattern + pattern_confirmed=true."""
|
self._store.mark_pattern_confirmed(pattern_id, media_info)
|
||||||
data = self.load()
|
|
||||||
data["detected_pattern"] = pattern_id
|
|
||||||
data["pattern_confirmed"] = True
|
|
||||||
if media_info:
|
|
||||||
data.setdefault("media_type", media_info.get("media_type"))
|
|
||||||
data.setdefault("imdb_id", media_info.get("imdb_id"))
|
|
||||||
data.setdefault("title", media_info.get("title"))
|
|
||||||
self.save(data)
|
|
||||||
logger.info(
|
|
||||||
f"MetadataStore: confirmed pattern '{pattern_id}' for {self._root.name}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ---- History -----------------------------------------------------
|
||||||
# Subtitle history
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def append_history(
|
def append_history(
|
||||||
self,
|
self,
|
||||||
@@ -105,15 +54,10 @@ class SubtitleMetadataStore:
|
|||||||
if not placed_pairs:
|
if not placed_pairs:
|
||||||
return
|
return
|
||||||
|
|
||||||
data = self.load()
|
|
||||||
history = data.setdefault("subtitle_history", [])
|
|
||||||
|
|
||||||
tracks_data: list[dict[str, Any]] = []
|
tracks_data: list[dict[str, Any]] = []
|
||||||
for placed, track in placed_pairs:
|
for placed, track in placed_pairs:
|
||||||
# Infer type from destination filename parts (e.g. en.sdh.srt → sdh)
|
# Infer type from destination filename parts (e.g. en.sdh.srt → sdh)
|
||||||
parts = placed.filename.rsplit(
|
parts = placed.filename.rsplit(".", 2)
|
||||||
".", 2
|
|
||||||
) # ["en", "sdh", "srt"] or ["en", "srt"]
|
|
||||||
inferred_type = parts[1] if len(parts) == 3 else "standard"
|
inferred_type = parts[1] if len(parts) == 3 else "standard"
|
||||||
|
|
||||||
tracks_data.append(
|
tracks_data.append(
|
||||||
@@ -138,21 +82,14 @@ class SubtitleMetadataStore:
|
|||||||
if episode is not None:
|
if episode is not None:
|
||||||
entry["episode"] = episode
|
entry["episode"] = episode
|
||||||
|
|
||||||
history.append(entry)
|
self._store.append_subtitle_history_entry(entry)
|
||||||
|
marker = (
|
||||||
# Update release_groups list
|
f"S{season:02d}E{episode:02d}" if season and episode else "movie"
|
||||||
if release_group:
|
)
|
||||||
groups = data.setdefault("release_groups", [])
|
|
||||||
if release_group not in groups:
|
|
||||||
groups.append(release_group)
|
|
||||||
|
|
||||||
self.save(data)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"MetadataStore: appended history "
|
f"SubtitleMetadataStore: appended history "
|
||||||
f"({'S%02dE%02d' % (season, episode) if season and episode else 'movie'}) "
|
f"({marker}) — {len(tracks_data)} track(s)"
|
||||||
f"— {len(tracks_data)} track(s)"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def history(self) -> list[dict]:
|
def history(self) -> list[dict]:
|
||||||
"""Return the raw history list."""
|
return self._store.subtitle_history()
|
||||||
return self.load().get("subtitle_history", [])
|
|
||||||
|
|||||||
Reference in New Issue
Block a user