feat: release parser, media type detection, ffprobe integration
Replace the old domain/media release parser with a full rewrite under
domain/release/:
- ParsedRelease with media_type ("movie" | "tv_show" | "tv_complete" |
"documentary" | "concert" | "other" | "unknown"), site_tag, parse_path,
languages, audio_codec, audio_channels, bit_depth, hdr_format, edition
- Well-formedness check + sanitize pipeline (_is_well_formed, _sanitize,
_strip_site_tag) before token-level parsing
- Multi-token sequence matching for audio (DTS-HD.MA, TrueHD.Atmos…),
HDR (DV.HDR10…) and editions (DIRECTORS.CUT…)
- Knowledge YAML: file_extensions, release_format, languages, audio,
video, editions, sites/c411
New infrastructure:
- ffprobe.py — single-pass probe returning MediaInfo (video, audio
tracks, subtitle tracks)
- find_video.py — locate first video file in a release folder
New application helpers:
- detect_media_type — filesystem-based type refinement
- enrich_from_probe — fill missing ParsedRelease fields from MediaInfo
New agent tools:
- analyze_release — parse + detect type + ffprobe in one call
- probe_media — standalone ffprobe for a specific file
New domain value object:
- MediaInfo + AudioTrack + SubtitleTrack (domain/shared/media_info.py)
Testing CLIs:
- recognize_folders_in_downloads.py — full pipeline with colored output
- probe_video.py — display MediaInfo for a video file
This commit is contained in:
@@ -8,7 +8,7 @@ from typing import Any
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
from alfred.settings import settings
|
||||
|
||||
from .prompts import PromptBuilder
|
||||
from .prompt import PromptBuilder
|
||||
from .registry import Tool, make_tools
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
"""Prompt builder for the agent system."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
from alfred.infrastructure.persistence.memory import MemoryRegistry
|
||||
|
||||
from .registry import Tool
|
||||
|
||||
|
||||
class PromptBuilder:
|
||||
"""Builds system prompts for the agent with memory context."""
|
||||
|
||||
def __init__(self, tools: dict[str, Tool]):
|
||||
self.tools = tools
|
||||
self._memory_registry = MemoryRegistry()
|
||||
|
||||
def build_tools_spec(self) -> list[dict[str, Any]]:
|
||||
"""Build the tool specification for the LLM API."""
|
||||
tool_specs = []
|
||||
for tool in self.tools.values():
|
||||
spec = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": tool.parameters,
|
||||
},
|
||||
}
|
||||
tool_specs.append(spec)
|
||||
return tool_specs
|
||||
|
||||
def _format_tools_description(self) -> str:
|
||||
"""Format tools with their descriptions and parameters."""
|
||||
if not self.tools:
|
||||
return ""
|
||||
return "\n".join(
|
||||
f"- {tool.name}: {tool.description}\n"
|
||||
f" Parameters: {json.dumps(tool.parameters, ensure_ascii=False)}"
|
||||
for tool in self.tools.values()
|
||||
)
|
||||
|
||||
def _format_episodic_context(self, memory) -> str:
|
||||
"""Format episodic memory context for the prompt."""
|
||||
lines = []
|
||||
|
||||
if memory.episodic.last_search_results:
|
||||
results = memory.episodic.last_search_results
|
||||
result_list = results.get("results", [])
|
||||
lines.append(
|
||||
f"\nLAST SEARCH: '{results.get('query')}' ({len(result_list)} results)"
|
||||
)
|
||||
# Show first 5 results
|
||||
for i, result in enumerate(result_list[:5]):
|
||||
name = result.get("name", "Unknown")
|
||||
lines.append(f" {i + 1}. {name}")
|
||||
if len(result_list) > 5:
|
||||
lines.append(f" ... and {len(result_list) - 5} more")
|
||||
|
||||
if memory.episodic.pending_question:
|
||||
question = memory.episodic.pending_question
|
||||
lines.append(f"\nPENDING QUESTION: {question.get('question')}")
|
||||
lines.append(f" Type: {question.get('type')}")
|
||||
if question.get("options"):
|
||||
lines.append(f" Options: {len(question.get('options'))}")
|
||||
|
||||
if memory.episodic.active_downloads:
|
||||
lines.append(f"\nACTIVE DOWNLOADS: {len(memory.episodic.active_downloads)}")
|
||||
for dl in memory.episodic.active_downloads[:3]:
|
||||
lines.append(f" - {dl.get('name')}: {dl.get('progress', 0)}%")
|
||||
|
||||
if memory.episodic.recent_errors:
|
||||
lines.append("\nRECENT ERRORS (up to 3):")
|
||||
for error in memory.episodic.recent_errors[-3:]:
|
||||
lines.append(
|
||||
f" - Action '{error.get('action')}' failed: {error.get('error')}"
|
||||
)
|
||||
|
||||
# Unread events
|
||||
unread = [e for e in memory.episodic.background_events if not e.get("read")]
|
||||
if unread:
|
||||
lines.append(f"\nUNREAD EVENTS: {len(unread)}")
|
||||
for event in unread[:3]:
|
||||
lines.append(f" - {event.get('type')}: {event.get('data')}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_stm_context(self, memory) -> str:
|
||||
"""Format short-term memory context for the prompt."""
|
||||
lines = []
|
||||
|
||||
if memory.stm.current_workflow:
|
||||
workflow = memory.stm.current_workflow
|
||||
lines.append(
|
||||
f"CURRENT WORKFLOW: {workflow.get('type')} (stage: {workflow.get('stage')})"
|
||||
)
|
||||
if workflow.get("target"):
|
||||
lines.append(f" Target: {workflow.get('target')}")
|
||||
|
||||
if memory.stm.current_topic:
|
||||
lines.append(f"CURRENT TOPIC: {memory.stm.current_topic}")
|
||||
|
||||
if memory.stm.extracted_entities:
|
||||
lines.append("EXTRACTED ENTITIES:")
|
||||
for key, value in memory.stm.extracted_entities.items():
|
||||
lines.append(f" - {key}: {value}")
|
||||
|
||||
if memory.stm.language:
|
||||
lines.append(f"CONVERSATION LANGUAGE: {memory.stm.language}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_memory_schema(self) -> str:
|
||||
"""Describe available memory components so the agent knows what to read/write and when."""
|
||||
schema = self._memory_registry.schema()
|
||||
tier_labels = {"ltm": "LONG-TERM (persisted)", "stm": "SHORT-TERM (session)", "episodic": "EPISODIC (volatile)"}
|
||||
lines = ["MEMORY COMPONENTS:"]
|
||||
|
||||
for tier, components in schema.items():
|
||||
if not components:
|
||||
continue
|
||||
lines.append(f"\n [{tier_labels.get(tier, tier.upper())}]")
|
||||
for c in components:
|
||||
access = c.get("access", "read")
|
||||
lines.append(f" {c['name']} ({access}): {c['description']}")
|
||||
for field_name, field_desc in c.get("fields", {}).items():
|
||||
lines.append(f" · {field_name}: {field_desc}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_config_context(self, memory) -> str:
|
||||
"""Format configuration context."""
|
||||
lines = ["CURRENT CONFIGURATION:"]
|
||||
folders = {**memory.ltm.workspace.as_dict(), **memory.ltm.library_paths.to_dict()}
|
||||
if folders:
|
||||
for key, value in folders.items():
|
||||
lines.append(f" - {key}: {value}")
|
||||
else:
|
||||
lines.append(" (no configuration set)")
|
||||
return "\n".join(lines)
|
||||
|
||||
def build_system_prompt(self) -> str:
|
||||
"""Build the complete system prompt."""
|
||||
# Get memory once for all context formatting
|
||||
memory = get_memory()
|
||||
|
||||
# Base instruction
|
||||
base = "You are a helpful AI assistant for managing a media library."
|
||||
|
||||
# Language instruction
|
||||
language_instruction = (
|
||||
"Your first task is to determine the user's language from their message "
|
||||
"and use the `set_language` tool if it's different from the current one. "
|
||||
"After that, proceed to help the user."
|
||||
)
|
||||
|
||||
# Available tools
|
||||
tools_desc = self._format_tools_description()
|
||||
tools_section = f"\nAVAILABLE TOOLS:\n{tools_desc}" if tools_desc else ""
|
||||
|
||||
# Memory schema
|
||||
memory_schema = self._format_memory_schema()
|
||||
|
||||
# Configuration
|
||||
config_section = self._format_config_context(memory)
|
||||
if config_section:
|
||||
config_section = f"\n{config_section}"
|
||||
|
||||
# STM context
|
||||
stm_context = self._format_stm_context(memory)
|
||||
if stm_context:
|
||||
stm_context = f"\n{stm_context}"
|
||||
|
||||
# Episodic context
|
||||
episodic_context = self._format_episodic_context(memory)
|
||||
|
||||
# Important rules
|
||||
rules = """
|
||||
IMPORTANT RULES:
|
||||
- Use tools to accomplish tasks
|
||||
- When search results are available, reference them by index (e.g., "add_torrent_by_index")
|
||||
- Always confirm actions with the user before executing destructive operations
|
||||
- Provide clear, concise responses
|
||||
"""
|
||||
|
||||
# Examples
|
||||
examples = """
|
||||
EXAMPLES:
|
||||
- User: "Find Inception" → Use find_media_imdb_id, then find_torrent
|
||||
- User: "download the 3rd one" → Use add_torrent_by_index with index=3
|
||||
- User: "List my downloads" → Use list_folder with folder_type="download"
|
||||
"""
|
||||
|
||||
return f"""{base}
|
||||
|
||||
{language_instruction}
|
||||
{tools_section}
|
||||
|
||||
{memory_schema}
|
||||
{config_section}
|
||||
{stm_context}
|
||||
{episodic_context}
|
||||
{rules}
|
||||
{examples}
|
||||
"""
|
||||
@@ -97,6 +97,8 @@ def make_tools(settings) -> dict[str, Tool]:
|
||||
tool_functions = [
|
||||
fs_tools.set_path_for_folder,
|
||||
fs_tools.list_folder,
|
||||
fs_tools.analyze_release,
|
||||
fs_tools.probe_media,
|
||||
fs_tools.resolve_destination,
|
||||
fs_tools.move_media,
|
||||
fs_tools.manage_subtitles,
|
||||
|
||||
@@ -14,7 +14,11 @@ from alfred.application.filesystem import (
|
||||
ResolveDestinationUseCase,
|
||||
SetFolderPathUseCase,
|
||||
)
|
||||
from alfred.application.filesystem.detect_media_type import detect_media_type
|
||||
from alfred.application.filesystem.enrich_from_probe import enrich_from_probe
|
||||
from alfred.infrastructure.filesystem import FileManager
|
||||
from alfred.infrastructure.filesystem.ffprobe import probe
|
||||
from alfred.infrastructure.filesystem.find_video import find_video_file
|
||||
|
||||
_LEARNED_ROOT = Path(_alfred_pkg.__file__).parent.parent / "data" / "knowledge"
|
||||
|
||||
@@ -213,6 +217,124 @@ def set_path_for_folder(folder_name: str, path_value: str) -> dict[str, Any]:
|
||||
return response.to_dict()
|
||||
|
||||
|
||||
def analyze_release(release_name: str, source_path: str) -> dict[str, Any]:
|
||||
"""
|
||||
Fully analyze a release: parse name, detect media type, probe video with ffprobe.
|
||||
|
||||
Combines parse_release + filesystem type detection + ffprobe in a single call.
|
||||
Use this at the start of any organize workflow to get a complete picture before
|
||||
deciding how to route the release.
|
||||
|
||||
Args:
|
||||
release_name: Raw release folder or file name.
|
||||
source_path: Absolute path to the release folder or file on disk.
|
||||
|
||||
Returns:
|
||||
Dict with all parsed fields: media_type, title, year, season, episode,
|
||||
quality, codec, source, group, languages, audio_codec, audio_channels,
|
||||
bit_depth, hdr_format, edition, site_tag, parse_path,
|
||||
and probe_used (bool).
|
||||
"""
|
||||
from alfred.domain.release.services import parse_release
|
||||
|
||||
path = Path(source_path)
|
||||
parsed = parse_release(release_name)
|
||||
parsed.media_type = detect_media_type(parsed, path)
|
||||
|
||||
probe_used = False
|
||||
if parsed.media_type not in ("unknown", "other"):
|
||||
video_file = find_video_file(path)
|
||||
if video_file:
|
||||
media_info = probe(video_file)
|
||||
if media_info:
|
||||
enrich_from_probe(parsed, media_info)
|
||||
probe_used = True
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"media_type": parsed.media_type,
|
||||
"parse_path": parsed.parse_path,
|
||||
"title": parsed.title,
|
||||
"year": parsed.year,
|
||||
"season": parsed.season,
|
||||
"episode": parsed.episode,
|
||||
"episode_end": parsed.episode_end,
|
||||
"quality": parsed.quality,
|
||||
"source": parsed.source,
|
||||
"codec": parsed.codec,
|
||||
"group": parsed.group,
|
||||
"languages": parsed.languages,
|
||||
"audio_codec": parsed.audio_codec,
|
||||
"audio_channels": parsed.audio_channels,
|
||||
"bit_depth": parsed.bit_depth,
|
||||
"hdr_format": parsed.hdr_format,
|
||||
"edition": parsed.edition,
|
||||
"site_tag": parsed.site_tag,
|
||||
"is_season_pack": parsed.is_season_pack,
|
||||
"probe_used": probe_used,
|
||||
}
|
||||
|
||||
|
||||
def probe_media(source_path: str) -> dict[str, Any]:
|
||||
"""
|
||||
Run ffprobe on a video file and return detailed media information.
|
||||
|
||||
Use this to inspect a specific file for codec, resolution, audio tracks,
|
||||
languages, and embedded subtitles — independently of release name parsing.
|
||||
|
||||
Args:
|
||||
source_path: Absolute path to the video file.
|
||||
|
||||
Returns:
|
||||
Dict with video (codec, resolution, width, height, duration, bitrate),
|
||||
audio_tracks (list of codec/channels/language), subtitle_tracks
|
||||
(list of codec/language/forced), audio_languages, is_multi_audio —
|
||||
or error if ffprobe fails.
|
||||
"""
|
||||
path = Path(source_path)
|
||||
if not path.exists():
|
||||
return {"status": "error", "error": "not_found", "message": f"{source_path} does not exist"}
|
||||
|
||||
media_info = probe(path)
|
||||
if media_info is None:
|
||||
return {"status": "error", "error": "probe_failed", "message": "ffprobe failed to read the file"}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"video": {
|
||||
"codec": media_info.video_codec,
|
||||
"resolution": media_info.resolution,
|
||||
"width": media_info.width,
|
||||
"height": media_info.height,
|
||||
"duration_seconds": media_info.duration_seconds,
|
||||
"bitrate_kbps": media_info.bitrate_kbps,
|
||||
},
|
||||
"audio_tracks": [
|
||||
{
|
||||
"index": t.index,
|
||||
"codec": t.codec,
|
||||
"channels": t.channels,
|
||||
"channel_layout": t.channel_layout,
|
||||
"language": t.language,
|
||||
"is_default": t.is_default,
|
||||
}
|
||||
for t in media_info.audio_tracks
|
||||
],
|
||||
"subtitle_tracks": [
|
||||
{
|
||||
"index": t.index,
|
||||
"codec": t.codec,
|
||||
"language": t.language,
|
||||
"is_default": t.is_default,
|
||||
"is_forced": t.is_forced,
|
||||
}
|
||||
for t in media_info.subtitle_tracks
|
||||
],
|
||||
"audio_languages": media_info.audio_languages,
|
||||
"is_multi_audio": media_info.is_multi_audio,
|
||||
}
|
||||
|
||||
|
||||
def list_folder(folder_type: str, path: str = ".") -> dict[str, Any]:
|
||||
"""
|
||||
List contents of a configured folder.
|
||||
|
||||
Reference in New Issue
Block a user