28 Commits

Author SHA1 Message Date
340c54b3d8 chore: bump version 0.1.1 → 0.1.2
Some checks failed
CI/CD Awesome Pipeline / Test (push) Successful in 37s
CI/CD Awesome Pipeline / Build & Push to Registry (push) Failing after 3m51s
2025-12-21 12:42:43 +01:00
8d0bc59d28 fix: Added API keys to CI
All checks were successful
CI/CD Awesome Pipeline / Test (push) Successful in 2m42s
CI/CD Awesome Pipeline / Build & Push to Registry (push) Has been skipped
2025-12-21 12:37:38 +01:00
f969724ee4 infra!: added CI/CD pipeline and made various improvements to Makefile and Dockerfile
Some checks failed
CI/CD Awesome Pipeline / Test (push) Failing after 17m6s
CI/CD Awesome Pipeline / Build & Push to Registry (push) Has been skipped
2025-12-21 12:01:02 +01:00
ffd2678c91 infra!: made app runner-agnostic (poetry/uv) and optimized build process 2025-12-21 05:27:59 +01:00
365f110f9c feat: Makefile full of commands 2025-12-20 09:05:18 +01:00
59d40241e2 feat: 'install-hooks' make command 2025-12-20 04:33:05 +01:00
51fb30646c fix: Fix branch check 2025-12-20 04:30:02 +01:00
4966072f64 chore: bump version 0.1.0 → 0.1.1 2025-12-20 04:05:39 +01:00
39acf8e1f2 infra: Added versioning 2025-12-20 04:04:21 +01:00
4dadb9c4cf fix! Bad Ruff configuration 2025-12-18 07:52:54 +01:00
b350bb12d3 infra! Removed black (replaced by Ruff for formatting) 2025-12-18 07:49:05 +01:00
f88f512cb0 infra! Added pre-commit module and configuration 2025-12-18 07:37:50 +01:00
9a726b52bc Fixed ConnectionError preventing IA response 2025-12-15 04:52:00 +01:00
8b94507aeb Finished dockerization 2025-12-14 07:03:37 +01:00
52d568e924 Dockerizing the app (WIP) 2025-12-09 16:12:58 +01:00
da3d6f123d Fix tests and previous commit 2025-12-09 07:11:02 +01:00
ca63865b07 Move .env.example to parent folder 2025-12-09 05:40:01 +01:00
ec7d2d623f Updated folder structure (for Docker) 2025-12-09 05:35:59 +01:00
6940c76e58 Updated README and did a little bit of cleanup 2025-12-09 04:24:16 +01:00
0c48640412 Fixed all ruff issues 2025-12-07 05:59:53 +01:00
a21121d025 Fix more ruff issues 2025-12-07 05:42:29 +01:00
10704896f9 Fix some ruff issues in code 2025-12-07 05:33:39 +01:00
7c9598f632 Display actions in frontend (needs to add manifest files to LibreChat) 2025-12-07 04:49:57 +01:00
4eae1d6d58 Formatting 2025-12-07 03:33:51 +01:00
a923a760ef Unfucked gemini's mess 2025-12-07 03:27:45 +01:00
5b71233fb0 Recovered tests 2025-12-06 23:55:21 +01:00
9ca31e45e0 feat!: migrate to OpenAI native tool calls and fix circular deps (#fuck-gemini)
- Fix circular dependencies in agent/tools
- Migrate from custom JSON to OpenAI tool calls format
- Add async streaming (step_stream, complete_stream)
- Simplify prompt system and remove token counting
- Add 5 new API endpoints (/health, /v1/models, /api/memory/*)
- Add 3 new tools (get_torrent_by_index, add_torrent_by_index, set_language)
- Fix all 500 tests and add coverage config (80% threshold)
- Add comprehensive docs (README, pytest guide)

BREAKING: LLM interface changed, memory injection via get_memory()
2025-12-06 19:11:05 +01:00
2c8cdd3ab1 New archi: domain driven development
Working but need to check out code
2025-12-01 07:10:03 +01:00
135 changed files with 17506 additions and 2255 deletions

View File

@@ -1,16 +1,69 @@
# DeepSeek LLM Configuration
DEEPSEEK_API_KEY=your_deepseek_api_key_here
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-chat
TEMPERATURE=0.2
# Agent Media - Environment Variables
# TMDB API Configuration
TMDB_API_KEY=your_tmdb_api_key_here
TMDB_BASE_URL=https://api.themoviedb.org/3
# LibreChat Security Keys
# Generate secure keys with: openssl rand -base64 32
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_REFRESH_SECRET=your-super-secret-refresh-key-change-this-too
# Storage Configuration
MEMORY_FILE=memory.json
# Generate with: openssl rand -hex 16 (for CREDS_KEY)
CREDS_KEY=your-32-character-secret-key-here
# Security Configuration
MAX_TOOL_ITERATIONS=5
REQUEST_TIMEOUT=30
# Generate with: openssl rand -hex 8 (for CREDS_IV)
CREDS_IV=your-16-character-iv-here
# LibreChat Configuration
DOMAIN_CLIENT=http://localhost:3080
DOMAIN_SERVER=http://localhost:3080
# Session expiry (in milliseconds)
# Default: 15 minutes
SESSION_EXPIRY=900000
# Refresh token expiry (in milliseconds)
# Default: 7 days
REFRESH_TOKEN_EXPIRY=604800000
# Meilisearch Configuration
# Master key for Meilisearch (generate with: openssl rand -base64 32)
MEILI_MASTER_KEY=DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFU
# PostgreSQL Configuration (for RAG API)
POSTGRES_DB=librechat_rag
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
# RAG API Configuration (Vector Database)
RAG_COLLECTION_NAME=testcollection
RAG_EMBEDDINGS_PROVIDER=openai
RAG_EMBEDDINGS_MODEL=text-embedding-3-small
# API Keys
# OpenAI API Key (required for RAG embeddings)
OPENAI_API_KEY=your-openai-api-key-here
# Deepseek API Key (for LLM in agent-brain)
DEEPSEEK_API_KEY=your-deepseek-api-key-here
# Agent Brain Configuration
# LLM Provider (deepseek or ollama)
LLM_PROVIDER=deepseek
# Memory storage directory (inside container)
MEMORY_STORAGE_DIR=/data/memory
# API Key for agent-brain (used by LibreChat custom endpoint)
AGENT_BRAIN_API_KEY=agent-brain-secret-key
# External Services (Optional)
# TMDB API Key (for movie metadata)
TMDB_API_KEY=your-tmdb-key
# qBittorrent Configuration
QBITTORRENT_URL=http://localhost:8080
QBITTORRENT_USERNAME=admin
QBITTORRENT_PASSWORD=adminpass
# Debug Options
DEBUG_LOGGING=false
DEBUG_CONSOLE=false

57
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,57 @@
name: CI/CD Awesome Pipeline
on:
push:
branches: [main]
tags:
- 'v*.*.*'
pull_request:
branches: [main]
env:
REGISTRY_URL: ${{ vars.REGISTRY_URL || 'gitea.iswearihadsomethingforthis.net' }}
REGISTRY_USER: ${{ vars.REGISTRY_USER || 'francwa' }}
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build and run tests
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }}
run: make _ci-run-tests
build-and-push:
name: Build & Push to Registry
runs-on: ubuntu-latest
needs: test
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Load config from Makefile
id: config
run: |
eval "$(make _ci-image-name)"
echo "image_name=${IMAGE_NAME}" >> $GITHUB_OUTPUT
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Build production image
run: make build
- name: Tag and push to registry
run: |
docker tag ${{ steps.config.outputs.image_name }}:latest ${{ env.REGISTRY_URL }}/${{ env.REGISTRY_USER }}/${{ steps.config.outputs.image_name }}:${{ steps.version.outputs.version }}
docker tag ${{ steps.config.outputs.image_name }}:latest ${{ env.REGISTRY_URL }}/${{ env.REGISTRY_USER }}/${{ steps.config.outputs.image_name }}:latest
echo "${{ secrets.GITEA_TOKEN }}" | docker login ${{ env.REGISTRY_URL }} -u ${{ env.REGISTRY_USER }} --password-stdin
docker push ${{ env.REGISTRY_URL }}/${{ env.REGISTRY_USER }}/${{ steps.config.outputs.image_name }}:${{ steps.version.outputs.version }}
docker push ${{ env.REGISTRY_URL }}/${{ env.REGISTRY_USER }}/${{ steps.config.outputs.image_name }}:latest

15
.gitignore vendored
View File

@@ -28,6 +28,7 @@ env/
# IDE
.vscode/
.idea/
.ruff_cache
*.swp
*.swo
*~
@@ -37,6 +38,17 @@ env/
# Memory and state files
memory.json
memory_data/
# Coverage reports
.coverage
.coverage.*
htmlcov/
coverage.xml
*.cover
# Pytest cache
.pytest_cache/
# OS
.DS_Store
@@ -44,3 +56,6 @@ Thumbs.db
# Secrets
.env
# Backup files
*.backup

35
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,35 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-toml
- id: check-added-large-files
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.0
hooks:
- id: gitleaks
name: Gitleaks
- repo: local
hooks:
- id: ruff-check
name: Ruff Linter
entry: bash -c 'make lint'
language: system
types: [python]
- id: ruff-format
name: Ruff Formatter
entry: bash -c 'make format'
language: system
types: [python]
- id: system-pytest
name: Pytest
entry: bash -c 'make test'
language: system
always_run: true

249
Makefile Normal file
View File

@@ -0,0 +1,249 @@
.POSIX:
.SUFFIXES:
.DEFAULT_GOAL := help
# --- SETTINGS ---
PYTHON_VERSION = 3.12.7
PYTHON_VERSION_SHORT = $(shell echo $(PYTHON_VERSION) | cut -d. -f1,2)
# Change to 'uv' when ready.
RUNNER ?= poetry
export PYTHON_VERSION
export PYTHON_VERSION_SHORT
export RUNNER
export IMAGE_NAME
# --- VARIABLES ---
CORE_DIR = brain
SERVICE_NAME = agent_media
IMAGE_NAME = agent_media
# --- ADAPTERS ---
# UV uses "sync", Poetry uses "install". Both install DEV deps by default.
INSTALL_CMD = $(if $(filter uv,$(RUNNER)),sync,install)
# --- MACROS ---
ARGS = $(filter-out $@,$(MAKECMDGOALS))
BUMP_CMD = cd $(CORE_DIR) && $(RUNNER) run bump-my-version bump
COMPOSE_CMD = docker-compose
DOCKER_CMD = docker build \
--build-arg PYTHON_VERSION=$(PYTHON_VERSION) \
--build-arg PYTHON_VERSION_SHORT=$(PYTHON_VERSION_SHORT) \
--build-arg RUNNER=$(RUNNER) \
-f $(CORE_DIR)/Dockerfile \
-t $(IMAGE_NAME):latest .
RUNNER_ADD = cd $(CORE_DIR) && $(RUNNER) add
RUNNER_HOOKS = cd $(CORE_DIR) && $(RUNNER) run pre-commit install -c ../.pre-commit-config.yaml
RUNNER_INSTALL = cd $(CORE_DIR) && $(RUNNER) $(INSTALL_CMD)
RUNNER_RUN = cd $(CORE_DIR) && $(RUNNER) run
RUNNER_UPDATE = cd $(CORE_DIR) && $(RUNNER) update
# --- STYLES ---
B = \033[1m
G = \033[32m
T = \033[36m
R = \033[0m
# --- TARGETS ---
.PHONY: add build build-test check-docker check-runner clean coverage down format help init-dotenv install install-hooks lint logs major minor patch prune ps restart run shell test up update _check_branch _ci-image-name _ci-run-tests
# Catch-all for args
%:
@:
add: check-runner
@echo "$(T) Adding dependency ($(RUNNER)): $(ARGS)$(R)"
$(RUNNER_ADD) $(ARGS)
build: check-docker
@echo "$(T)🐳 Building Docker image...$(R)"
$(DOCKER_CMD)
@echo "✅ Image $(IMAGE_NAME):latest ready."
build-test: check-docker
@echo "$(T)🐳 Building test image (with dev deps)...$(R)"
docker build \
--build-arg RUNNER=$(RUNNER) \
--build-arg PYTHON_VERSION=$(PYTHON_VERSION) \
--build-arg PYTHON_VERSION_SHORT=$(PYTHON_VERSION_SHORT) \
-f $(CORE_DIR)/Dockerfile \
--target test \
-t $(IMAGE_NAME):test .
@echo "✅ Test image $(IMAGE_NAME):test ready."
check-docker:
@command -v docker >/dev/null 2>&1 || { echo "$(R)❌ Docker not installed$(R)"; exit 1; }
@docker info >/dev/null 2>&1 || { echo "$(R)❌ Docker daemon not running$(R)"; exit 1; }
check-runner:
@command -v $(RUNNER) >/dev/null 2>&1 || { echo "$(R)$(RUNNER) not installed$(R)"; exit 1; }
clean:
@echo "$(T)🧹 Cleaning caches...$(R)"
cd $(CORE_DIR) && rm -rf .ruff_cache __pycache__ .pytest_cache
find $(CORE_DIR) -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find $(CORE_DIR) -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
find $(CORE_DIR) -type f -name "*.pyc" -delete 2>/dev/null || true
@echo "✅ Caches cleaned."
coverage: check-runner
@echo "$(T)📊 Running tests with coverage...$(R)"
$(RUNNER_RUN) pytest --cov=. --cov-report=html --cov-report=term $(ARGS)
@echo "✅ Report generated in htmlcov/"
down: check-docker
@echo "$(T)🛑 Stopping containers...$(R)"
$(COMPOSE_CMD) down
@echo "✅ System stopped."
format: check-runner
@echo "$(T)✨ Formatting with Ruff...$(R)"
$(RUNNER_RUN) ruff format .
$(RUNNER_RUN) ruff check --fix .
@echo "✅ Code cleaned."
help:
@echo "$(B)Available commands:$(R)"
@echo ""
@echo "$(G)Setup:$(R)"
@echo " $(T)check-docker $(R) Verify Docker is installed and running."
@echo " $(T)check-runner $(R) Verify package manager ($(RUNNER))."
@echo " $(T)init-dotenv $(R) Create .env from .env.example with generated secrets."
@echo " $(T)install $(R) Install ALL dependencies (Prod + Dev)."
@echo " $(T)install-hooks $(R) Install git pre-commit hooks."
@echo ""
@echo "$(G)Docker:$(R)"
@echo " $(T)build $(R) Build the docker image (production)."
@echo " $(T)build-test $(R) Build the docker image (with dev deps for testing)."
@echo " $(T)down $(R) Stop and remove containers."
@echo " $(T)logs $(R) Follow logs."
@echo " $(T)prune $(R) Clean Docker system."
@echo " $(T)ps $(R) Show container status."
@echo " $(T)restart $(R) Restart all containers."
@echo " $(T)shell $(R) Open shell in container."
@echo " $(T)up $(R) Start the agent."
@echo ""
@echo "$(G)Development:$(R)"
@echo " $(T)add ... $(R) Add dependency (use --group dev or --dev if needed)."
@echo " $(T)clean $(R) Clean caches."
@echo " $(T)coverage $(R) Run tests with coverage."
@echo " $(T)format $(R) Format code (Ruff)."
@echo " $(T)lint $(R) Lint code without fixing."
@echo " $(T)test ... $(R) Run tests (local with $(RUNNER))."
@echo " $(T)update $(R) Update dependencies."
@echo ""
@echo "$(G)Versioning:$(R)"
@echo " $(T)major/minor/patch $(R) Bump version."
init-dotenv:
@echo "$(T)🔑 Initializing .env file...$(R)"
@if [ -f .env ]; then \
echo "$(R)⚠️ .env already exists. Skipping.$(R)"; \
exit 0; \
fi
@if [ ! -f .env.example ]; then \
echo "$(R)❌ .env.example not found$(R)"; \
exit 1; \
fi
@if ! command -v openssl >/dev/null 2>&1; then \
echo "$(R)❌ openssl not found. Please install it first.$(R)"; \
exit 1; \
fi
@echo "$(T) → Copying .env.example...$(R)"
@cp .env.example .env
@echo "$(T) → Generating secrets...$(R)"
@sed -i.bak "s|JWT_SECRET=.*|JWT_SECRET=$$(openssl rand -base64 32)|" .env
@sed -i.bak "s|JWT_REFRESH_SECRET=.*|JWT_REFRESH_SECRET=$$(openssl rand -base64 32)|" .env
@sed -i.bak "s|CREDS_KEY=.*|CREDS_KEY=$$(openssl rand -hex 16)|" .env
@sed -i.bak "s|CREDS_IV=.*|CREDS_IV=$$(openssl rand -hex 8)|" .env
@sed -i.bak "s|MEILI_MASTER_KEY=.*|MEILI_MASTER_KEY=$$(openssl rand -base64 32)|" .env
@sed -i.bak "s|AGENT_BRAIN_API_KEY=.*|AGENT_BRAIN_API_KEY=$$(openssl rand -base64 24)|" .env
@rm -f .env.bak
@echo "$(G)✅ .env created with generated secrets!$(R)"
@echo "$(T)⚠️ Don't forget to add your API keys:$(R)"
@echo " - OPENAI_API_KEY"
@echo " - DEEPSEEK_API_KEY"
@echo " - TMDB_API_KEY (optional)"
install: check-runner
@echo "$(T)📦 Installing FULL environment ($(RUNNER))...$(R)"
$(RUNNER_INSTALL)
@echo "✅ Environment ready (Prod + Dev)."
install-hooks: check-runner
@echo "$(T)🔧 Installing hooks...$(R)"
$(RUNNER_HOOKS)
@echo "✅ Hooks ready."
lint: check-runner
@echo "$(T)🔍 Linting code...$(R)"
$(RUNNER_RUN) ruff check .
logs: check-docker
@echo "$(T)📋 Following logs...$(R)"
$(COMPOSE_CMD) logs -f
major: _check_branch
@echo "$(T)💥 Bumping major...$(R)"
SKIP=all $(BUMP_CMD) major
minor: _check_branch
@echo "$(T)✨ Bumping minor...$(R)"
SKIP=all $(BUMP_CMD) minor
patch: _check_branch
@echo "$(T)🚀 Bumping patch...$(R)"
SKIP=all $(BUMP_CMD) patch
prune: check-docker
@echo "$(T)🗑️ Pruning Docker resources...$(R)"
docker system prune -af
@echo "✅ Docker cleaned."
ps: check-docker
@echo "$(T)📋 Container status:$(R)"
@$(COMPOSE_CMD) ps
restart: check-docker
@echo "$(T)🔄 Restarting containers...$(R)"
$(COMPOSE_CMD) restart
@echo "✅ Containers restarted."
run: check-runner
$(RUNNER_RUN) $(ARGS)
shell: check-docker
@echo "$(T)🐚 Opening shell in $(SERVICE_NAME)...$(R)"
$(COMPOSE_CMD) exec $(SERVICE_NAME) /bin/sh
test: check-runner
@echo "$(T)🧪 Running tests...$(R)"
$(RUNNER_RUN) pytest $(ARGS)
up: check-docker
@echo "$(T)🚀 Starting Agent Media...$(R)"
$(COMPOSE_CMD) up -d
@echo "✅ System is up."
update: check-runner
@echo "$(T)🔄 Updating dependencies...$(R)"
$(RUNNER_UPDATE)
@echo "✅ All packages up to date."
_check_branch:
@curr=$$(git rev-parse --abbrev-ref HEAD); \
if [ "$$curr" != "main" ]; then \
echo "❌ Error: not on the main branch"; exit 1; \
fi
_ci-image-name:
@echo "IMAGE_NAME=$(IMAGE_NAME)"
_ci-run-tests: build-test
@echo "$(T)🧪 Running tests in Docker...$(R)"
docker run --rm \
-e DEEPSEEK_API_KEY \
-e TMDB_API_KEY \
$(IMAGE_NAME):test pytest
@echo "✅ Tests passed."

View File

@@ -1,129 +0,0 @@
# agent/agent.py
from typing import Any, Dict, List
import json
from .llm import DeepSeekClient
from .memory import Memory
from .registry import make_tools, Tool
from .prompts import PromptBuilder
class Agent:
def __init__(self, llm: DeepSeekClient, memory: Memory, max_tool_iterations: int = 5):
self.llm = llm
self.memory = memory
self.tools: Dict[str, Tool] = make_tools(memory)
self.prompt_builder = PromptBuilder(self.tools)
self.max_tool_iterations = max_tool_iterations
def _parse_intent(self, text: str) -> Dict[str, Any] | None:
try:
data = json.loads(text)
except json.JSONDecodeError:
return None
if not isinstance(data, dict):
return None
action = data.get("action")
if not isinstance(action, dict):
return None
name = action.get("name")
if not isinstance(name, str):
return None
return data
def _execute_action(self, intent: Dict[str, Any]) -> Dict[str, Any]:
action = intent["action"]
name: str = action["name"]
args: Dict[str, Any] = action.get("args", {}) or {}
tool = self.tools.get(name)
if not tool:
return {"error": "unknown_tool", "tool": name}
try:
result = tool.func(**args)
except TypeError as e:
# Mauvais arguments
return {"error": "bad_args", "message": str(e)}
return result
def step(self, user_input: str) -> str:
"""
Execute one agent step with iterative tool execution:
- Build system prompt
- Query LLM
- Loop: If JSON intent -> execute tool, add result to conversation, query LLM again
- Continue until LLM responds with text (no tool call) or max iterations reached
- Return final text response
"""
print("Starting a new step...")
print("User input:", user_input)
print("Current memory state:", self.memory.data)
# Build system prompt using PromptBuilder
system_prompt = self.prompt_builder.build_system_prompt(self.memory.data)
# Initialize conversation with user input
messages: List[Dict[str, Any]] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_input},
]
# Tool execution loop
iteration = 0
while iteration < self.max_tool_iterations:
print(f"\n--- Iteration {iteration + 1} ---")
# Get LLM response
llm_response = self.llm.complete(messages)
print("LLM response:", llm_response)
# Try to parse as tool intent
intent = self._parse_intent(llm_response)
if not intent:
# No tool call - this is the final text response
print("No tool intent detected, returning final response")
# Save to history
self.memory.append_history("user", user_input)
self.memory.append_history("assistant", llm_response)
return llm_response
# Tool call detected - execute it
print("Intent detected:", intent)
tool_result = self._execute_action(intent)
print("Tool result:", tool_result)
# Add assistant's tool call and result to conversation
messages.append({
"role": "assistant",
"content": json.dumps(intent, ensure_ascii=False)
})
messages.append({
"role": "user",
"content": json.dumps(
{"tool_result": tool_result},
ensure_ascii=False
)
})
iteration += 1
# Max iterations reached - ask LLM for final response
print(f"\n--- Max iterations ({self.max_tool_iterations}) reached, requesting final response ---")
messages.append({
"role": "user",
"content": "Merci pour ces résultats. Peux-tu maintenant me donner une réponse finale en texte naturel ?"
})
final_response = self.llm.complete(messages)
# Save to history
self.memory.append_history("user", user_input)
self.memory.append_history("assistant", final_response)
return final_response

View File

@@ -1,57 +0,0 @@
"""API clients module."""
from .themoviedb import (
TMDBClient,
tmdb_client,
TMDBError,
TMDBConfigurationError,
TMDBAPIError,
TMDBNotFoundError,
MediaResult
)
from .knaben import (
KnabenClient,
knaben_client,
KnabenError,
KnabenConfigurationError,
KnabenAPIError,
KnabenNotFoundError,
TorrentResult
)
from .qbittorrent import (
QBittorrentClient,
qbittorrent_client,
QBittorrentError,
QBittorrentConfigurationError,
QBittorrentAPIError,
QBittorrentAuthError,
TorrentInfo
)
__all__ = [
# TMDB
'TMDBClient',
'tmdb_client',
'TMDBError',
'TMDBConfigurationError',
'TMDBAPIError',
'TMDBNotFoundError',
'MediaResult',
# Knaben
'KnabenClient',
'knaben_client',
'KnabenError',
'KnabenConfigurationError',
'KnabenAPIError',
'KnabenNotFoundError',
'TorrentResult',
# qBittorrent
'QBittorrentClient',
'qbittorrent_client',
'QBittorrentError',
'QBittorrentConfigurationError',
'QBittorrentAPIError',
'QBittorrentAuthError',
'TorrentInfo'
]

View File

@@ -1,2 +0,0 @@
"""LLM client module."""
from .deepseek import DeepSeekClient

View File

@@ -1,86 +0,0 @@
# agent/memory.py
from pathlib import Path
from typing import Any, Dict
import json
from .config import settings
from .parameters import validate_parameter, get_parameter_schema
class Memory:
"""
Generic memory storage for agent state.
Provides a simple key-value store that persists to JSON.
"""
def __init__(self, path: str = "memory.json"):
self.file = Path(path)
self.data: Dict[str, Any] = {}
self.load()
def load(self) -> None:
"""Load memory from file or initialize with defaults."""
if self.file.exists():
try:
self.data = json.loads(self.file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, IOError) as e:
print(f"Warning: Could not load memory file: {e}")
self.data = {
"config": {},
"tv_shows": [],
"history": [],
}
else:
self.data = {
"config": {},
"tv_shows": [],
"history": [],
}
def save(self) -> None:
self.file.write_text(
json.dumps(self.data, indent=2, ensure_ascii=False),
encoding="utf-8",
)
def get(self, key: str, default: Any = None) -> Any:
"""Get a value from memory by key."""
return self.data.get(key, default)
def set(self, key: str, value: Any) -> None:
"""
Set a value in memory and save.
Validates the value against the parameter schema if one exists.
"""
# Validate if schema exists
is_valid, error_msg = validate_parameter(key, value)
if not is_valid:
print(f'Validation failed for {key}: {error_msg}')
raise ValueError(f"Invalid value for {key}: {error_msg}")
print(f'Setting {key} in memory to: {value}')
self.data[key] = value
self.save()
def has(self, key: str) -> bool:
"""Check if a key exists and has a non-None value."""
return key in self.data and self.data[key] is not None
def append_history(self, role: str, content: str) -> None:
"""
Append a message to conversation history.
Args:
role: Message role ('user' or 'assistant')
content: Message content
"""
if "history" not in self.data:
self.data["history"] = []
self.data["history"].append({
"role": role,
"content": content
})
self.save()

View File

@@ -1,2 +0,0 @@
"""Models module."""
from .tv_show import TVShow, ShowStatus, validate_tv_shows_structure

View File

@@ -1,58 +0,0 @@
"""TV Show models and validation."""
from dataclasses import dataclass
from enum import Enum
from typing import Any
class ShowStatus(Enum):
"""Status of a TV show - whether it's still airing or has ended."""
ONGOING = "ongoing"
ENDED = "ended"
@dataclass
class TVShow:
"""Represents a TV show."""
imdb_id: str
title: str
seasons_count: int
status: ShowStatus # ongoing or ended
def validate_tv_shows_structure(tv_shows: Any) -> bool:
"""
Validate the structure of the tv_shows parameter.
Expected structure: list of TV show objects
[
{
"imdb_id": str,
"title": str,
"seasons_count": int,
"status": str # "ongoing" or "ended"
}
]
"""
if not isinstance(tv_shows, list):
return False
for show in tv_shows:
if not isinstance(show, dict):
return False
# Check required fields
required_fields = {"imdb_id", "title", "seasons_count", "status"}
if not all(field in show for field in required_fields):
return False
# Validate field types
if not isinstance(show["imdb_id"], str):
return False
if not isinstance(show["title"], str):
return False
if not isinstance(show["seasons_count"], int):
return False
if show["status"] not in ["ongoing", "ended"]:
return False
return True

View File

@@ -1,88 +0,0 @@
# agent/prompts.py
from typing import Dict, Any
import json
from .registry import Tool
from .parameters import format_parameters_for_prompt, get_missing_required_parameters
class PromptBuilder:
"""Handles construction of system prompts for the agent."""
def __init__(self, tools: Dict[str, Tool]):
self.tools = tools
def _format_tools_description(self) -> str:
"""Format tools with their descriptions and parameters."""
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 _build_context(self, memory_data: dict) -> Dict[str, Any]:
"""Build the context object with current state from memory."""
return memory_data
def build_system_prompt(self, memory_data: dict) -> str:
"""
Build the system prompt with context provided as JSON.
Args:
memory_data: The full memory data dictionary
Returns:
The complete system prompt string
"""
context = self._build_context(memory_data)
tools_desc = self._format_tools_description()
params_desc = format_parameters_for_prompt()
# Check for missing required parameters
missing_params = get_missing_required_parameters(memory_data)
missing_info = ""
if missing_params:
missing_info = "\n\n⚠️ MISSING REQUIRED PARAMETERS:\n"
for param in missing_params:
missing_info += f"- {param.key}: {param.description}\n"
missing_info += f" Why needed: {param.why_needed}\n"
return (
"You are an AI agent helping a user manage their local media library.\n\n"
f"{params_desc}\n\n"
"CURRENT CONTEXT (JSON):\n"
f"{json.dumps(context, indent=2, ensure_ascii=False)}\n"
f"{missing_info}\n"
"IMPORTANT RULES:\n"
"1. Check the REQUIRED PARAMETERS section above to understand what information you need.\n"
"2. If any required parameter is missing (shown in MISSING REQUIRED PARAMETERS), "
"you MUST ask the user for it and explain WHY you need it based on the parameter description.\n"
"3. To use a tool, respond STRICTLY with this JSON format:\n"
' { "thought": "explanation", "action": { "name": "tool_name", "args": { "arg": "value" } } }\n'
" - No text before or after the JSON\n"
" - All args must be complete and non-null\n"
"4. You can use MULTIPLE TOOLS IN SEQUENCE:\n"
" - After executing a tool, you will receive its result\n"
" - You can then decide to use another tool based on the result\n"
" - Or provide a final text response to the user\n"
" - Continue using tools until you have all the information needed\n"
"5. If you respond with text (not using a tool), respond normally in French.\n"
"6. When you have all the information needed, provide a final response in NATURAL TEXT (not JSON).\n"
"7. Extract the relevant information from the user's request and pass it as tool arguments.\n"
"\n"
"EXAMPLES:\n"
" To set the download folder:\n"
' { "thought": "User provided download path", "action": { "name": "set_path", "args": { "path_type": "download_folder", "path_value": "/home/user/downloads" } } }\n'
"\n"
" To set the TV show folder:\n"
' { "thought": "User provided TV show path", "action": { "name": "set_path", "args": { "path_type": "tvshow_folder", "path_value": "/home/user/media/tvshows" } } }\n'
"\n"
" To list the download folder:\n"
' { "thought": "User wants to see downloads", "action": { "name": "list_folder", "args": { "folder_type": "download", "path": "." } } }\n'
"\n"
" To list a subfolder in TV shows:\n"
' { "thought": "User wants to see a specific show", "action": { "name": "list_folder", "args": { "folder_type": "tvshow", "path": "Game.of.Thrones" } } }\n'
"\n"
"AVAILABLE TOOLS:\n"
f"{tools_desc}\n"
)

View File

@@ -1,123 +0,0 @@
"""Tool registry and definitions."""
from dataclasses import dataclass
from typing import Callable, Any, Dict
from functools import partial
from .memory import Memory
from .tools.filesystem import set_path_for_folder, list_folder
from .tools.api import find_media_imdb_id, find_torrent, add_torrent_to_qbittorrent
@dataclass
class Tool:
"""Represents a tool that can be used by the agent."""
name: str
description: str
func: Callable[..., Dict[str, Any]]
parameters: Dict[str, Any] # JSON Schema des paramètres
def make_tools(memory: Memory) -> Dict[str, Tool]:
"""
Create all available tools with memory bound to them.
Args:
memory: Memory instance to be used by the tools
Returns:
Dictionary mapping tool names to Tool instances
"""
# Create partial functions with memory pre-bound for filesystem tools
set_path_func = partial(set_path_for_folder, memory)
list_folder_func = partial(list_folder, memory)
tools = [
Tool(
name="set_path_for_folder",
description="Sets a path in the configuration (download_folder, tvshow_folder, movie_folder, or torrent_folder).",
func=set_path_func,
parameters={
"type": "object",
"properties": {
"folder_name": {
"type": "string",
"description": "Name of folder to set",
"enum": ["download", "tvshow", "movie", "torrent"]
},
"path_value": {
"type": "string",
"description": "Absolute path to the folder (e.g., /home/user/downloads)"
}
},
"required": ["folder_name", "path_value"]
}
),
Tool(
name="list_folder",
description="Lists the contents of a specified folder (download, tvshow, movie, or torrent).",
func=list_folder_func,
parameters={
"type": "object",
"properties": {
"folder_type": {
"type": "string",
"description": "Type of folder to list: 'download', 'tvshow', 'movie', or 'torrent'",
"enum": ["download", "tvshow", "movie", "torrent"]
},
"path": {
"type": "string",
"description": "Relative path within the folder (default: '.' for root)",
"default": "."
}
},
"required": ["folder_type"]
}
),
Tool(
name="find_media_imdb_id",
description="Finds the IMDb ID for a given media title using TMDB API.",
func=find_media_imdb_id,
parameters={
"type": "object",
"properties": {
"media_title": {
"type": "string",
"description": "Title of the media to find the IMDb ID for"
},
},
"required": ["media_title"]
}
),
Tool(
name="find_torrents",
description="Finds torrents for a given media title using Knaben API.",
func=find_torrent,
parameters={
"type": "object",
"properties": {
"media_title": {
"type": "string",
"description": "Title of the media to find torrents for"
},
},
"required": ["media_title"]
}
),
Tool(
name="add_torrent_to_qbittorrent",
description="Adds a torrent to qBittorrent client.",
func=add_torrent_to_qbittorrent,
parameters={
"type": "object",
"properties": {
"magnet_link": {
"type": "string",
"description": "Title of the media to find torrents for"
},
},
"required": ["magnet_link"]
}
),
]
return {t.name: t for t in tools}

View File

@@ -1,3 +0,0 @@
"""Tools module - filesystem and API tools."""
from .filesystem import FolderName, set_path_for_folder, list_folder
from .api import find_media_imdb_id

View File

@@ -1,224 +0,0 @@
"""API tools for interacting with external services."""
from typing import Dict, Any
import logging
from ..api import tmdb_client, TMDBError, TMDBNotFoundError, TMDBAPIError, TMDBConfigurationError
from ..api.knaben import knaben_client, KnabenError, KnabenNotFoundError, KnabenAPIError
from ..api.qbittorrent import qbittorrent_client, QBittorrentError, QBittorrentAuthError, QBittorrentAPIError
logger = logging.getLogger(__name__)
def find_media_imdb_id(media_title: str) -> Dict[str, Any]:
"""
Find the IMDb ID for a given media title using TMDB API.
This is a wrapper around the TMDB client that returns a standardized
dict format for compatibility with the agent's tool system.
Args:
media_title: Title of the media to search for
Returns:
Dict with IMDb ID or error information:
- Success: {"status": "ok", "imdb_id": str, "title": str, ...}
- Error: {"error": str, "message": str}
Example:
>>> result = find_media_imdb_id("Inception")
>>> print(result)
{'status': 'ok', 'imdb_id': 'tt1375666', 'title': 'Inception', ...}
"""
try:
# Use the TMDB client to search for media
result = 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 {
"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 {
"error": "no_imdb_id",
"message": f"No IMDb ID available for '{result.title}'",
"title": result.title,
"media_type": result.media_type,
"tmdb_id": result.tmdb_id
}
except TMDBNotFoundError as e:
logger.info(f"Media not found: {e}")
return {
"error": "not_found",
"message": str(e)
}
except TMDBConfigurationError as e:
logger.error(f"TMDB configuration error: {e}")
return {
"error": "configuration_error",
"message": str(e)
}
except TMDBAPIError as e:
logger.error(f"TMDB API error: {e}")
return {
"error": "api_error",
"message": str(e)
}
except ValueError as e:
logger.error(f"Validation error: {e}")
return {
"error": "validation_failed",
"message": str(e)
}
def find_torrent(media_title: str) -> Dict[str, Any]:
"""
Find torrents for a given media title using Knaben API.
This is a wrapper around the Knaben client that returns a standardized
dict format for compatibility with the agent's tool system.
Args:
media_title: Title of the media to search for
Returns:
Dict with torrent information or error details:
- Success: {"status": "ok", "torrents": List[Dict[str, Any]]}
- Error: {"error": str, "message": str}
"""
try:
# Search for torrents
results = knaben_client.search(media_title, limit=10)
if not results:
logger.info(f"No torrents found for '{media_title}'")
return {
"error": "not_found",
"message": f"No torrents found for '{media_title}'"
}
# Convert to dict format
torrents = []
for torrent in results:
torrents.append({
"name": torrent.title,
"size": torrent.size,
"seeders": torrent.seeders,
"leechers": torrent.leechers,
"magnet": torrent.magnet,
"info_hash": torrent.info_hash,
"tracker": torrent.tracker,
"upload_date": torrent.upload_date,
"category": torrent.category
})
logger.info(f"Found {len(torrents)} torrents for '{media_title}'")
return {
"status": "ok",
"torrents": torrents,
"count": len(torrents)
}
except KnabenNotFoundError as e:
logger.info(f"Torrents not found: {e}")
return {
"error": "not_found",
"message": str(e)
}
except KnabenAPIError as e:
logger.error(f"Knaben API error: {e}")
return {
"error": "api_error",
"message": str(e)
}
except ValueError as e:
logger.error(f"Validation error: {e}")
return {
"error": "validation_failed",
"message": str(e)
}
def add_torrent_to_qbittorrent(magnet_link: str) -> Dict[str, Any]:
"""
Add a torrent to qBittorrent using a magnet link.
This is a wrapper around the qBittorrent client that returns a standardized
dict format for compatibility with the agent's tool system.
Args:
magnet_link: Magnet link of the torrent to add
Returns:
Dict with success or error information:
- Success: {"status": "ok", "message": str}
- Error: {"error": str, "message": str}
Example:
>>> result = add_torrent_to_qbittorrent("magnet:?xt=urn:btih:...")
>>> print(result)
{'status': 'ok', 'message': 'Torrent added successfully'}
"""
try:
# Validate magnet link
if not magnet_link or not isinstance(magnet_link, str):
raise ValueError("Magnet link must be a non-empty string")
if not magnet_link.startswith("magnet:"):
raise ValueError("Invalid magnet link format")
logger.info("Adding torrent to qBittorrent")
# Add torrent to qBittorrent
success = qbittorrent_client.add_torrent(magnet_link)
if success:
logger.info("Torrent added successfully to qBittorrent")
return {
"status": "ok",
"message": "Torrent added successfully to qBittorrent"
}
else:
logger.warning("Failed to add torrent to qBittorrent")
return {
"error": "add_failed",
"message": "Failed to add torrent to qBittorrent"
}
except QBittorrentAuthError as e:
logger.error(f"qBittorrent authentication error: {e}")
return {
"error": "authentication_failed",
"message": "Failed to authenticate with qBittorrent"
}
except QBittorrentAPIError as e:
logger.error(f"qBittorrent API error: {e}")
return {
"error": "api_error",
"message": str(e)
}
except ValueError as e:
logger.error(f"Validation error: {e}")
return {
"error": "validation_failed",
"message": str(e)
}

View File

@@ -1,448 +0,0 @@
"""Filesystem tools for managing folders and files with security."""
from typing import Dict, Any
from enum import Enum
from pathlib import Path
import logging
import os
from ..memory import Memory
logger = logging.getLogger(__name__)
class FolderName(Enum):
"""Types of folders that can be managed."""
DOWNLOAD = "download"
TVSHOW = "tvshow"
MOVIE = "movie"
TORRENT = "torrent"
class FilesystemError(Exception):
"""Base exception for filesystem operations."""
pass
class PathTraversalError(FilesystemError):
"""Raised when path traversal attack is detected."""
pass
def _validate_folder_name(folder_name: str) -> bool:
"""
Validate folder name against allowed values.
Args:
folder_name: Name to validate
Returns:
True if valid
Raises:
ValueError: If folder name is invalid
"""
valid_names = [fn.value for fn in FolderName]
if folder_name not in valid_names:
raise ValueError(
f"Invalid folder_name '{folder_name}'. Must be one of: {', '.join(valid_names)}"
)
return True
def _sanitize_path(path: str) -> str:
"""
Sanitize path to prevent path traversal attacks.
Args:
path: Path to sanitize
Returns:
Sanitized path
Raises:
PathTraversalError: If path contains dangerous patterns
"""
# Normalize path
normalized = os.path.normpath(path)
# Check for absolute paths
if os.path.isabs(normalized):
raise PathTraversalError("Absolute paths are not allowed")
# Check for parent directory references
if normalized.startswith("..") or "/.." in normalized or "\\.." in normalized:
raise PathTraversalError("Parent directory references are not allowed")
# Check for null bytes
if "\x00" in normalized:
raise PathTraversalError("Null bytes in path are not allowed")
return normalized
def _is_safe_path(base_path: Path, target_path: Path) -> bool:
"""
Check if target path is within base path (prevents path traversal).
Args:
base_path: Base directory path
target_path: Target path to check
Returns:
True if safe, False otherwise
"""
try:
# Resolve both paths to absolute paths
base_resolved = base_path.resolve()
target_resolved = target_path.resolve()
# Check if target is relative to base
target_resolved.relative_to(base_resolved)
return True
except (ValueError, OSError):
return False
def set_path_for_folder(memory: Memory, folder_name: str, path_value: str) -> Dict[str, Any]:
"""
Set a path in the config with validation.
Args:
memory: Memory instance to store the configuration
folder_name: Name of folder to set (download, tvshow, movie, torrent)
path_value: Absolute path to the folder
Returns:
Dict with status or error information
"""
try:
# Validate folder name
_validate_folder_name(folder_name)
# Convert to Path object for better handling
path_obj = Path(path_value).resolve()
# Validate path exists and is a directory
if not path_obj.exists():
logger.warning(f"Path does not exist: {path_value}")
return {
"error": "invalid_path",
"message": f"Path does not exist: {path_value}"
}
if not path_obj.is_dir():
logger.warning(f"Path is not a directory: {path_value}")
return {
"error": "invalid_path",
"message": f"Path is not a directory: {path_value}"
}
# Check if path is readable
if not os.access(path_obj, os.R_OK):
logger.warning(f"Path is not readable: {path_value}")
return {
"error": "permission_denied",
"message": f"Path is not readable: {path_value}"
}
# Store in memory
config = memory.get("config", {})
config[f"{folder_name}_folder"] = str(path_obj)
memory.set("config", config)
logger.info(f"Set {folder_name}_folder to: {path_obj}")
return {
"status": "ok",
"folder_name": folder_name,
"path": str(path_obj)
}
except ValueError as e:
logger.error(f"Validation error: {e}")
return {"error": "validation_failed", "message": str(e)}
except Exception as e:
logger.error(f"Unexpected error setting path: {e}", exc_info=True)
return {"error": "internal_error", "message": "Failed to set path"}
def list_folder(memory: Memory, folder_type: str, path: str = ".") -> Dict[str, Any]:
"""
List contents of a folder with security checks.
Args:
memory: Memory instance to retrieve the configuration
folder_type: Type of folder to list (download, tvshow, movie, torrent)
path: Relative path within the folder (default: ".")
Returns:
Dict with folder contents or error information
"""
try:
# Validate folder type
_validate_folder_name(folder_type)
# Sanitize the path
safe_path = _sanitize_path(path)
# Get root folder from config
folder_key = f"{folder_type}_folder"
config = memory.get("config", {})
if folder_key not in config or not config[folder_key]:
logger.warning(f"Folder not configured: {folder_type}")
return {
"error": "folder_not_set",
"message": f"{folder_type.capitalize()} folder not set in config."
}
root = Path(config[folder_key])
target = root / safe_path
# Security check: ensure target is within root
if not _is_safe_path(root, target):
logger.warning(f"Path traversal attempt detected: {path}")
return {
"error": "forbidden",
"message": "Access denied: path outside allowed directory"
}
# Check if target exists
if not target.exists():
logger.warning(f"Path does not exist: {target}")
return {
"error": "not_found",
"message": f"Path does not exist: {safe_path}"
}
# Check if target is a directory
if not target.is_dir():
logger.warning(f"Path is not a directory: {target}")
return {
"error": "not_a_directory",
"message": f"Path is not a directory: {safe_path}"
}
# List directory contents
try:
entries = [entry.name for entry in target.iterdir()]
logger.debug(f"Listed {len(entries)} entries in {target}")
return {
"status": "ok",
"folder_type": folder_type,
"path": safe_path,
"entries": sorted(entries),
"count": len(entries)
}
except PermissionError:
logger.warning(f"Permission denied accessing: {target}")
return {
"error": "permission_denied",
"message": f"Permission denied accessing: {safe_path}"
}
except PathTraversalError as e:
logger.warning(f"Path traversal attempt: {e}")
return {
"error": "forbidden",
"message": str(e)
}
except ValueError as e:
logger.error(f"Validation error: {e}")
return {"error": "validation_failed", "message": str(e)}
except Exception as e:
logger.error(f"Unexpected error listing folder: {e}", exc_info=True)
return {"error": "internal_error", "message": "Failed to list folder"}
def move_file(path: str, destination: str) -> Dict[str, Any]:
"""
Move a file from one location to another with safety checks.
This function is designed to safely move files from downloads to movies/series
folders with comprehensive validation and error handling to prevent data loss.
Args:
path: Source file path (absolute or relative)
destination: Destination file path (absolute or relative)
Returns:
Dict with status or error information:
- Success: {"status": "ok", "source": str, "destination": str, "size": int}
- Error: {"error": str, "message": str}
Safety features:
- Validates source file exists and is readable
- Validates destination directory exists and is writable
- Prevents overwriting existing files
- Verifies file integrity after move (size check)
- Atomic operation using shutil.move
- Comprehensive logging
Example:
>>> result = move_file(
... "/downloads/movie.mkv",
... "/movies/Inception (2010)/movie.mkv"
... )
>>> print(result)
{'status': 'ok', 'source': '...', 'destination': '...', 'size': 1234567890}
"""
import shutil
try:
# Convert to Path objects
source_path = Path(path).resolve()
dest_path = Path(destination).resolve()
logger.info(f"Moving file from {source_path} to {dest_path}")
# === VALIDATION: Source file ===
# Check source exists
if not source_path.exists():
logger.error(f"Source file does not exist: {source_path}")
return {
"error": "source_not_found",
"message": f"Source file does not exist: {path}"
}
# Check source is a file (not a directory)
if not source_path.is_file():
logger.error(f"Source is not a file: {source_path}")
return {
"error": "source_not_file",
"message": f"Source is not a file: {path}"
}
# Check source is readable
if not os.access(source_path, os.R_OK):
logger.error(f"Source file is not readable: {source_path}")
return {
"error": "permission_denied",
"message": f"Source file is not readable: {path}"
}
# Get source file size for verification
source_size = source_path.stat().st_size
logger.debug(f"Source file size: {source_size} bytes")
# === VALIDATION: Destination ===
# Check destination parent directory exists
dest_parent = dest_path.parent
if not dest_parent.exists():
logger.error(f"Destination directory does not exist: {dest_parent}")
return {
"error": "destination_dir_not_found",
"message": f"Destination directory does not exist: {dest_parent}"
}
# Check destination parent is a directory
if not dest_parent.is_dir():
logger.error(f"Destination parent is not a directory: {dest_parent}")
return {
"error": "destination_not_dir",
"message": f"Destination parent is not a directory: {dest_parent}"
}
# Check destination parent is writable
if not os.access(dest_parent, os.W_OK):
logger.error(f"Destination directory is not writable: {dest_parent}")
return {
"error": "permission_denied",
"message": f"Destination directory is not writable: {dest_parent}"
}
# Check destination file doesn't already exist
if dest_path.exists():
logger.warning(f"Destination file already exists: {dest_path}")
return {
"error": "destination_exists",
"message": f"Destination file already exists: {destination}"
}
# === SAFETY CHECK: Prevent moving to same location ===
if source_path == dest_path:
logger.warning("Source and destination are the same")
return {
"error": "same_location",
"message": "Source and destination are the same"
}
# === PERFORM MOVE ===
logger.info(f"Moving file: {source_path.name} ({source_size} bytes)")
try:
# Use shutil.move for atomic operation
# This handles cross-filesystem moves automatically
shutil.move(str(source_path), str(dest_path))
logger.info(f"File moved successfully to {dest_path}")
except Exception as e:
logger.error(f"Failed to move file: {e}", exc_info=True)
return {
"error": "move_failed",
"message": f"Failed to move file: {str(e)}"
}
# === VERIFICATION: Ensure file was moved correctly ===
# Check destination file exists
if not dest_path.exists():
logger.error("Destination file does not exist after move!")
# Try to recover by checking if source still exists
if source_path.exists():
logger.info("Source file still exists, move may have failed")
return {
"error": "move_verification_failed",
"message": "File was not moved successfully (destination not found)"
}
else:
logger.critical("Both source and destination missing after move!")
return {
"error": "file_lost",
"message": "CRITICAL: File missing after move operation"
}
# Check destination file size matches source
dest_size = dest_path.stat().st_size
if dest_size != source_size:
logger.error(f"File size mismatch! Source: {source_size}, Dest: {dest_size}")
return {
"error": "size_mismatch",
"message": f"File size mismatch after move (expected {source_size}, got {dest_size})"
}
# Check source file no longer exists
if source_path.exists():
logger.warning("Source file still exists after move (copy instead of move?)")
# This is not necessarily an error (shutil.move copies across filesystems)
# but we should log it
# === SUCCESS ===
logger.info(f"File successfully moved and verified: {dest_path.name}")
return {
"status": "ok",
"source": str(source_path),
"destination": str(dest_path),
"filename": dest_path.name,
"size": dest_size
}
except PermissionError as e:
logger.error(f"Permission denied: {e}")
return {
"error": "permission_denied",
"message": f"Permission denied: {str(e)}"
}
except OSError as e:
logger.error(f"OS error during move: {e}", exc_info=True)
return {
"error": "os_error",
"message": f"OS error: {str(e)}"
}

90
app.py
View File

@@ -1,90 +0,0 @@
# app.py
import time
import uuid
import json
from typing import Any, Dict
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, StreamingResponse
from agent.llm.deepseek import DeepSeekClient
from agent.memory import Memory
from agent.agent import Agent
app = FastAPI(
title="LibreChat Agent Backend",
version="0.1.0",
)
llm = DeepSeekClient()
memory = Memory()
agent = Agent(llm=llm, memory=memory)
def extract_last_user_content(messages: list[Dict[str, Any]]) -> str:
last = ""
for m in reversed(messages):
if m.get("role") == "user":
last = m.get("content") or ""
break
return last
@app.post("/v1/chat/completions")
async def chat_completions(request: Request):
body = await request.json()
model = body.get("model", "local-deepseek-agent")
messages = body.get("messages", [])
stream = body.get("stream", False)
user_input = extract_last_user_content(messages)
print("Received chat completion request, stream =", stream, "input:", user_input)
# Process user input through the agent
answer = agent.step(user_input)
# Ensuite = même logique de réponse (non-stream ou stream)
created_ts = int(time.time())
completion_id = f"chatcmpl-{uuid.uuid4().hex}"
if not stream:
resp = {
"id": completion_id,
"object": "chat.completion",
"created": created_ts,
"model": model,
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": answer or "",
},
}
],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
}
return JSONResponse(resp)
async def event_generator():
chunk = {
"id": completion_id,
"object": "chat.completion.chunk",
"created": created_ts,
"model": model,
"choices": [
{
"index": 0,
"delta": {
"role": "assistant",
"content": answer or "",
},
"finish_reason": "stop",
}
],
}
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")

18
brain/.bumpversion.toml Normal file
View File

@@ -0,0 +1,18 @@
[tool.bumpversion]
current_version = "0.1.2"
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
serialize = ["{major}.{minor}.{patch}"]
search = "{current_version}"
replace = "{new_version}"
regex = false
ignore_missing_version = false
tag = true
sign_tags = false
tag_name = "v{new_version}"
tag_message = "Bump version: {current_version} → {new_version}"
allow_dirty = false
commit = true
message = "chore: bump version {current_version} → {new_version}"
[[tool.bumpversion.files]]
filename = "pyproject.toml"

57
brain/.dockerignore Normal file
View File

@@ -0,0 +1,57 @@
# Git
.git
.gitignore
.gitea
# Python
__pycache__
*.pyc
*.pyo
*.pyd
.Python
*.so
.pytest_cache
.coverage
htmlcov
.tox
.nox
.hypothesis
# Virtual environments
venv
.venv
env
.env
.env.*
# IDE
.vscode
.idea
*.swp
*.swo
.qodo
# Build
build
dist
*.egg-info
# Documentation
docs/
*.md
!README.md
# Tests
tests/
pytest.ini
# Data (will be mounted as volumes)
memory_data/
logs/
*.log
# Misc
*.bak
*.tmp
.DS_Store
Thumbs.db

122
brain/Dockerfile Normal file
View File

@@ -0,0 +1,122 @@
# Dockerfile for Agent Media
# Multi-stage build for smaller image size
ARG PYTHON_VERSION
ARG PYTHON_VERSION_SHORT
ARG RUNNER
# ===========================================
# Stage 1: Builder
# ===========================================
FROM python:${PYTHON_VERSION}-slim-bookworm as builder
# Re-declare ARGs after FROM to make them available in this stage
ARG RUNNER
# STFU - No need - Write logs asap
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Install build dependencies (needs root)
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Install runner globally (needs root) - Save cache for future
RUN --mount=type=cache,target=/root/.cache/pip \
pip install $RUNNER
# Set working directory for dependency installation
WORKDIR /tmp
# Copy dependency files
COPY brain/pyproject.toml brain/poetry.lock* brain/uv.lock* Makefile ./
# Install dependencies as root (to avoid permission issues with system packages)
RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=cache,target=/root/.cache/pypoetry \
--mount=type=cache,target=/root/.cache/uv \
if [ "$RUNNER" = "poetry" ]; then \
poetry config virtualenvs.create false && \
poetry install --only main --no-root; \
elif [ "$RUNNER" = "uv" ]; then \
uv pip install --system -r pyproject.toml; \
fi
# ===========================================
# Stage 2: Testing
# ===========================================
FROM builder as test
ARG RUNNER
RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=cache,target=/root/.cache/pypoetry \
--mount=type=cache,target=/root/.cache/uv \
if [ "$RUNNER" = "poetry" ]; then \
poetry install --no-root; \
elif [ "$RUNNER" = "uv" ]; then \
uv pip install --system -e .[dev]; \
fi
COPY brain/agent/ ./agent/
COPY brain/application/ ./application/
COPY brain/domain/ ./domain/
COPY brain/infrastructure/ ./infrastructure/
COPY brain/tests/ ./tests/
COPY brain/app.py .
# ===========================================
# Stage 3: Runtime
# ===========================================
FROM python:${PYTHON_VERSION}-slim-bookworm as runtime
ARG PYTHON_VERSION_SHORT
ENV LLM_PROVIDER=deepseek \
MEMORY_STORAGE_DIR=/data/memory \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/home/appuser/app \
PYTHONUNBUFFERED=1
# Install runtime dependencies (needs root)
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Create non-root user
RUN useradd -m -u 1000 -s /bin/bash appuser
# Create data directories (needs root for /data)
RUN mkdir -p /data/memory /data/logs \
&& chown -R appuser:appuser /data
# Switch to non-root user
USER appuser
# Set working directory (owned by appuser)
WORKDIR /home/appuser/app
# Copy Python packages from builder stage
COPY --from=builder /usr/local/lib/python${PYTHON_VERSION_SHORT}/site-packages /usr/local/lib/python${PYTHON_VERSION_SHORT}/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# Copy application code (already owned by appuser)
COPY --chown=appuser:appuser brain/agent/ ./agent/
COPY --chown=appuser:appuser brain/application/ ./application/
COPY --chown=appuser:appuser brain/domain/ ./domain/
COPY --chown=appuser:appuser brain/infrastructure/ ./infrastructure/
COPY --chown=appuser:appuser brain/app.py .
# Create volumes for persistent data
VOLUME ["/data/memory", "/data/logs"]
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/health', timeout=5).raise_for_status()" || exit 1
# Run the application
CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

400
brain/README.md Normal file
View File

@@ -0,0 +1,400 @@
# Agent Media 🎬
An AI-powered agent for managing your local media library with natural language. Search, download, and organize movies and TV shows effortlessly.
## Features
- 🤖 **Natural Language Interface**: Talk to your media library in plain language
- 🔍 **Smart Search**: Find movies and TV shows via TMDB
- 📥 **Torrent Integration**: Search and download via qBittorrent
- 🧠 **Contextual Memory**: Remembers your preferences and conversation history
- 📁 **Auto-Organization**: Keeps your media library tidy
- 🌐 **API Compatible**: OpenAI-compatible API for easy integration
## Architecture
Built with **Domain-Driven Design (DDD)** principles:
```
agent_media/
├── agent/ # AI agent orchestration
├── application/ # Use cases & DTOs
├── domain/ # Business logic & entities
└── infrastructure/ # External services & persistence
```
See [architecture_diagram.md](docs/architecture_diagram.md) for architectural details.
## Quick Start
### Prerequisites
- Python 3.12+
- Poetry
- qBittorrent (optional, for downloads)
- API Keys:
- DeepSeek API key (or Ollama for local LLM)
- TMDB API key
### Installation
```bash
# Clone the repository
git clone https://github.com/your-username/agent-media.git
cd agent-media
# Install dependencies
poetry install
# Copy environment template
cp .env.example .env
# Edit .env with your API keys
nano .env
```
### Configuration
Edit `.env`:
```bash
# LLM Provider (deepseek or ollama)
LLM_PROVIDER=deepseek
DEEPSEEK_API_KEY=your-api-key-here
# TMDB (for movie/TV show metadata)
TMDB_API_KEY=your-tmdb-key-here
# qBittorrent (optional)
QBITTORRENT_HOST=http://localhost:8080
QBITTORRENT_USERNAME=admin
QBITTORRENT_PASSWORD=adminadmin
```
### Run
```bash
# Start the API server
poetry run uvicorn app:app --reload
# Or with Docker
docker-compose up
```
The API will be available at `http://localhost:8000`
## Usage
### Via API
```bash
# Health check
curl http://localhost:8000/health
# Chat with the agent
curl -X POST http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "agent-media",
"messages": [
{"role": "user", "content": "Find Inception 1080p"}
]
}'
```
### Via OpenWebUI
Agent Media is compatible with [OpenWebUI](https://github.com/open-webui/open-webui):
1. Add as OpenAI-compatible endpoint: `http://localhost:8000/v1`
2. Model name: `agent-media`
3. Start chatting!
### Example Conversations
```
You: Find Inception in 1080p
Agent: I found 3 torrents for Inception:
1. Inception.2010.1080p.BluRay.x264 (150 seeders)
2. Inception.2010.1080p.WEB-DL.x265 (80 seeders)
3. Inception.2010.720p.BluRay (45 seeders)
You: Download the first one
Agent: Added to qBittorrent! Download started.
You: List my downloads
Agent: You have 1 active download:
- Inception.2010.1080p.BluRay.x264 (45% complete)
```
## Available Tools
The agent has access to these tools:
| Tool | Description |
|------|-------------|
| `find_media_imdb_id` | Search for movies/TV shows on TMDB |
| `find_torrents` | Search for torrents |
| `get_torrent_by_index` | Get torrent details by index |
| `add_torrent_by_index` | Download torrent by index |
| `add_torrent_to_qbittorrent` | Add torrent via magnet link |
| `set_path_for_folder` | Configure folder paths |
| `list_folder` | List folder contents |
## Memory System
Agent Media uses a three-tier memory system:
### Long-Term Memory (LTM)
- **Persistent** (saved to JSON)
- Configuration, preferences, media library
- Survives restarts
### Short-Term Memory (STM)
- **Session-based** (RAM only)
- Conversation history, current workflow
- Cleared on restart
### Episodic Memory
- **Transient** (RAM only)
- Search results, active downloads, recent errors
- Cleared frequently
## Development
### Project Structure
```
agent_media/
├── agent/
│ ├── agent.py # Main agent orchestrator
│ ├── prompts.py # System prompt builder
│ ├── registry.py # Tool registration
│ ├── tools/ # Tool implementations
│ └── llm/ # LLM clients (DeepSeek, Ollama)
├── application/
│ ├── movies/ # Movie use cases
│ ├── torrents/ # Torrent use cases
│ └── filesystem/ # Filesystem use cases
├── domain/
│ ├── movies/ # Movie entities & value objects
│ ├── tv_shows/ # TV show entities
│ ├── subtitles/ # Subtitle entities
│ └── shared/ # Shared value objects
├── infrastructure/
│ ├── api/ # External API clients
│ │ ├── tmdb/ # TMDB client
│ │ ├── knaben/ # Torrent search
│ │ └── qbittorrent/ # qBittorrent client
│ ├── filesystem/ # File operations
│ └── persistence/ # Memory & repositories
├── tests/ # Test suite (~500 tests)
└── docs/ # Documentation
```
### Running Tests
```bash
# Run all tests
poetry run pytest
# Run with coverage
poetry run pytest --cov
# Run specific test file
poetry run pytest tests/test_agent.py
# Run specific test
poetry run pytest tests/test_agent.py::TestAgent::test_step
```
### Code Quality
```bash
# Linting
poetry run ruff check .
# Formatting
poetry run black .
# Type checking (if mypy is installed)
poetry run mypy .
```
### Adding a New Tool
Quick example:
```python
# 1. Create the tool function in agent/tools/api.py
def my_new_tool(param: str) -> Dict[str, Any]:
"""Tool description."""
memory = get_memory()
# Implementation
return {"status": "ok", "data": "result"}
# 2. Register in agent/registry.py
Tool(
name="my_new_tool",
description="What this tool does",
func=api_tools.my_new_tool,
parameters={
"type": "object",
"properties": {
"param": {"type": "string", "description": "Parameter description"},
},
"required": ["param"],
},
),
```
## Docker
### Build
```bash
docker build -t agent-media .
```
### Run
```bash
docker run -p 8000:8000 \
-e DEEPSEEK_API_KEY=your-key \
-e TMDB_API_KEY=your-key \
-v $(pwd)/memory_data:/app/memory_data \
agent-media
```
### Docker Compose
```bash
# Start all services (agent + qBittorrent)
docker-compose up -d
# View logs
docker-compose logs -f
# Stop
docker-compose down
```
## API Documentation
### Endpoints
#### `GET /health`
Health check endpoint.
**Response:**
```json
{
"status": "healthy",
"version": "0.2.0"
}
```
#### `GET /v1/models`
List available models (OpenAI-compatible).
#### `POST /v1/chat/completions`
Chat with the agent (OpenAI-compatible).
**Request:**
```json
{
"model": "agent-media",
"messages": [
{"role": "user", "content": "Find Inception"}
],
"stream": false
}
```
**Response:**
```json
{
"id": "chatcmpl-xxx",
"object": "chat.completion",
"created": 1234567890,
"model": "agent-media",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "I found Inception (2010)..."
},
"finish_reason": "stop"
}]
}
```
#### `GET /memory/state`
View full memory state (debug).
#### `POST /memory/clear-session`
Clear session memories (STM + Episodic).
## Troubleshooting
### Agent doesn't respond
- Check API keys in `.env`
- Verify LLM provider is running (Ollama) or accessible (DeepSeek)
- Check logs: `docker-compose logs agent-media`
### qBittorrent connection failed
- Verify qBittorrent is running
- Check `QBITTORRENT_HOST` in `.env`
- Ensure Web UI is enabled in qBittorrent settings
### Memory not persisting
- Check `memory_data/` directory exists and is writable
- Verify volume mounts in Docker
### Tests failing
- Run `poetry install` to ensure dependencies are up to date
- Check logs for specific error messages
## Contributing
Contributions are welcome!
### Development Workflow
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/my-feature`
3. Make your changes
4. Run tests: `poetry run pytest`
5. Run linting: `poetry run ruff check . && poetry run black .`
6. Commit: `git commit -m "Add my feature"`
7. Push: `git push origin feature/my-feature`
8. Create a Pull Request
## Documentation
- [Architecture Diagram](docs/architecture_diagram.md) - System architecture overview
- [Class Diagram](docs/class_diagram.md) - Class structure and relationships
- [Component Diagram](docs/component_diagram.md) - Component interactions
- [Sequence Diagram](docs/sequence_diagram.md) - Sequence flows
- [Flowchart](docs/flowchart.md) - System flowcharts
## License
MIT License - see [LICENSE](LICENSE) file for details.
## Acknowledgments
- [DeepSeek](https://www.deepseek.com/) - LLM provider
- [TMDB](https://www.themoviedb.org/) - Movie database
- [qBittorrent](https://www.qbittorrent.org/) - Torrent client
- [FastAPI](https://fastapi.tiangolo.com/) - Web framework
## Support
- 📧 Email: francois.hodiaumont@gmail.com
- 🐛 Issues: [GitHub Issues](https://github.com/your-username/agent-media/issues)
- 💬 Discussions: [GitHub Discussions](https://github.com/your-username/agent-media/discussions)
---
Made with ❤️ by Francwa

6
brain/agent/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""Agent module for media library management."""
from .agent import Agent
from .config import settings
__all__ = ["Agent", "settings"]

371
brain/agent/agent.py Normal file
View File

@@ -0,0 +1,371 @@
"""Main agent for media library management."""
import json
import logging
from collections.abc import AsyncGenerator
from typing import Any
from infrastructure.persistence import get_memory
from .config import settings
from .prompts import PromptBuilder
from .registry import Tool, make_tools
logger = logging.getLogger(__name__)
class Agent:
"""
AI agent for media library management.
Uses OpenAI-compatible tool calling API.
"""
def __init__(self, llm, max_tool_iterations: int = 5):
"""
Initialize the agent.
Args:
llm: LLM client with complete() method
max_tool_iterations: Maximum number of tool execution iterations
"""
self.llm = llm
self.tools: dict[str, Tool] = make_tools()
self.prompt_builder = PromptBuilder(self.tools)
self.max_tool_iterations = max_tool_iterations
def step(self, user_input: str) -> str:
"""
Execute one agent step with the user input.
This method:
1. Adds user message to memory
2. Builds prompt with history and context
3. Calls LLM, executing tools as needed
4. Returns final response
Args:
user_input: User's message
Returns:
Agent's final response
"""
memory = get_memory()
# Add user message to history
memory.stm.add_message("user", user_input)
memory.save()
# Build initial messages
system_prompt = self.prompt_builder.build_system_prompt()
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
# Add conversation history
history = memory.stm.get_recent_history(settings.max_history_messages)
messages.extend(history)
# Add unread events if any
unread_events = memory.episodic.get_unread_events()
if unread_events:
events_text = "\n".join(
[f"- {e['type']}: {e['data']}" for e in unread_events]
)
messages.append(
{"role": "system", "content": f"Background events:\n{events_text}"}
)
# Get tools specification for OpenAI format
tools_spec = self.prompt_builder.build_tools_spec()
# Tool execution loop
for _iteration in range(self.max_tool_iterations):
# Call LLM with tools
llm_result = self.llm.complete(messages, tools=tools_spec)
# Handle both tuple (response, usage) and dict response
if isinstance(llm_result, tuple):
response_message, usage = llm_result
else:
response_message = llm_result
# Check if there are tool calls
tool_calls = response_message.get("tool_calls")
if not tool_calls:
# No tool calls, this is the final response
final_content = response_message.get("content", "")
memory.stm.add_message("assistant", final_content)
memory.save()
return final_content
# Add assistant message with tool calls to conversation
messages.append(response_message)
# Execute each tool call
for tool_call in tool_calls:
tool_result = self._execute_tool_call(tool_call)
# Add tool result to messages
messages.append(
{
"tool_call_id": tool_call.get("id"),
"role": "tool",
"name": tool_call.get("function", {}).get("name"),
"content": json.dumps(tool_result, ensure_ascii=False),
}
)
# Max iterations reached, force final response
messages.append(
{
"role": "system",
"content": "Please provide a final response to the user without using any more tools.",
}
)
llm_result = self.llm.complete(messages)
if isinstance(llm_result, tuple):
final_message, usage = llm_result
else:
final_message = llm_result
final_response = final_message.get(
"content", "I've completed the requested actions."
)
memory.stm.add_message("assistant", final_response)
memory.save()
return final_response
def _execute_tool_call(self, tool_call: dict[str, Any]) -> dict[str, Any]:
"""
Execute a single tool call.
Args:
tool_call: OpenAI-format tool call dict
Returns:
Result dictionary
"""
function = tool_call.get("function", {})
tool_name = function.get("name", "")
try:
args_str = function.get("arguments", "{}")
args = json.loads(args_str)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse tool arguments: {e}")
return {"error": "bad_args", "message": f"Invalid JSON arguments: {e}"}
# Validate tool exists
if tool_name not in self.tools:
available = list(self.tools.keys())
return {
"error": "unknown_tool",
"message": f"Tool '{tool_name}' not found",
"available_tools": available,
}
tool = self.tools[tool_name]
# Execute tool
try:
result = tool.func(**args)
return result
except KeyboardInterrupt:
# Don't catch KeyboardInterrupt - let it propagate
raise
except TypeError as e:
# Bad arguments
memory = get_memory()
memory.episodic.add_error(tool_name, f"bad_args: {e}")
return {"error": "bad_args", "message": str(e), "tool": tool_name}
except Exception as e:
# Other errors
memory = get_memory()
memory.episodic.add_error(tool_name, str(e))
return {"error": "execution_failed", "message": str(e), "tool": tool_name}
async def step_streaming(
self, user_input: str, completion_id: str, created_ts: int, model: str
) -> AsyncGenerator[dict[str, Any], None]:
"""
Execute agent step with streaming support for LibreChat.
Yields SSE chunks for tool calls and final response.
Args:
user_input: User's message
completion_id: Completion ID for the response
created_ts: Timestamp for the response
model: Model name
Yields:
SSE chunks in OpenAI format
"""
memory = get_memory()
# Add user message to history
memory.stm.add_message("user", user_input)
memory.save()
# Build initial messages
system_prompt = self.prompt_builder.build_system_prompt()
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
# Add conversation history
history = memory.stm.get_recent_history(settings.max_history_messages)
messages.extend(history)
# Add unread events if any
unread_events = memory.episodic.get_unread_events()
if unread_events:
events_text = "\n".join(
[f"- {e['type']}: {e['data']}" for e in unread_events]
)
messages.append(
{"role": "system", "content": f"Background events:\n{events_text}"}
)
# Get tools specification for OpenAI format
tools_spec = self.prompt_builder.build_tools_spec()
# Tool execution loop
for _iteration in range(self.max_tool_iterations):
# Call LLM with tools
llm_result = self.llm.complete(messages, tools=tools_spec)
# Handle both tuple (response, usage) and dict response
if isinstance(llm_result, tuple):
response_message, usage = llm_result
else:
response_message = llm_result
# Check if there are tool calls
tool_calls = response_message.get("tool_calls")
if not tool_calls:
# No tool calls, this is the final response
final_content = response_message.get("content", "")
memory.stm.add_message("assistant", final_content)
memory.save()
# Stream the final response
yield {
"id": completion_id,
"object": "chat.completion.chunk",
"created": created_ts,
"model": model,
"choices": [
{
"index": 0,
"delta": {"role": "assistant", "content": final_content},
"finish_reason": "stop",
}
],
}
return
# Stream tool calls
for tool_call in tool_calls:
function = tool_call.get("function", {})
tool_name = function.get("name", "")
tool_args = function.get("arguments", "{}")
# Yield chunk indicating tool call
yield {
"id": completion_id,
"object": "chat.completion.chunk",
"created": created_ts,
"model": model,
"choices": [
{
"index": 0,
"delta": {
"tool_calls": [
{
"index": 0,
"id": tool_call.get("id"),
"type": "function",
"function": {
"name": tool_name,
"arguments": tool_args,
},
}
]
},
"finish_reason": None,
}
],
}
# Add assistant message with tool calls to conversation
messages.append(response_message)
# Execute each tool call and stream results
for tool_call in tool_calls:
tool_result = self._execute_tool_call(tool_call)
function = tool_call.get("function", {})
tool_name = function.get("name", "")
# Add tool result to messages
messages.append(
{
"tool_call_id": tool_call.get("id"),
"role": "tool",
"name": tool_name,
"content": json.dumps(tool_result, ensure_ascii=False),
}
)
# Stream tool result as content
result_text = (
f"\n🔧 {tool_name}: {json.dumps(tool_result, ensure_ascii=False)}\n"
)
yield {
"id": completion_id,
"object": "chat.completion.chunk",
"created": created_ts,
"model": model,
"choices": [
{
"index": 0,
"delta": {"content": result_text},
"finish_reason": None,
}
],
}
# Max iterations reached, force final response
messages.append(
{
"role": "system",
"content": "Please provide a final response to the user without using any more tools.",
}
)
llm_result = self.llm.complete(messages)
if isinstance(llm_result, tuple):
final_message, usage = llm_result
else:
final_message = llm_result
final_response = final_message.get(
"content", "I've completed the requested actions."
)
memory.stm.add_message("assistant", final_response)
memory.save()
# Stream final response
yield {
"id": completion_id,
"object": "chat.completion.chunk",
"created": created_ts,
"model": model,
"choices": [
{
"index": 0,
"delta": {"content": final_response},
"finish_reason": "stop",
}
],
}

View File

@@ -1,8 +1,9 @@
"""Configuration management with validation."""
from dataclasses import dataclass, field
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from dotenv import load_dotenv
# Load environment variables from .env file
@@ -11,6 +12,7 @@ load_dotenv()
class ConfigurationError(Exception):
"""Raised when configuration is invalid."""
pass
@@ -19,21 +21,46 @@ class Settings:
"""Application settings loaded from environment variables."""
# LLM Configuration
deepseek_api_key: str = field(default_factory=lambda: os.getenv("DEEPSEEK_API_KEY", ""))
deepseek_base_url: str = field(default_factory=lambda: os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"))
model: str = field(default_factory=lambda: os.getenv("DEEPSEEK_MODEL", "deepseek-chat"))
temperature: float = field(default_factory=lambda: float(os.getenv("TEMPERATURE", "0.2")))
deepseek_api_key: str = field(
default_factory=lambda: os.getenv("DEEPSEEK_API_KEY", "")
)
deepseek_base_url: str = field(
default_factory=lambda: os.getenv(
"DEEPSEEK_BASE_URL", "https://api.deepseek.com"
)
)
model: str = field(
default_factory=lambda: os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
)
temperature: float = field(
default_factory=lambda: float(os.getenv("TEMPERATURE", "0.2"))
)
# TMDB Configuration
tmdb_api_key: str = field(default_factory=lambda: os.getenv("TMDB_API_KEY", ""))
tmdb_base_url: str = field(default_factory=lambda: os.getenv("TMDB_BASE_URL", "https://api.themoviedb.org/3"))
tmdb_base_url: str = field(
default_factory=lambda: os.getenv(
"TMDB_BASE_URL", "https://api.themoviedb.org/3"
)
)
# Storage Configuration
memory_file: str = field(default_factory=lambda: os.getenv("MEMORY_FILE", "memory.json"))
memory_file: str = field(
default_factory=lambda: os.getenv("MEMORY_FILE", "memory.json")
)
# Security Configuration
max_tool_iterations: int = field(default_factory=lambda: int(os.getenv("MAX_TOOL_ITERATIONS", "5")))
request_timeout: int = field(default_factory=lambda: int(os.getenv("REQUEST_TIMEOUT", "30")))
max_tool_iterations: int = field(
default_factory=lambda: int(os.getenv("MAX_TOOL_ITERATIONS", "5"))
)
request_timeout: int = field(
default_factory=lambda: int(os.getenv("REQUEST_TIMEOUT", "30"))
)
# Memory Configuration
max_history_messages: int = field(
default_factory=lambda: int(os.getenv("MAX_HISTORY_MESSAGES", "10"))
)
def __post_init__(self):
"""Validate settings after initialization."""
@@ -43,19 +70,27 @@ class Settings:
"""Validate configuration values."""
# Validate temperature
if not 0.0 <= self.temperature <= 2.0:
raise ConfigurationError(f"Temperature must be between 0.0 and 2.0, got {self.temperature}")
raise ConfigurationError(
f"Temperature must be between 0.0 and 2.0, got {self.temperature}"
)
# Validate max_tool_iterations
if self.max_tool_iterations < 1 or self.max_tool_iterations > 20:
raise ConfigurationError(f"max_tool_iterations must be between 1 and 20, got {self.max_tool_iterations}")
raise ConfigurationError(
f"max_tool_iterations must be between 1 and 20, got {self.max_tool_iterations}"
)
# Validate request_timeout
if self.request_timeout < 1 or self.request_timeout > 300:
raise ConfigurationError(f"request_timeout must be between 1 and 300 seconds, got {self.request_timeout}")
raise ConfigurationError(
f"request_timeout must be between 1 and 300 seconds, got {self.request_timeout}"
)
# Validate URLs
if not self.deepseek_base_url.startswith(("http://", "https://")):
raise ConfigurationError(f"Invalid deepseek_base_url: {self.deepseek_base_url}")
raise ConfigurationError(
f"Invalid deepseek_base_url: {self.deepseek_base_url}"
)
if not self.tmdb_base_url.startswith(("http://", "https://")):
raise ConfigurationError(f"Invalid tmdb_base_url: {self.tmdb_base_url}")
@@ -63,7 +98,9 @@ class Settings:
# Validate memory file path
memory_path = Path(self.memory_file)
if memory_path.exists() and not memory_path.is_file():
raise ConfigurationError(f"memory_file exists but is not a file: {self.memory_file}")
raise ConfigurationError(
f"memory_file exists but is not a file: {self.memory_file}"
)
def is_deepseek_configured(self) -> bool:
"""Check if DeepSeek API is properly configured."""

View File

@@ -0,0 +1,13 @@
"""LLM clients module."""
from .deepseek import DeepSeekClient
from .exceptions import LLMAPIError, LLMConfigurationError, LLMError
from .ollama import OllamaClient
__all__ = [
"DeepSeekClient",
"OllamaClient",
"LLMError",
"LLMAPIError",
"LLMConfigurationError",
]

View File

@@ -1,48 +1,36 @@
"""DeepSeek LLM client with robust error handling."""
from typing import List, Dict, Any, Optional
import logging
from typing import Any
import requests
from requests.exceptions import RequestException, Timeout, HTTPError
from requests.exceptions import HTTPError, RequestException, Timeout
from ..config import settings
from .exceptions import LLMAPIError, LLMConfigurationError
logger = logging.getLogger(__name__)
class LLMError(Exception):
"""Base exception for LLM-related errors."""
pass
class LLMConfigurationError(LLMError):
"""Raised when LLM is not properly configured."""
pass
class LLMAPIError(LLMError):
"""Raised when LLM API returns an error."""
pass
class DeepSeekClient:
"""Client for interacting with DeepSeek API."""
def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
model: Optional[str] = None,
timeout: Optional[int] = None,
api_key: str | None = None,
base_url: str | None = None,
model: str | None = None,
timeout: int | None = None,
):
"""
Initialize DeepSeek client.
Args:
api_key: API key for authentication (defaults to settings)
base_url: Base URL for API (defaults to settings)
model: Model name to use (defaults to settings)
timeout: Request timeout in seconds (defaults to settings)
Raises:
LLMConfigurationError: If API key is missing
"""
@@ -50,29 +38,32 @@ class DeepSeekClient:
self.base_url = base_url or settings.deepseek_base_url
self.model = model or settings.model
self.timeout = timeout or settings.request_timeout
if not self.api_key:
raise LLMConfigurationError(
"DeepSeek API key is required. Set DEEPSEEK_API_KEY environment variable."
)
if not self.base_url:
raise LLMConfigurationError(
"DeepSeek base URL is required. Set DEEPSEEK_BASE_URL environment variable."
)
logger.info(f"DeepSeek client initialized with model: {self.model}")
def complete(self, messages: List[Dict[str, Any]]) -> str:
def complete( # noqa: PLR0912
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None
) -> dict[str, Any]:
"""
Generate a completion from the LLM.
Args:
messages: List of message dicts with 'role' and 'content' keys
tools: Optional list of tool specifications (OpenAI format)
Returns:
Generated text response
OpenAI-compatible message dict with 'role', 'content', and optionally 'tool_calls'
Raises:
LLMAPIError: If API request fails
ValueError: If messages format is invalid
@@ -80,15 +71,21 @@ class DeepSeekClient:
# Validate messages format
if not messages:
raise ValueError("Messages list cannot be empty")
for msg in messages:
if not isinstance(msg, dict):
raise ValueError(f"Each message must be a dict, got {type(msg)}")
if "role" not in msg or "content" not in msg:
raise ValueError(f"Each message must have 'role' and 'content' keys, got {msg.keys()}")
if msg["role"] not in ("system", "user", "assistant"):
if "role" not in msg:
raise ValueError(f"Message must have 'role' key, got {msg.keys()}")
# Allow system, user, assistant, and tool roles
if msg["role"] not in ("system", "user", "assistant", "tool"):
raise ValueError(f"Invalid role: {msg['role']}")
# Content is optional for tool messages (they may have tool_call_id instead)
if msg["role"] != "tool" and "content" not in msg:
raise ValueError(
f"Non-tool message must have 'content' key, got {msg.keys()}"
)
url = f"{self.base_url}/v1/chat/completions"
headers = {
"Authorization": f"Bearer {self.api_key}",
@@ -99,37 +96,38 @@ class DeepSeekClient:
"messages": messages,
"temperature": settings.temperature,
}
# Add tools if provided
if tools:
payload["tools"] = tools
try:
logger.debug(f"Sending request to {url} with {len(messages)} messages")
logger.debug(
f"Sending request to {url} with {len(messages)} messages and {len(tools) if tools else 0} tools"
)
response = requests.post(
url,
headers=headers,
json=payload,
timeout=self.timeout
url, headers=headers, json=payload, timeout=self.timeout
)
response.raise_for_status()
data = response.json()
# Validate response structure
if "choices" not in data or not data["choices"]:
raise LLMAPIError("Invalid API response: missing 'choices'")
if "message" not in data["choices"][0]:
raise LLMAPIError("Invalid API response: missing 'message' in choice")
if "content" not in data["choices"][0]["message"]:
raise LLMAPIError("Invalid API response: missing 'content' in message")
content = data["choices"][0]["message"]["content"]
logger.debug(f"Received response with {len(content)} characters")
return content
# Return the full message dict (OpenAI format)
message = data["choices"][0]["message"]
logger.debug(f"Received response: {message.get('content', '')[:100]}...")
return message
except Timeout as e:
logger.error(f"Request timeout after {self.timeout}s: {e}")
raise LLMAPIError(f"Request timeout after {self.timeout} seconds") from e
except HTTPError as e:
logger.error(f"HTTP error from DeepSeek API: {e}")
if e.response is not None:
@@ -140,11 +138,11 @@ class DeepSeekClient:
error_msg = str(e)
raise LLMAPIError(f"DeepSeek API error: {error_msg}") from e
raise LLMAPIError(f"HTTP error: {e}") from e
except RequestException as e:
logger.error(f"Request failed: {e}")
raise LLMAPIError(f"Failed to connect to DeepSeek API: {e}") from e
except (KeyError, IndexError, TypeError) as e:
logger.error(f"Failed to parse API response: {e}")
raise LLMAPIError(f"Invalid API response format: {e}") from e

View File

@@ -0,0 +1,19 @@
"""LLM-related exceptions."""
class LLMError(Exception):
"""Base exception for LLM-related errors."""
pass
class LLMConfigurationError(LLMError):
"""Raised when LLM is not properly configured."""
pass
class LLMAPIError(LLMError):
"""Raised when LLM API returns an error."""
pass

193
brain/agent/llm/ollama.py Normal file
View File

@@ -0,0 +1,193 @@
"""Ollama LLM client with robust error handling."""
import logging
import os
from typing import Any
import requests
from requests.exceptions import HTTPError, RequestException, Timeout
from ..config import settings
from .exceptions import LLMAPIError, LLMConfigurationError
logger = logging.getLogger(__name__)
class OllamaClient:
"""
Client for interacting with Ollama API.
Ollama runs locally and provides an OpenAI-compatible API.
Example:
>>> client = OllamaClient(model="llama3.2")
>>> messages = [{"role": "user", "content": "Hello!"}]
>>> response = client.complete(messages)
>>> print(response)
"""
def __init__(
self,
base_url: str | None = None,
model: str | None = None,
timeout: int | None = None,
temperature: float | None = None,
):
"""
Initialize Ollama client.
Args:
base_url: Ollama API base URL (defaults to http://localhost:11434)
model: Model name to use (e.g., "llama3.2", "mistral", "codellama")
timeout: Request timeout in seconds (defaults to settings)
temperature: Temperature for generation (defaults to settings)
Raises:
LLMConfigurationError: If configuration is invalid
"""
self.base_url = base_url or os.getenv(
"OLLAMA_BASE_URL", "http://localhost:11434"
)
self.model = model or os.getenv("OLLAMA_MODEL", "llama3.2")
self.timeout = timeout or settings.request_timeout
self.temperature = (
temperature if temperature is not None else settings.temperature
)
if not self.base_url:
raise LLMConfigurationError(
"Ollama base URL is required. Set OLLAMA_BASE_URL environment variable."
)
if not self.model:
raise LLMConfigurationError(
"Ollama model is required. Set OLLAMA_MODEL environment variable."
)
logger.info(f"Ollama client initialized with model: {self.model}")
def complete( # noqa: PLR0912
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None
) -> dict[str, Any]:
"""
Generate a completion from the LLM.
Args:
messages: List of message dicts with 'role' and 'content' keys
tools: Optional list of tool specifications (OpenAI format)
Returns:
OpenAI-compatible message dict with 'role', 'content', and optionally 'tool_calls'
Raises:
LLMAPIError: If API request fails
ValueError: If messages format is invalid
"""
# Validate messages format
if not messages:
raise ValueError("Messages list cannot be empty")
for msg in messages:
if not isinstance(msg, dict):
raise ValueError(f"Each message must be a dict, got {type(msg)}")
if "role" not in msg:
raise ValueError(f"Message must have 'role' key, got {msg.keys()}")
# Allow system, user, assistant, and tool roles
if msg["role"] not in ("system", "user", "assistant", "tool"):
raise ValueError(f"Invalid role: {msg['role']}")
# Content is optional for tool messages (they may have tool_call_id instead)
if msg["role"] != "tool" and "content" not in msg:
raise ValueError(
f"Non-tool message must have 'content' key, got {msg.keys()}"
)
url = f"{self.base_url}/api/chat"
payload = {
"model": self.model,
"messages": messages,
"stream": False,
"options": {
"temperature": self.temperature,
},
}
# Add tools if provided
if tools:
payload["tools"] = tools
try:
logger.debug(
f"Sending request to {url} with {len(messages)} messages and {len(tools) if tools else 0} tools"
)
response = requests.post(url, json=payload, timeout=self.timeout)
response.raise_for_status()
data = response.json()
# Validate response structure
if "message" not in data:
raise LLMAPIError("Invalid API response: missing 'message'")
# Return the full message dict (OpenAI format)
message = data["message"]
logger.debug(f"Received response: {message.get('content', '')[:100]}...")
return message
except Timeout as e:
logger.error(f"Request timeout after {self.timeout}s: {e}")
raise LLMAPIError(f"Request timeout after {self.timeout} seconds") from e
except HTTPError as e:
logger.error(f"HTTP error from Ollama API: {e}")
if e.response is not None:
try:
error_data = e.response.json()
error_msg = error_data.get("error", str(e))
except Exception:
error_msg = str(e)
raise LLMAPIError(f"Ollama API error: {error_msg}") from e
raise LLMAPIError(f"HTTP error: {e}") from e
except RequestException as e:
logger.error(f"Request failed: {e}")
raise LLMAPIError(f"Failed to connect to Ollama API: {e}") from e
except (KeyError, IndexError, TypeError) as e:
logger.error(f"Failed to parse API response: {e}")
raise LLMAPIError(f"Invalid API response format: {e}") from e
def list_models(self) -> list[str]:
"""
List available models in Ollama.
Returns:
List of model names
"""
url = f"{self.base_url}/api/tags"
try:
response = requests.get(url, timeout=self.timeout)
response.raise_for_status()
data = response.json()
models = [model["name"] for model in data.get("models", [])]
logger.info(f"Found {len(models)} models: {models}")
return models
except Exception as e:
logger.error(f"Failed to list models: {e}")
return []
def is_available(self) -> bool:
"""
Check if Ollama is running and accessible.
Returns:
True if Ollama is available, False otherwise
"""
try:
url = f"{self.base_url}/api/tags"
response = requests.get(url, timeout=5)
return response.status_code == 200
except Exception:
return False

View File

@@ -1,17 +1,18 @@
# agent/parameters.py
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Optional, Callable
import os
from typing import Any
@dataclass
class ParameterSchema:
"""Describes a required parameter for the agent."""
key: str
description: str
why_needed: str # Explanation for the AI
type: str # "string", "number", "object", etc.
validator: Optional[Callable[[Any], bool]] = None
validator: Callable[[Any], bool] | None = None
default: Any = None
required: bool = True
@@ -31,7 +32,7 @@ REQUIRED_PARAMETERS = [
type="object",
validator=lambda x: isinstance(x, dict),
required=True,
default={}
default={},
),
ParameterSchema(
key="tv_shows",
@@ -43,12 +44,12 @@ REQUIRED_PARAMETERS = [
type="array",
validator=lambda x: isinstance(x, list),
required=False,
default=[]
default=[],
),
]
def get_parameter_schema(key: str) -> Optional[ParameterSchema]:
def get_parameter_schema(key: str) -> ParameterSchema | None:
"""Get schema for a specific parameter."""
for param in REQUIRED_PARAMETERS:
if param.key == key:
@@ -79,7 +80,7 @@ def format_parameters_for_prompt() -> str:
return "\n".join(lines)
def validate_parameter(key: str, value: Any) -> tuple[bool, Optional[str]]:
def validate_parameter(key: str, value: Any) -> tuple[bool, str | None]:
"""
Validate a parameter value against its schema.

180
brain/agent/prompts.py Normal file
View File

@@ -0,0 +1,180 @@
"""Prompt builder for the agent system."""
import json
from typing import Any
from infrastructure.persistence import get_memory
from .registry import Tool
class PromptBuilder:
"""Builds system prompts for the agent with memory context."""
def __init__(self, tools: dict[str, Tool]):
self.tools = tools
def build_tools_spec(self) -> list[dict[str, Any]]:
"""Build the tool specification for the LLM API."""
tool_specs = []
for tool in self.tools.values():
spec = {
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
},
}
tool_specs.append(spec)
return tool_specs
def _format_tools_description(self) -> str:
"""Format tools with their descriptions and parameters."""
if not self.tools:
return ""
return "\n".join(
f"- {tool.name}: {tool.description}\n"
f" Parameters: {json.dumps(tool.parameters, ensure_ascii=False)}"
for tool in self.tools.values()
)
def _format_episodic_context(self, memory) -> str:
"""Format episodic memory context for the prompt."""
lines = []
if memory.episodic.last_search_results:
results = memory.episodic.last_search_results
result_list = results.get("results", [])
lines.append(
f"\nLAST SEARCH: '{results.get('query')}' ({len(result_list)} results)"
)
# Show first 5 results
for i, result in enumerate(result_list[:5]):
name = result.get("name", "Unknown")
lines.append(f" {i+1}. {name}")
if len(result_list) > 5:
lines.append(f" ... and {len(result_list) - 5} more")
if memory.episodic.pending_question:
question = memory.episodic.pending_question
lines.append(f"\nPENDING QUESTION: {question.get('question')}")
lines.append(f" Type: {question.get('type')}")
if question.get("options"):
lines.append(f" Options: {len(question.get('options'))}")
if memory.episodic.active_downloads:
lines.append(f"\nACTIVE DOWNLOADS: {len(memory.episodic.active_downloads)}")
for dl in memory.episodic.active_downloads[:3]:
lines.append(f" - {dl.get('name')}: {dl.get('progress', 0)}%")
if memory.episodic.recent_errors:
lines.append("\nRECENT ERRORS (up to 3):")
for error in memory.episodic.recent_errors[-3:]:
lines.append(
f" - Action '{error.get('action')}' failed: {error.get('error')}"
)
# Unread events
unread = [e for e in memory.episodic.background_events if not e.get("read")]
if unread:
lines.append(f"\nUNREAD EVENTS: {len(unread)}")
for event in unread[:3]:
lines.append(f" - {event.get('type')}: {event.get('data')}")
return "\n".join(lines)
def _format_stm_context(self, memory) -> str:
"""Format short-term memory context for the prompt."""
lines = []
if memory.stm.current_workflow:
workflow = memory.stm.current_workflow
lines.append(
f"CURRENT WORKFLOW: {workflow.get('type')} (stage: {workflow.get('stage')})"
)
if workflow.get("target"):
lines.append(f" Target: {workflow.get('target')}")
if memory.stm.current_topic:
lines.append(f"CURRENT TOPIC: {memory.stm.current_topic}")
if memory.stm.extracted_entities:
lines.append("EXTRACTED ENTITIES:")
for key, value in memory.stm.extracted_entities.items():
lines.append(f" - {key}: {value}")
if memory.stm.language:
lines.append(f"CONVERSATION LANGUAGE: {memory.stm.language}")
return "\n".join(lines)
def _format_config_context(self, memory) -> str:
"""Format configuration context."""
lines = ["CURRENT CONFIGURATION:"]
if memory.ltm.config:
for key, value in memory.ltm.config.items():
lines.append(f" - {key}: {value}")
else:
lines.append(" (no configuration set)")
return "\n".join(lines)
def build_system_prompt(self) -> str:
"""Build the complete system prompt."""
# Get memory once for all context formatting
memory = get_memory()
# Base instruction
base = "You are a helpful AI assistant for managing a media library."
# Language instruction
language_instruction = (
"Your first task is to determine the user's language from their message "
"and use the `set_language` tool if it's different from the current one. "
"After that, proceed to help the user."
)
# Available tools
tools_desc = self._format_tools_description()
tools_section = f"\nAVAILABLE TOOLS:\n{tools_desc}" if tools_desc else ""
# Configuration
config_section = self._format_config_context(memory)
if config_section:
config_section = f"\n{config_section}"
# STM context
stm_context = self._format_stm_context(memory)
if stm_context:
stm_context = f"\n{stm_context}"
# Episodic context
episodic_context = self._format_episodic_context(memory)
# Important rules
rules = """
IMPORTANT RULES:
- Use tools to accomplish tasks
- When search results are available, reference them by index (e.g., "add_torrent_by_index")
- Always confirm actions with the user before executing destructive operations
- Provide clear, concise responses
"""
# Examples
examples = """
EXAMPLES:
- User: "Find Inception" → Use find_media_imdb_id, then find_torrent
- User: "download the 3rd one" → Use add_torrent_by_index with index=3
- User: "List my downloads" → Use list_folder with folder_type="download"
"""
return f"""{base}
{language_instruction}
{tools_section}
{config_section}
{stm_context}
{episodic_context}
{rules}
{examples}
"""

112
brain/agent/registry.py Normal file
View File

@@ -0,0 +1,112 @@
"""Tool registry - defines and registers all available tools for the agent."""
import inspect
import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
@dataclass
class Tool:
"""Represents a tool that can be used by the agent."""
name: str
description: str
func: Callable[..., dict[str, Any]]
parameters: dict[str, Any]
def _create_tool_from_function(func: Callable) -> Tool:
"""
Create a Tool object from a function.
Args:
func: Function to convert to a tool
Returns:
Tool object with metadata extracted from function
"""
sig = inspect.signature(func)
doc = inspect.getdoc(func)
# Extract description from docstring (first line)
description = doc.strip().split("\n")[0] if doc else func.__name__
# Build JSON schema from function signature
properties = {}
required = []
for param_name, param in sig.parameters.items():
if param_name == "self":
continue
# Map Python types to JSON schema types
param_type = "string" # default
if param.annotation != inspect.Parameter.empty:
if param.annotation is str:
param_type = "string"
elif param.annotation is int:
param_type = "integer"
elif param.annotation is float:
param_type = "number"
elif param.annotation is bool:
param_type = "boolean"
properties[param_name] = {
"type": param_type,
"description": f"Parameter {param_name}",
}
# Add to required if no default value
if param.default == inspect.Parameter.empty:
required.append(param_name)
parameters = {
"type": "object",
"properties": properties,
"required": required,
}
return Tool(
name=func.__name__,
description=description,
func=func,
parameters=parameters,
)
def make_tools() -> dict[str, Tool]:
"""
Create and register all available tools.
Returns:
Dictionary mapping tool names to Tool objects
"""
# Import tools here to avoid circular dependencies
from .tools import api as api_tools # noqa: PLC0415
from .tools import filesystem as fs_tools # noqa: PLC0415
from .tools import language as lang_tools # noqa: PLC0415
# List of all tool functions
tool_functions = [
fs_tools.set_path_for_folder,
fs_tools.list_folder,
api_tools.find_media_imdb_id,
api_tools.find_torrent,
api_tools.add_torrent_by_index,
api_tools.add_torrent_to_qbittorrent,
api_tools.get_torrent_by_index,
lang_tools.set_language,
]
# Create Tool objects from functions
tools = {}
for func in tool_functions:
tool = _create_tool_from_function(func)
tools[tool.name] = tool
logger.info(f"Registered {len(tools)} tools: {list(tools.keys())}")
return tools

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",
]

196
brain/agent/tools/api.py Normal file
View File

@@ -0,0 +1,196 @@
"""API tools for interacting with external services."""
import logging
from typing import Any
from application.movies import SearchMovieUseCase
from application.torrents import AddTorrentUseCase, SearchTorrentsUseCase
from infrastructure.api.knaben import knaben_client
from infrastructure.api.qbittorrent import qbittorrent_client
from infrastructure.api.tmdb import tmdb_client
from infrastructure.persistence import get_memory
logger = logging.getLogger(__name__)
def find_media_imdb_id(media_title: str) -> dict[str, Any]:
"""
Find the IMDb ID for a given media title using TMDB API.
Args:
media_title: Title of the media to search for.
Returns:
Dict with IMDb ID and media info, or error details.
"""
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_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
def find_torrent(media_title: str) -> dict[str, Any]:
"""
Find torrents for a given media title using Knaben API.
Results are stored in episodic memory so the user can reference them
by index (e.g., "download the 3rd one").
Args:
media_title: Title of the media to search for.
Returns:
Dict with torrent list or error details.
"""
logger.info(f"Searching torrents for: {media_title}")
use_case = SearchTorrentsUseCase(knaben_client)
response = use_case.execute(media_title, limit=10)
result = response.to_dict()
if result.get("status") == "ok":
memory = get_memory()
torrents = result.get("torrents", [])
memory.episodic.store_search_results(
query=media_title, results=torrents, search_type="torrent"
)
memory.stm.set_topic("selecting_torrent")
logger.info(f"Stored {len(torrents)} torrent results in episodic memory")
return result
def get_torrent_by_index(index: int) -> dict[str, Any]:
"""
Get a torrent from the last search results by its index.
Allows the user to reference results by number after a search.
Args:
index: 1-based index of the torrent in the search results.
Returns:
Dict with torrent data or error if not found.
"""
logger.info(f"Getting torrent at index: {index}")
memory = get_memory()
if memory.episodic.last_search_results:
results_count = len(memory.episodic.last_search_results.get("results", []))
query = memory.episodic.last_search_results.get("query", "unknown")
logger.debug(f"Episodic memory has {results_count} results from: {query}")
else:
logger.warning("No search results in episodic memory")
result = memory.episodic.get_result_by_index(index)
if result:
logger.info(f"Found torrent at index {index}: {result.get('name', 'unknown')}")
return {"status": "ok", "torrent": result}
logger.warning(f"No torrent found at index {index}")
return {
"status": "error",
"error": "not_found",
"message": f"No torrent found at index {index}. Search for torrents first.",
}
def add_torrent_to_qbittorrent(magnet_link: str) -> dict[str, Any]:
"""
Add a torrent to qBittorrent using a magnet link.
Args:
magnet_link: Magnet link of the torrent to add.
Returns:
Dict with success status or error details.
"""
logger.info("Adding torrent to qBittorrent")
use_case = AddTorrentUseCase(qbittorrent_client)
response = use_case.execute(magnet_link)
result = response.to_dict()
if result.get("status") == "ok":
memory = get_memory()
last_search = memory.episodic.get_search_results()
torrent_name = "Unknown"
if last_search:
for t in last_search.get("results", []):
if t.get("magnet") == magnet_link:
torrent_name = t.get("name", "Unknown")
break
memory.episodic.add_active_download(
{
"task_id": magnet_link[:20],
"name": torrent_name,
"magnet": magnet_link,
"progress": 0,
"status": "queued",
}
)
memory.stm.set_topic("downloading")
memory.stm.end_workflow()
logger.info(f"Added download to episodic memory: {torrent_name}")
return result
def add_torrent_by_index(index: int) -> dict[str, Any]:
"""
Add a torrent from the last search results by its index.
Combines get_torrent_by_index and add_torrent_to_qbittorrent.
Args:
index: 1-based index of the torrent in the search results.
Returns:
Dict with success status or error details.
"""
logger.info(f"Adding torrent by index: {index}")
torrent_result = get_torrent_by_index(index)
if torrent_result.get("status") != "ok":
return torrent_result
torrent = torrent_result.get("torrent", {})
magnet = torrent.get("magnet")
if not magnet:
logger.error("Torrent has no magnet link")
return {
"status": "error",
"error": "no_magnet",
"message": "The selected torrent has no magnet link",
}
logger.info(f"Adding torrent: {torrent.get('name', 'unknown')}")
result = add_torrent_to_qbittorrent(magnet)
if result.get("status") == "ok":
result["torrent_name"] = torrent.get("name", "Unknown")
return result

View File

@@ -0,0 +1,40 @@
"""Filesystem tools for folder management."""
from typing import Any
from application.filesystem import ListFolderUseCase, SetFolderPathUseCase
from infrastructure.filesystem import FileManager
def set_path_for_folder(folder_name: str, path_value: str) -> dict[str, Any]:
"""
Set a folder path in the configuration.
Args:
folder_name: Name of folder to set (download, tvshow, movie, torrent).
path_value: Absolute path to the folder.
Returns:
Dict with status or error information.
"""
file_manager = FileManager()
use_case = SetFolderPathUseCase(file_manager)
response = use_case.execute(folder_name, path_value)
return response.to_dict()
def list_folder(folder_type: str, path: str = ".") -> dict[str, Any]:
"""
List contents of a configured folder.
Args:
folder_type: Type of folder to list (download, tvshow, movie, torrent).
path: Relative path within the folder (default: root).
Returns:
Dict with folder contents or error information.
"""
file_manager = FileManager()
use_case = ListFolderUseCase(file_manager)
response = use_case.execute(folder_type, path)
return response.to_dict()

View File

@@ -0,0 +1,35 @@
"""Language management tools for the agent."""
import logging
from typing import Any
from infrastructure.persistence import get_memory
logger = logging.getLogger(__name__)
def set_language(language: str) -> dict[str, Any]:
"""
Set the conversation language.
Args:
language: Language code (e.g., 'en', 'fr', 'es', 'de')
Returns:
Status dictionary
"""
try:
memory = get_memory()
memory.stm.set_language(language)
memory.save()
logger.info(f"Language set to: {language}")
return {
"status": "ok",
"message": f"Language set to {language}",
"language": language,
}
except Exception as e:
logger.error(f"Failed to set language: {e}")
return {"status": "error", "error": str(e)}

266
brain/app.py Normal file
View File

@@ -0,0 +1,266 @@
"""FastAPI application for the media library agent."""
import json
import logging
import os
import time
import uuid
from typing import Any
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, validator
from agent.agent import Agent
from agent.config import settings
from agent.llm.deepseek import DeepSeekClient
from agent.llm.exceptions import LLMAPIError, LLMConfigurationError
from agent.llm.ollama import OllamaClient
from infrastructure.persistence import get_memory, init_memory
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
app = FastAPI(
title="Agent Media API",
description="AI agent for managing a local media library",
version="0.2.0",
)
# TODO: Make a variable
manifests = "manifests"
# Sécurité : on vérifie que le dossier existe pour ne pas faire planter l'app au démarrage
if os.path.exists(manifests):
app.mount("/manifests", StaticFiles(directory=manifests), name="manifests")
else:
print(
f"⚠️ ATTENTION : Le dossier '{manifests}' est introuvable. Le plugin ne marchera pas."
)
# Initialize memory context at startup
# Use /data/memory in Docker, fallback to memory_data for local dev
storage_dir = os.getenv("MEMORY_STORAGE_DIR", "memory_data")
init_memory(storage_dir=storage_dir)
logger.info(f"Memory context initialized (storage: {storage_dir})")
# Initialize LLM based on environment variable
llm_provider = os.getenv("LLM_PROVIDER", "deepseek").lower()
try:
if llm_provider == "ollama":
logger.info("Using Ollama LLM")
llm = OllamaClient()
else:
logger.info("Using DeepSeek LLM")
llm = DeepSeekClient()
except LLMConfigurationError as e:
logger.error(f"Failed to initialize LLM: {e}")
raise
# Initialize agent
agent = Agent(llm=llm, max_tool_iterations=settings.max_tool_iterations)
logger.info("Agent Media API initialized")
# Pydantic models for request validation
class ChatMessage(BaseModel):
"""A single message in the conversation."""
role: str = Field(..., description="Role of the message sender")
content: str | None = Field(None, description="Content of the message")
@validator("content")
def content_must_not_be_empty_for_user(cls, v, values):
"""Validate that user messages have non-empty content."""
if values.get("role") == "user" and not v:
raise ValueError("User messages must have non-empty content")
return v
class ChatCompletionRequest(BaseModel):
"""Request body for chat completions."""
model: str = Field(default="agent-media", description="Model to use")
messages: list[ChatMessage] = Field(..., description="List of messages")
stream: bool = Field(default=False, description="Whether to stream the response")
temperature: float | None = Field(default=None, ge=0.0, le=2.0)
max_tokens: int | None = Field(default=None, gt=0)
@validator("messages")
def messages_must_have_user_message(cls, v):
"""Validate that there is at least one user message."""
if not any(msg.role == "user" for msg in v):
raise ValueError("At least one user message is required")
return v
def extract_last_user_content(messages: list[dict[str, Any]]) -> str:
"""
Extract the last user message from the conversation.
Args:
messages: List of message dictionaries.
Returns:
Content of the last user message, or empty string.
"""
for m in reversed(messages):
if m.get("role") == "user":
return m.get("content") or ""
return ""
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "healthy", "version": "0.2.0"}
@app.get("/v1/models")
async def list_models():
"""List available models (OpenAI-compatible endpoint)."""
return {
"object": "list",
"data": [
{
"id": "agent-media",
"object": "model",
"created": int(time.time()),
"owned_by": "local",
}
],
}
@app.get("/memory/state")
async def get_memory_state():
"""Debug endpoint to view full memory state."""
memory = get_memory()
return memory.get_full_state()
@app.get("/memory/episodic/search-results")
async def get_search_results():
"""Debug endpoint to view last search results."""
memory = get_memory()
if memory.episodic.last_search_results:
return {
"status": "ok",
"query": memory.episodic.last_search_results.get("query"),
"type": memory.episodic.last_search_results.get("type"),
"timestamp": memory.episodic.last_search_results.get("timestamp"),
"result_count": len(memory.episodic.last_search_results.get("results", [])),
"results": memory.episodic.last_search_results.get("results", []),
}
return {"status": "empty", "message": "No search results in episodic memory"}
@app.post("/memory/clear-session")
async def clear_session():
"""Clear session memories (STM + Episodic)."""
memory = get_memory()
memory.clear_session()
return {"status": "ok", "message": "Session memories cleared"}
@app.post("/v1/chat/completions")
async def chat_completions(chat_request: ChatCompletionRequest):
"""
OpenAI-compatible chat completions endpoint.
Accepts messages and returns agent response.
Supports both streaming and non-streaming modes.
"""
# Convert Pydantic models to dicts for processing
messages_dict = [msg.dict() for msg in chat_request.messages]
user_input = extract_last_user_content(messages_dict)
logger.info(
f"Chat request - stream={chat_request.stream}, input_length={len(user_input)}"
)
created_ts = int(time.time())
completion_id = f"chatcmpl-{uuid.uuid4().hex}"
if not chat_request.stream:
try:
answer = agent.step(user_input)
except LLMAPIError as e:
logger.error(f"LLM API error: {e}")
raise HTTPException(status_code=502, detail=f"LLM API error: {e}") from e
except Exception as e:
logger.error(f"Agent error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal agent error") from e
return JSONResponse(
{
"id": completion_id,
"object": "chat.completion",
"created": created_ts,
"model": chat_request.model,
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {"role": "assistant", "content": answer or ""},
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
},
}
)
async def event_generator():
try:
# Stream the agent execution
async for chunk in agent.step_streaming(
user_input, completion_id, created_ts, chat_request.model
):
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
except LLMAPIError as e:
logger.error(f"LLM API error: {e}")
error_chunk = {
"id": completion_id,
"object": "chat.completion.chunk",
"created": created_ts,
"model": chat_request.model,
"choices": [
{
"index": 0,
"delta": {"role": "assistant", "content": f"Error: {e}"},
"finish_reason": "stop",
}
],
}
yield f"data: {json.dumps(error_chunk, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
except Exception as e:
logger.error(f"Agent error: {e}", exc_info=True)
error_chunk = {
"id": completion_id,
"object": "chat.completion.chunk",
"created": created_ts,
"model": chat_request.model,
"choices": [
{
"index": 0,
"delta": {
"role": "assistant",
"content": "Internal agent error",
},
"finish_reason": "stop",
}
],
}
yield f"data: {json.dumps(error_chunk, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")

View File

@@ -0,0 +1 @@
"""Application layer - Use cases and application services."""

View File

@@ -0,0 +1,12 @@
"""Filesystem use cases."""
from .dto import ListFolderResponse, SetFolderPathResponse
from .list_folder import ListFolderUseCase
from .set_folder_path import SetFolderPathUseCase
__all__ = [
"SetFolderPathUseCase",
"ListFolderUseCase",
"SetFolderPathResponse",
"ListFolderResponse",
]

View File

@@ -0,0 +1,61 @@
"""Filesystem application DTOs."""
from dataclasses import dataclass
@dataclass
class SetFolderPathResponse:
"""Response from setting a folder path."""
status: str
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 ListFolderResponse:
"""Response from listing a folder."""
status: str
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):
"""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

View File

@@ -0,0 +1,52 @@
"""List folder use case."""
import logging
from infrastructure.filesystem import FileManager
from .dto import ListFolderResponse
logger = logging.getLogger(__name__)
class ListFolderUseCase:
"""
Use case for listing folder contents.
This orchestrates the FileManager to list folders.
"""
def __init__(self, file_manager: FileManager):
"""
Initialize use case.
Args:
file_manager: FileManager instance
"""
self.file_manager = file_manager
def execute(self, folder_type: str, path: str = ".") -> ListFolderResponse:
"""
List contents of a folder.
Args:
folder_type: Type of folder to list (download, tvshow, movie, torrent)
path: Relative path within the folder (default: ".")
Returns:
ListFolderResponse with folder contents or error information
"""
result = self.file_manager.list_folder(folder_type, path)
if result.get("status") == "ok":
return ListFolderResponse(
status="ok",
folder_type=result.get("folder_type"),
path=result.get("path"),
entries=result.get("entries"),
count=result.get("count"),
)
else:
return ListFolderResponse(
status="error", error=result.get("error"), message=result.get("message")
)

View File

@@ -0,0 +1,50 @@
"""Set folder path use case."""
import logging
from 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")
)

View File

@@ -0,0 +1,9 @@
"""Movie use cases."""
from .dto import SearchMovieResponse
from .search_movie import SearchMovieUseCase
__all__ = [
"SearchMovieUseCase",
"SearchMovieResponse",
]

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

View File

@@ -0,0 +1,93 @@
"""Search movie use case."""
import logging
from 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)
)

View File

@@ -0,0 +1,12 @@
"""Torrent use cases."""
from .add_torrent import AddTorrentUseCase
from .dto import AddTorrentResponse, SearchTorrentsResponse
from .search_torrents import SearchTorrentsUseCase
__all__ = [
"SearchTorrentsUseCase",
"AddTorrentUseCase",
"SearchTorrentsResponse",
"AddTorrentResponse",
]

View File

@@ -0,0 +1,84 @@
"""Add torrent use case."""
import logging
from infrastructure.api.qbittorrent import (
QBittorrentAPIError,
QBittorrentAuthError,
QBittorrentClient,
)
from .dto import AddTorrentResponse
logger = logging.getLogger(__name__)
class AddTorrentUseCase:
"""
Use case for adding a torrent to qBittorrent.
This orchestrates the qBittorrent API client to add torrents.
"""
def __init__(self, qbittorrent_client: QBittorrentClient):
"""
Initialize use case.
Args:
qbittorrent_client: qBittorrent API client
"""
self.qbittorrent_client = qbittorrent_client
def execute(self, magnet_link: str) -> AddTorrentResponse:
"""
Add a torrent to qBittorrent using a magnet link.
Args:
magnet_link: Magnet link of the torrent to add
Returns:
AddTorrentResponse with success or error information
"""
try:
# Validate magnet link
if not magnet_link or not isinstance(magnet_link, str):
raise ValueError("Magnet link must be a non-empty string")
if not magnet_link.startswith("magnet:"):
raise ValueError("Invalid magnet link format")
logger.info("Adding torrent to qBittorrent")
# Add torrent to qBittorrent
success = self.qbittorrent_client.add_torrent(magnet_link)
if success:
logger.info("Torrent added successfully to qBittorrent")
return AddTorrentResponse(
status="ok", message="Torrent added successfully to qBittorrent"
)
else:
logger.warning("Failed to add torrent to qBittorrent")
return AddTorrentResponse(
status="error",
error="add_failed",
message="Failed to add torrent to qBittorrent",
)
except QBittorrentAuthError as e:
logger.error(f"qBittorrent authentication error: {e}")
return AddTorrentResponse(
status="error",
error="authentication_failed",
message="Failed to authenticate with qBittorrent",
)
except QBittorrentAPIError as e:
logger.error(f"qBittorrent API error: {e}")
return AddTorrentResponse(status="error", error="api_error", message=str(e))
except ValueError as e:
logger.error(f"Validation error: {e}")
return AddTorrentResponse(
status="error", error="validation_failed", message=str(e)
)

View File

@@ -0,0 +1,50 @@
"""Torrent application DTOs."""
from dataclasses import dataclass
from typing import Any
@dataclass
class SearchTorrentsResponse:
"""Response from searching for torrents."""
status: str
torrents: list[dict[str, Any]] | None = None
count: int | 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.torrents is not None:
result["torrents"] = self.torrents
if self.count is not None:
result["count"] = self.count
return result
@dataclass
class AddTorrentResponse:
"""Response from adding a torrent."""
status: str
message: str | None = None
error: str | None = None
def to_dict(self):
"""Convert to dict for agent compatibility."""
result = {"status": self.status}
if self.error:
result["error"] = self.error
if self.message:
result["message"] = self.message
return result

View File

@@ -0,0 +1,90 @@
"""Search torrents use case."""
import logging
from infrastructure.api.knaben import KnabenAPIError, KnabenClient, KnabenNotFoundError
from .dto import SearchTorrentsResponse
logger = logging.getLogger(__name__)
class SearchTorrentsUseCase:
"""
Use case for searching torrents.
This orchestrates the Knaben API client to find torrents.
"""
def __init__(self, knaben_client: KnabenClient):
"""
Initialize use case.
Args:
knaben_client: Knaben API client
"""
self.knaben_client = knaben_client
def execute(self, media_title: str, limit: int = 10) -> SearchTorrentsResponse:
"""
Search for torrents by media title.
Args:
media_title: Title of the media to search for
limit: Maximum number of results
Returns:
SearchTorrentsResponse with torrent information or error
"""
try:
# Search for torrents
results = self.knaben_client.search(media_title, limit=limit)
if not results:
logger.info(f"No torrents found for '{media_title}'")
return SearchTorrentsResponse(
status="error",
error="not_found",
message=f"No torrents found for '{media_title}'",
)
# Convert to dict format
torrents = []
for torrent in results:
torrents.append(
{
"name": torrent.title,
"size": torrent.size,
"seeders": torrent.seeders,
"leechers": torrent.leechers,
"magnet": torrent.magnet,
"info_hash": torrent.info_hash,
"tracker": torrent.tracker,
"upload_date": torrent.upload_date,
"category": torrent.category,
}
)
logger.info(f"Found {len(torrents)} torrents for '{media_title}'")
return SearchTorrentsResponse(
status="ok", torrents=torrents, count=len(torrents)
)
except KnabenNotFoundError as e:
logger.info(f"Torrents not found: {e}")
return SearchTorrentsResponse(
status="error", error="not_found", message=str(e)
)
except KnabenAPIError as e:
logger.error(f"Knaben API error: {e}")
return SearchTorrentsResponse(
status="error", error="api_error", message=str(e)
)
except ValueError as e:
logger.error(f"Validation error: {e}")
return SearchTorrentsResponse(
status="error", error="validation_failed", message=str(e)
)

View File

@@ -0,0 +1,402 @@
# Architecture Diagram - Agent Media
## System Overview
```mermaid
flowchart TB
subgraph Client["👤 Client"]
CHAT[Chat Interface<br/>OpenWebUI / CLI / Custom]
end
subgraph AgentMedia["🎬 Agent Media"]
subgraph API["API Layer"]
FASTAPI[FastAPI Server<br/>:8000]
end
subgraph Core["Core"]
AGENT[🤖 Agent<br/>Orchestrator]
MEMORY[🧠 Memory<br/>LTM + STM + Episodic]
end
subgraph Tools["Tools"]
T1[📁 Filesystem]
T2[🔍 Search]
T3[⬇️ Download]
end
end
subgraph LLM["🧠 LLM Provider"]
DEEPSEEK[DeepSeek API]
OLLAMA[Ollama<br/>Local]
end
subgraph External["☁️ External Services"]
TMDB[(TMDB<br/>Movie Database)]
KNABEN[(Knaben<br/>Torrent Search)]
QBIT[qBittorrent<br/>Download Client]
end
subgraph Storage["💾 Storage"]
JSON[(memory_data/<br/>ltm.json)]
MEDIA[(Media Folders<br/>/movies /tvshows)]
end
CHAT <-->|OpenAI API| FASTAPI
FASTAPI <--> AGENT
AGENT <--> MEMORY
AGENT <--> Tools
AGENT <-->|Chat Completion| LLM
T1 <--> MEDIA
T2 --> TMDB
T2 --> KNABEN
T3 --> QBIT
MEMORY <--> JSON
QBIT --> MEDIA
style AgentMedia fill:#1a1a2e,color:#fff
style AGENT fill:#ff6b6b,color:#fff
style MEMORY fill:#4ecdc4,color:#fff
```
## Detailed Architecture
```mermaid
flowchart TB
subgraph Clients["Clients"]
direction LR
OWU[OpenWebUI]
CLI[CLI Client]
CURL[cURL / HTTP]
end
subgraph LoadBalancer["Entry Point"]
NGINX[Nginx / Reverse Proxy<br/>Optional]
end
subgraph Application["Agent Media Application"]
direction TB
subgraph Presentation["Presentation Layer"]
EP1["/v1/chat/completions"]
EP2["/v1/models"]
EP3["/health"]
EP4["/memory/state"]
end
subgraph AgentLayer["Agent Layer"]
direction LR
AG[Agent]
PB[PromptBuilder]
REG[Registry]
end
subgraph ToolsLayer["Tools Layer"]
direction LR
FS_TOOL[Filesystem Tools<br/>set_path, list_folder]
API_TOOL[API Tools<br/>find_torrent, add_torrent]
end
subgraph AppLayer["Application Layer"]
direction LR
UC1[SearchMovie<br/>UseCase]
UC2[SearchTorrents<br/>UseCase]
UC3[AddTorrent<br/>UseCase]
UC4[SetFolderPath<br/>UseCase]
end
subgraph DomainLayer["Domain Layer"]
direction LR
ENT[Entities<br/>Movie, TVShow, Subtitle]
VO[Value Objects<br/>ImdbId, Quality, FilePath]
REPO_INT[Repository<br/>Interfaces]
end
subgraph InfraLayer["Infrastructure Layer"]
direction TB
subgraph Persistence["Persistence"]
MEM[Memory Manager]
REPO_IMPL[JSON Repositories]
end
subgraph APIClients["API Clients"]
TMDB_C[TMDB Client]
KNAB_C[Knaben Client]
QBIT_C[qBittorrent Client]
end
subgraph FSManager["Filesystem"]
FM[FileManager]
end
end
end
subgraph LLMProviders["LLM Providers"]
direction LR
DS[DeepSeek<br/>api.deepseek.com]
OL[Ollama<br/>localhost:11434]
end
subgraph ExternalAPIs["External APIs"]
direction LR
TMDB_API[TMDB API<br/>api.themoviedb.org]
KNAB_API[Knaben API<br/>knaben.eu]
QBIT_API[qBittorrent WebUI<br/>localhost:8080]
end
subgraph DataStores["Data Stores"]
direction LR
LTM_FILE[(ltm.json<br/>Persistent Config)]
MEDIA_DIR[(Media Directories<br/>/downloads /movies /tvshows)]
end
%% Client connections
Clients --> LoadBalancer
LoadBalancer --> Presentation
%% Internal flow
Presentation --> AgentLayer
AgentLayer --> ToolsLayer
ToolsLayer --> AppLayer
AppLayer --> DomainLayer
AppLayer --> InfraLayer
InfraLayer -.->|implements| DomainLayer
%% Agent to LLM
AgentLayer <-->|HTTP| LLMProviders
%% Infrastructure to External
TMDB_C -->|HTTP| TMDB_API
KNAB_C -->|HTTP| KNAB_API
QBIT_C -->|HTTP| QBIT_API
%% Persistence
MEM <--> LTM_FILE
FM <--> MEDIA_DIR
QBIT_API --> MEDIA_DIR
```
## Memory System Architecture
```mermaid
flowchart TB
subgraph MemoryManager["Memory Manager"]
direction TB
subgraph LTM["💾 Long-Term Memory"]
direction LR
LTM_DESC["Persistent across restarts<br/>Stored in ltm.json"]
subgraph LTM_DATA["Data"]
CONFIG["config{}<br/>folder paths, API keys"]
PREFS["preferences{}<br/>quality, languages"]
LIBRARY["library{}<br/>movies[], tv_shows[]"]
FOLLOWING["following[]<br/>watchlist"]
end
end
subgraph STM["🧠 Short-Term Memory"]
direction LR
STM_DESC["Session-based<br/>Cleared on restart"]
subgraph STM_DATA["Data"]
HISTORY["conversation_history[]<br/>last 20 messages"]
WORKFLOW["current_workflow{}<br/>type, stage, target"]
ENTITIES["extracted_entities{}<br/>title, year, quality"]
TOPIC["current_topic<br/>searching, downloading"]
end
end
subgraph EPISODIC["⚡ Episodic Memory"]
direction LR
EPIS_DESC["Transient state<br/>Cleared on restart"]
subgraph EPIS_DATA["Data"]
SEARCH["last_search_results{}<br/>indexed torrents"]
DOWNLOADS["active_downloads[]<br/>in-progress"]
ERRORS["recent_errors[]<br/>last 5 errors"]
PENDING["pending_question{}<br/>awaiting user input"]
EVENTS["background_events[]<br/>notifications"]
end
end
end
subgraph Storage["Storage"]
FILE[(memory_data/ltm.json)]
end
subgraph Lifecycle["Lifecycle"]
SAVE[save()]
LOAD[load()]
CLEAR[clear_session()]
end
LTM <-->|read/write| FILE
SAVE --> LTM
LOAD --> LTM
CLEAR --> STM
CLEAR --> EPISODIC
style LTM fill:#4caf50,color:#fff
style STM fill:#2196f3,color:#fff
style EPISODIC fill:#ff9800,color:#fff
```
## Request Flow
```mermaid
flowchart LR
subgraph Request["1⃣ Request"]
USER[User Message]
end
subgraph Parse["2⃣ Parse"]
FASTAPI[FastAPI<br/>Extract message]
end
subgraph Context["3⃣ Build Context"]
PROMPT[PromptBuilder<br/>+ Memory context<br/>+ Tool descriptions]
end
subgraph Think["4⃣ Think"]
LLM[LLM<br/>Decide action]
end
subgraph Act["5⃣ Act"]
TOOL[Execute Tool<br/>or respond]
end
subgraph Store["6⃣ Store"]
MEM[Update Memory<br/>STM + Episodic]
end
subgraph Response["7⃣ Response"]
RESP[JSON Response]
end
USER --> FASTAPI --> PROMPT --> LLM
LLM -->|Tool call| TOOL --> MEM --> LLM
LLM -->|Text response| MEM --> RESP
style Think fill:#ff6b6b,color:#fff
style Act fill:#4ecdc4,color:#fff
style Store fill:#45b7d1,color:#fff
```
## Deployment Architecture
```mermaid
flowchart TB
subgraph Host["Host Machine"]
subgraph Docker["Docker (Optional)"]
AGENT_CONTAINER[Agent Media<br/>Container]
end
subgraph Native["Native Services"]
QBIT_SERVICE[qBittorrent<br/>:8080]
OLLAMA_SERVICE[Ollama<br/>:11434]
end
subgraph Storage["Local Storage"]
CONFIG_DIR[/config<br/>memory_data/]
MEDIA_DIR[/media<br/>downloads, movies, tvshows]
end
end
subgraph Cloud["Cloud Services"]
DEEPSEEK[DeepSeek API]
TMDB[TMDB API]
KNABEN[Knaben API]
end
subgraph Client["Client"]
BROWSER[Browser<br/>OpenWebUI]
end
BROWSER <-->|:8000| AGENT_CONTAINER
AGENT_CONTAINER <-->|:8080| QBIT_SERVICE
AGENT_CONTAINER <-->|:11434| OLLAMA_SERVICE
AGENT_CONTAINER <--> CONFIG_DIR
AGENT_CONTAINER <--> MEDIA_DIR
QBIT_SERVICE --> MEDIA_DIR
AGENT_CONTAINER <-->|HTTPS| Cloud
```
## Technology Stack
```mermaid
mindmap
root((Agent Media))
API
FastAPI
Uvicorn
OpenAI Compatible
Agent
Python 3.11+
Dataclasses
Protocol typing
LLM
DeepSeek
Ollama
OpenAI compatible
Storage
JSON files
Filesystem
External APIs
TMDB
Knaben
qBittorrent WebUI
Architecture
DDD
Clean Architecture
Hexagonal
```
## Security Considerations
```mermaid
flowchart TB
subgraph Security["Security Layers"]
direction TB
subgraph Input["Input Validation"]
PATH_VAL[Path Traversal Protection<br/>FileManager._sanitize_path]
INPUT_VAL[Input Sanitization<br/>Tool parameters]
end
subgraph Auth["Authentication"]
API_KEYS[API Keys<br/>Environment variables]
QBIT_AUTH[qBittorrent Auth<br/>Username/Password]
end
subgraph Access["Access Control"]
FOLDER_RESTRICT[Folder Restrictions<br/>Configured paths only]
SAFE_PATH[Safe Path Checks<br/>_is_safe_path()]
end
end
subgraph Env["Environment"]
ENV_FILE[.env file<br/>DEEPSEEK_API_KEY<br/>TMDB_API_KEY<br/>QBITTORRENT_*]
end
ENV_FILE --> Auth
Input --> Access
```
## Legend
| Icon | Meaning |
|------|---------|
| 🎬 | Agent Media System |
| 🤖 | AI Agent |
| 🧠 | Memory / LLM |
| 💾 | Persistent Storage |
| ⚡ | Transient / Fast |
| 📁 | Filesystem |
| 🔍 | Search |
| ⬇️ | Download |
| ☁️ | Cloud / External |
| 👤 | User / Client |

367
brain/docs/class_diagram.md Normal file
View File

@@ -0,0 +1,367 @@
# Class Diagram - Agent Media
```mermaid
classDiagram
direction TB
%% ===========================================
%% MEMORY SYSTEM
%% ===========================================
class Memory {
+Path storage_dir
+Path ltm_file
+LongTermMemory ltm
+ShortTermMemory stm
+EpisodicMemory episodic
+__init__(storage_dir: str)
+save() void
+get_context_for_prompt() Dict
+get_full_state() Dict
+clear_session() void
}
class LongTermMemory {
+Dict config
+Dict preferences
+Dict~str, List~ library
+List~Dict~ following
+get_config(key: str) Any
+set_config(key: str, value: Any) void
+has_config(key: str) bool
+add_to_library(media_type: str, media: Dict) void
+get_library(media_type: str) List
+follow_show(show: Dict) void
+to_dict() Dict
+from_dict(data: Dict)$ LongTermMemory
}
class ShortTermMemory {
+List~Dict~ conversation_history
+Dict current_workflow
+Dict extracted_entities
+str current_topic
+int max_history
+add_message(role: str, content: str) void
+get_recent_history(n: int) List
+start_workflow(type: str, target: Dict) void
+update_workflow_stage(stage: str) void
+end_workflow() void
+set_entity(key: str, value: Any) void
+get_entity(key: str) Any
+clear() void
+to_dict() Dict
}
class EpisodicMemory {
+Dict last_search_results
+List~Dict~ active_downloads
+List~Dict~ recent_errors
+Dict pending_question
+List~Dict~ background_events
+store_search_results(query: str, results: List) void
+get_result_by_index(index: int) Dict
+get_search_results() Dict
+add_active_download(download: Dict) void
+complete_download(task_id: str, path: str) Dict
+add_error(action: str, error: str) void
+set_pending_question(question: str, options: List) void
+resolve_pending_question(index: int) Dict
+add_background_event(type: str, data: Dict) void
+get_unread_events() List
+clear() void
+to_dict() Dict
}
Memory *-- LongTermMemory : ltm
Memory *-- ShortTermMemory : stm
Memory *-- EpisodicMemory : episodic
%% ===========================================
%% AGENT SYSTEM
%% ===========================================
class Agent {
+LLMClient llm
+Memory memory
+Dict~str, Tool~ tools
+PromptBuilder prompt_builder
+int max_tool_iterations
+__init__(llm: LLMClient, memory: Memory)
+step(user_input: str) str
-_parse_intent(text: str) Dict
-_execute_action(intent: Dict) Dict
-_check_unread_events() str
}
class LLMClient {
<<Protocol>>
+complete(messages: List) str
}
class DeepSeekClient {
+str api_key
+str model
+str base_url
+complete(messages: List) str
}
class OllamaClient {
+str base_url
+str model
+complete(messages: List) str
}
class PromptBuilder {
+Dict~str, Tool~ tools
+__init__(tools: Dict)
+build_system_prompt(memory: Memory) str
-_format_tools_description() str
-_format_episodic_context(memory: Memory) str
-_format_stm_context(memory: Memory) str
}
class Tool {
<<dataclass>>
+str name
+str description
+Callable func
+Dict parameters
}
Agent --> LLMClient : uses
Agent --> Memory : uses
Agent --> PromptBuilder : uses
Agent --> Tool : executes
DeepSeekClient ..|> LLMClient
OllamaClient ..|> LLMClient
PromptBuilder --> Tool : formats
%% ===========================================
%% DOMAIN - MOVIES
%% ===========================================
class Movie {
<<Entity>>
+ImdbId imdb_id
+MovieTitle title
+ReleaseYear release_year
+Quality quality
+FilePath file_path
+FileSize file_size
+int tmdb_id
+datetime added_at
}
class MovieTitle {
<<ValueObject>>
+str value
+__init__(value: str)
}
class ReleaseYear {
<<ValueObject>>
+int value
+__init__(value: int)
}
class Quality {
<<ValueObject>>
+str value
+__init__(value: str)
}
class MovieRepository {
<<Interface>>
+save(movie: Movie) void
+find_by_imdb_id(imdb_id: ImdbId) Movie
+find_all() List~Movie~
+delete(imdb_id: ImdbId) bool
+exists(imdb_id: ImdbId) bool
}
Movie --> MovieTitle
Movie --> ReleaseYear
Movie --> Quality
Movie --> ImdbId
%% ===========================================
%% DOMAIN - TV SHOWS
%% ===========================================
class TVShow {
<<Entity>>
+ImdbId imdb_id
+str title
+int seasons_count
+ShowStatus status
+int tmdb_id
+str first_air_date
+datetime added_at
}
class ShowStatus {
<<Enum>>
CONTINUING
ENDED
UNKNOWN
+from_string(value: str)$ ShowStatus
}
class TVShowRepository {
<<Interface>>
+save(show: TVShow) void
+find_by_imdb_id(imdb_id: ImdbId) TVShow
+find_all() List~TVShow~
+delete(imdb_id: ImdbId) bool
}
TVShow --> ShowStatus
TVShow --> ImdbId
%% ===========================================
%% DOMAIN - SHARED
%% ===========================================
class ImdbId {
<<ValueObject>>
+str value
+__init__(value: str)
+__str__() str
}
class FilePath {
<<ValueObject>>
+str value
+__init__(value: str)
}
class FileSize {
<<ValueObject>>
+int bytes
+__init__(bytes: int)
+to_human_readable() str
}
%% ===========================================
%% INFRASTRUCTURE - PERSISTENCE
%% ===========================================
class JsonMovieRepository {
+Memory memory
+__init__(memory: Memory)
+save(movie: Movie) void
+find_by_imdb_id(imdb_id: ImdbId) Movie
+find_all() List~Movie~
+delete(imdb_id: ImdbId) bool
}
class JsonTVShowRepository {
+Memory memory
+__init__(memory: Memory)
+save(show: TVShow) void
+find_by_imdb_id(imdb_id: ImdbId) TVShow
+find_all() List~TVShow~
+delete(imdb_id: ImdbId) bool
}
JsonMovieRepository ..|> MovieRepository
JsonTVShowRepository ..|> TVShowRepository
JsonMovieRepository --> Memory
JsonTVShowRepository --> Memory
%% ===========================================
%% INFRASTRUCTURE - API CLIENTS
%% ===========================================
class TMDBClient {
+str api_key
+str base_url
+search_movie(title: str) TMDBSearchResult
+search_tv(title: str) TMDBSearchResult
+get_external_ids(tmdb_id: int) Dict
}
class KnabenClient {
+str base_url
+search(query: str, limit: int) List~TorrentResult~
}
class QBittorrentClient {
+str host
+str username
+str password
+add_torrent(magnet: str) bool
+get_torrents() List
}
%% ===========================================
%% INFRASTRUCTURE - FILESYSTEM
%% ===========================================
class FileManager {
+Memory memory
+__init__(memory: Memory)
+set_folder_path(name: str, path: str) Dict
+list_folder(type: str, path: str) Dict
+move_file(source: str, dest: str) Dict
}
FileManager --> Memory
%% ===========================================
%% APPLICATION - USE CASES
%% ===========================================
class SearchMovieUseCase {
+TMDBClient tmdb_client
+execute(title: str) SearchMovieResponse
}
class SearchTorrentsUseCase {
+KnabenClient knaben_client
+execute(title: str, limit: int) SearchTorrentsResponse
}
class AddTorrentUseCase {
+QBittorrentClient qbittorrent_client
+execute(magnet: str) AddTorrentResponse
}
class SetFolderPathUseCase {
+FileManager file_manager
+execute(folder_name: str, path: str) SetFolderPathResponse
}
class ListFolderUseCase {
+FileManager file_manager
+execute(folder_type: str, path: str) ListFolderResponse
}
SearchMovieUseCase --> TMDBClient
SearchTorrentsUseCase --> KnabenClient
AddTorrentUseCase --> QBittorrentClient
SetFolderPathUseCase --> FileManager
ListFolderUseCase --> FileManager
```
## Legend
| Symbol | Meaning |
|--------|---------|
| `<<Entity>>` | Domain entity with identity |
| `<<ValueObject>>` | Immutable value object |
| `<<Interface>>` | Abstract interface/protocol |
| `<<Enum>>` | Enumeration |
| `<<dataclass>>` | Python dataclass |
| `<<Protocol>>` | Python Protocol (structural typing) |
| `*--` | Composition (owns) |
| `-->` | Association (uses) |
| `..\|>` | Implementation |
## Architecture Layers
1. **Domain Layer** - Business entities and rules (Movie, TVShow, ValueObjects)
2. **Application Layer** - Use cases orchestrating business logic
3. **Infrastructure Layer** - External services (APIs, filesystem, persistence)
4. **Agent Layer** - AI agent, LLM clients, tools, prompts

View File

@@ -0,0 +1,311 @@
# Component Diagram - Agent Media (DDD Architecture)
```mermaid
C4Component
title Component Diagram - Agent Media
Container_Boundary(agent_layer, "Agent Layer") {
Component(agent, "Agent", "Python", "Orchestrates LLM and tools")
Component(prompt_builder, "PromptBuilder", "Python", "Builds system prompts with context")
Component(registry, "Tool Registry", "Python", "Registers and binds tools")
Component_Boundary(llm_clients, "LLM Clients") {
Component(deepseek, "DeepSeekClient", "Python", "DeepSeek API client")
Component(ollama, "OllamaClient", "Python", "Ollama local client")
}
Component_Boundary(tools, "Tools") {
Component(api_tools, "API Tools", "Python", "find_torrent, add_torrent, etc.")
Component(fs_tools, "Filesystem Tools", "Python", "set_path, list_folder")
}
}
```
## Layered Architecture (DDD)
```mermaid
flowchart TB
subgraph Presentation["🌐 Presentation Layer"]
API["FastAPI Server<br/>/v1/chat/completions"]
end
subgraph Agent["🤖 Agent Layer"]
AG[Agent]
PB[PromptBuilder]
TR[Tool Registry]
subgraph LLM["LLM Clients"]
DS[DeepSeek]
OL[Ollama]
end
subgraph Tools["Tools"]
AT[API Tools]
FT[Filesystem Tools]
end
end
subgraph Application["⚙️ Application Layer"]
subgraph UseCases["Use Cases"]
UC1[SearchMovieUseCase]
UC2[SearchTorrentsUseCase]
UC3[AddTorrentUseCase]
UC4[SetFolderPathUseCase]
UC5[ListFolderUseCase]
end
subgraph DTOs["DTOs"]
DTO1[SearchMovieResponse]
DTO2[SearchTorrentsResponse]
DTO3[AddTorrentResponse]
end
end
subgraph Domain["📦 Domain Layer"]
subgraph Movies["movies/"]
ME[Movie Entity]
MVO[MovieTitle, Quality, ReleaseYear]
MR[MovieRepository Interface]
end
subgraph TVShows["tv_shows/"]
TE[TVShow Entity]
TVO[ShowStatus]
TR2[TVShowRepository Interface]
end
subgraph Subtitles["subtitles/"]
SE[Subtitle Entity]
SVO[Language, SubtitleFormat]
SR[SubtitleRepository Interface]
end
subgraph Shared["shared/"]
SH[ImdbId, FilePath, FileSize]
end
end
subgraph Infrastructure["🔧 Infrastructure Layer"]
subgraph Persistence["persistence/"]
MEM[Memory<br/>LTM + STM + Episodic]
JMR[JsonMovieRepository]
JTR[JsonTVShowRepository]
JSR[JsonSubtitleRepository]
end
subgraph APIs["api/"]
TMDB[TMDBClient]
KNAB[KnabenClient]
QBIT[QBittorrentClient]
end
subgraph FS["filesystem/"]
FM[FileManager]
end
end
subgraph External["☁️ External Services"]
TMDB_API[(TMDB API)]
KNAB_API[(Knaben API)]
QBIT_API[(qBittorrent)]
DISK[(Filesystem)]
end
%% Connections
API --> AG
AG --> PB
AG --> TR
AG --> LLM
TR --> Tools
AT --> UC1
AT --> UC2
AT --> UC3
FT --> UC4
FT --> UC5
UC1 --> TMDB
UC2 --> KNAB
UC3 --> QBIT
UC4 --> FM
UC5 --> FM
JMR --> MEM
JTR --> MEM
JSR --> MEM
FM --> MEM
JMR -.->|implements| MR
JTR -.->|implements| TR2
JSR -.->|implements| SR
TMDB --> TMDB_API
KNAB --> KNAB_API
QBIT --> QBIT_API
FM --> DISK
MEM --> DISK
%% Styling
classDef presentation fill:#e1f5fe
classDef agent fill:#fff3e0
classDef application fill:#f3e5f5
classDef domain fill:#e8f5e9
classDef infrastructure fill:#fce4ec
classDef external fill:#f5f5f5
class API presentation
class AG,PB,TR,DS,OL,AT,FT agent
class UC1,UC2,UC3,UC4,UC5,DTO1,DTO2,DTO3 application
class ME,MVO,MR,TE,TVO,TR2,SE,SVO,SR,SH domain
class MEM,JMR,JTR,JSR,TMDB,KNAB,QBIT,FM infrastructure
class TMDB_API,KNAB_API,QBIT_API,DISK external
```
## Memory Architecture
```mermaid
flowchart LR
subgraph Memory["Memory System"]
direction TB
subgraph LTM["💾 Long-Term Memory<br/>(Persistent - JSON)"]
CONFIG[config<br/>download_folder, tvshow_folder...]
PREFS[preferences<br/>quality, languages...]
LIB[library<br/>movies[], tv_shows[]]
FOLLOW[following<br/>watchlist]
end
subgraph STM["🧠 Short-Term Memory<br/>(Session - RAM)"]
HIST[conversation_history]
WORKFLOW[current_workflow]
ENTITIES[extracted_entities]
TOPIC[current_topic]
end
subgraph EPISODIC["⚡ Episodic Memory<br/>(Transient - RAM)"]
SEARCH[last_search_results<br/>indexed torrents]
DOWNLOADS[active_downloads]
ERRORS[recent_errors]
PENDING[pending_question]
EVENTS[background_events]
end
end
subgraph Storage["Storage"]
JSON[(ltm.json)]
end
LTM -->|save| JSON
JSON -->|load| LTM
STM -.->|cleared on| RESTART[Server Restart]
EPISODIC -.->|cleared on| RESTART
```
## Data Flow
```mermaid
flowchart LR
subgraph Input
USER[User Request]
end
subgraph Processing
direction TB
FASTAPI[FastAPI]
AGENT[Agent]
TOOLS[Tools]
USECASES[Use Cases]
end
subgraph External
direction TB
TMDB[(TMDB)]
KNABEN[(Knaben)]
QBIT[(qBittorrent)]
end
subgraph Memory
direction TB
LTM[(LTM)]
STM[(STM)]
EPIS[(Episodic)]
end
USER -->|HTTP POST| FASTAPI
FASTAPI -->|step()| AGENT
AGENT -->|execute| TOOLS
TOOLS -->|call| USECASES
USECASES -->|search| TMDB
USECASES -->|search| KNABEN
USECASES -->|add| QBIT
AGENT <-->|read/write| LTM
AGENT <-->|read/write| STM
TOOLS <-->|read/write| EPIS
AGENT -->|response| FASTAPI
FASTAPI -->|JSON| USER
```
## Dependency Direction
```mermaid
flowchart BT
subgraph External["External"]
EXT[APIs, Filesystem]
end
subgraph Infra["Infrastructure"]
INF[Clients, Repositories, Memory]
end
subgraph App["Application"]
APP[Use Cases, DTOs]
end
subgraph Dom["Domain"]
DOM[Entities, Value Objects, Interfaces]
end
subgraph Agent["Agent"]
AGT[Agent, Tools, Prompts]
end
subgraph Pres["Presentation"]
PRES[FastAPI]
end
EXT --> Infra
Infra --> App
Infra -.->|implements| Dom
App --> Dom
Agent --> App
Agent --> Infra
Pres --> Agent
style Dom fill:#e8f5e9,stroke:#4caf50,stroke-width:3px
style Infra fill:#fce4ec,stroke:#e91e63
style App fill:#f3e5f5,stroke:#9c27b0
style Agent fill:#fff3e0,stroke:#ff9800
style Pres fill:#e1f5fe,stroke:#03a9f4
```
## Legend
| Layer | Responsibility | Examples |
|-------|---------------|----------|
| 🌐 **Presentation** | HTTP interface, request/response handling | FastAPI endpoints |
| 🤖 **Agent** | AI orchestration, LLM interaction, tools | Agent, PromptBuilder, Tools |
| ⚙️ **Application** | Use case orchestration, DTOs | SearchMovieUseCase, SearchTorrentsResponse |
| 📦 **Domain** | Business entities, rules, interfaces | Movie, TVShow, ImdbId, MovieRepository |
| 🔧 **Infrastructure** | External service implementations | TMDBClient, JsonMovieRepository, Memory |
| ☁️ **External** | Third-party services | TMDB API, qBittorrent, Filesystem |
## Key Principles
1. **Dependency Inversion**: Domain defines interfaces, Infrastructure implements them
2. **Clean Architecture**: Dependencies point inward (toward Domain)
3. **Separation of Concerns**: Each layer has a single responsibility
4. **Memory Segregation**: LTM (persistent), STM (session), Episodic (transient)

366
brain/docs/flowchart.md Normal file
View File

@@ -0,0 +1,366 @@
# Flowcharts - Agent Media
## 1. Main Application Flow
```mermaid
flowchart TD
START([Application Start]) --> INIT_MEM[Initialize Memory Context<br/>init_memory]
INIT_MEM --> INIT_LLM{LLM Provider?}
INIT_LLM -->|OLLAMA| OLLAMA[Create OllamaClient]
INIT_LLM -->|DEEPSEEK| DEEPSEEK[Create DeepSeekClient]
OLLAMA --> INIT_AGENT[Create Agent]
DEEPSEEK --> INIT_AGENT
INIT_AGENT --> INIT_TOOLS[Register Tools<br/>make_tools]
INIT_TOOLS --> START_SERVER[Start FastAPI Server<br/>:8000]
START_SERVER --> WAIT_REQ[/Wait for Request/]
WAIT_REQ --> REQ_TYPE{Request Type?}
REQ_TYPE -->|GET /health| HEALTH[Return health status]
REQ_TYPE -->|GET /v1/models| MODELS[Return model list]
REQ_TYPE -->|GET /memory/state| MEM_STATE[Return memory state]
REQ_TYPE -->|POST /memory/clear-session| CLEAR_SESSION[Clear STM + Episodic]
REQ_TYPE -->|POST /v1/chat/completions| CHAT[Process Chat Request]
HEALTH --> WAIT_REQ
MODELS --> WAIT_REQ
MEM_STATE --> WAIT_REQ
CLEAR_SESSION --> WAIT_REQ
CHAT --> AGENT_STEP[agent.step]
AGENT_STEP --> RETURN_RESP[Return Response]
RETURN_RESP --> WAIT_REQ
```
## 2. Agent Step Flow (Core Logic)
```mermaid
flowchart TD
START([agent.step called]) --> GET_MEM[Get Memory from Context]
GET_MEM --> CHECK_EVENTS[Check Unread Events]
CHECK_EVENTS --> HAS_EVENTS{Has Events?}
HAS_EVENTS -->|Yes| FORMAT_EVENTS[Format Event Notifications]
HAS_EVENTS -->|No| BUILD_PROMPT
FORMAT_EVENTS --> BUILD_PROMPT
BUILD_PROMPT[Build System Prompt<br/>with Memory Context]
BUILD_PROMPT --> INIT_MSGS[Initialize Messages Array]
INIT_MSGS --> ADD_SYSTEM[Add System Prompt]
ADD_SYSTEM --> GET_HISTORY[Get STM History]
GET_HISTORY --> ADD_HISTORY[Add History Messages]
ADD_HISTORY --> ADD_NOTIF{Has Notifications?}
ADD_NOTIF -->|Yes| ADD_NOTIF_MSG[Add Notification Message]
ADD_NOTIF -->|No| ADD_USER
ADD_NOTIF_MSG --> ADD_USER[Add User Input]
ADD_USER --> LOOP_START[/Tool Execution Loop/]
LOOP_START --> CHECK_ITER{iteration < max?}
CHECK_ITER -->|No| MAX_REACHED[Request Final Response]
CHECK_ITER -->|Yes| CALL_LLM[Call LLM.complete]
MAX_REACHED --> FINAL_LLM[Call LLM.complete]
FINAL_LLM --> SAVE_FINAL[Save to STM History]
SAVE_FINAL --> RETURN_FINAL([Return Response])
CALL_LLM --> PARSE_INTENT[Parse Intent from Response]
PARSE_INTENT --> IS_TOOL{Is Tool Call?}
IS_TOOL -->|No| SAVE_HISTORY[Save to STM History]
SAVE_HISTORY --> SAVE_LTM[Save LTM]
SAVE_LTM --> RETURN_TEXT([Return Text Response])
IS_TOOL -->|Yes| EXEC_TOOL[Execute Tool]
EXEC_TOOL --> ADD_RESULT[Add Tool Result to Messages]
ADD_RESULT --> INC_ITER[iteration++]
INC_ITER --> LOOP_START
```
## 3. Tool Execution Flow
```mermaid
flowchart TD
START([_execute_action called]) --> GET_ACTION[Extract action name & args]
GET_ACTION --> FIND_TOOL{Tool exists?}
FIND_TOOL -->|No| UNKNOWN[Return unknown_tool error]
UNKNOWN --> END_ERR([Return Error])
FIND_TOOL -->|Yes| CALL_FUNC[Call tool.func with args]
CALL_FUNC --> EXEC_OK{Execution OK?}
EXEC_OK -->|TypeError| BAD_ARGS[Log bad arguments error]
EXEC_OK -->|Exception| EXEC_ERR[Log execution error]
EXEC_OK -->|Success| CHECK_RESULT{Result has error?}
BAD_ARGS --> ADD_ERR_MEM[Add error to Episodic Memory]
EXEC_ERR --> ADD_ERR_MEM
ADD_ERR_MEM --> END_ERR
CHECK_RESULT -->|Yes| ADD_ERR_MEM2[Add error to Episodic Memory]
ADD_ERR_MEM2 --> RETURN_RESULT
CHECK_RESULT -->|No| RETURN_RESULT([Return Result])
```
## 4. Prompt Building Flow
```mermaid
flowchart TD
START([build_system_prompt called]) --> GET_MEM[Get Memory from Context]
GET_MEM --> FORMAT_TOOLS[Format Tools Description]
FORMAT_TOOLS --> FORMAT_PARAMS[Format Parameters Description]
FORMAT_PARAMS --> CHECK_MISSING[Check Missing Required Params]
CHECK_MISSING --> HAS_MISSING{Has Missing?}
HAS_MISSING -->|Yes| FORMAT_MISSING[Format Missing Params Info]
HAS_MISSING -->|No| FORMAT_EPISODIC
FORMAT_MISSING --> FORMAT_EPISODIC
FORMAT_EPISODIC[Format Episodic Context]
FORMAT_EPISODIC --> HAS_SEARCH{Has Search Results?}
HAS_SEARCH -->|Yes| ADD_SEARCH[Add Search Results Summary]
HAS_SEARCH -->|No| CHECK_PENDING
ADD_SEARCH --> CHECK_PENDING
CHECK_PENDING{Has Pending Question?}
CHECK_PENDING -->|Yes| ADD_PENDING[Add Pending Question]
CHECK_PENDING -->|No| CHECK_DOWNLOADS
ADD_PENDING --> CHECK_DOWNLOADS
CHECK_DOWNLOADS{Has Active Downloads?}
CHECK_DOWNLOADS -->|Yes| ADD_DOWNLOADS[Add Downloads Status]
CHECK_DOWNLOADS -->|No| CHECK_ERRORS
ADD_DOWNLOADS --> CHECK_ERRORS
CHECK_ERRORS{Has Recent Errors?}
CHECK_ERRORS -->|Yes| ADD_ERRORS[Add Last Error]
CHECK_ERRORS -->|No| FORMAT_STM
ADD_ERRORS --> FORMAT_STM
FORMAT_STM[Format STM Context]
FORMAT_STM --> HAS_WORKFLOW{Has Workflow?}
HAS_WORKFLOW -->|Yes| ADD_WORKFLOW[Add Workflow Info]
HAS_WORKFLOW -->|No| CHECK_TOPIC
ADD_WORKFLOW --> CHECK_TOPIC
CHECK_TOPIC{Has Topic?}
CHECK_TOPIC -->|Yes| ADD_TOPIC[Add Current Topic]
CHECK_TOPIC -->|No| CHECK_ENTITIES
ADD_TOPIC --> CHECK_ENTITIES
CHECK_ENTITIES{Has Entities?}
CHECK_ENTITIES -->|Yes| ADD_ENTITIES[Add Extracted Entities]
CHECK_ENTITIES -->|No| BUILD_FINAL
ADD_ENTITIES --> BUILD_FINAL
BUILD_FINAL[Assemble Final Prompt]
BUILD_FINAL --> RETURN([Return System Prompt])
```
## 5. Memory System Flow
```mermaid
flowchart TD
subgraph Initialization
INIT([init_memory called]) --> CREATE_MEM[Create Memory Instance]
CREATE_MEM --> LOAD_LTM{LTM file exists?}
LOAD_LTM -->|Yes| READ_FILE[Read ltm.json]
LOAD_LTM -->|No| CREATE_DEFAULT[Create Default LTM]
READ_FILE --> PARSE_JSON{Parse OK?}
PARSE_JSON -->|Yes| RESTORE_LTM[Restore LTM from Dict]
PARSE_JSON -->|No| CREATE_DEFAULT
CREATE_DEFAULT --> CREATE_STM[Create Empty STM]
RESTORE_LTM --> CREATE_STM
CREATE_STM --> CREATE_EPIS[Create Empty Episodic]
CREATE_EPIS --> SET_CTX[Set in Context Variable]
SET_CTX --> RETURN_MEM([Return Memory])
end
subgraph Access
GET([get_memory called]) --> CHECK_CTX{Context has Memory?}
CHECK_CTX -->|Yes| RETURN_CTX([Return Memory])
CHECK_CTX -->|No| RAISE_ERR[Raise RuntimeError]
end
subgraph Save
SAVE([memory.save called]) --> SERIALIZE[Serialize LTM to Dict]
SERIALIZE --> WRITE_JSON[Write to ltm.json]
WRITE_JSON --> SAVE_OK{Write OK?}
SAVE_OK -->|Yes| DONE([Done])
SAVE_OK -->|No| LOG_ERR[Log Error & Raise]
end
```
## 6. Torrent Search & Download Flow
```mermaid
flowchart TD
subgraph Search
SEARCH_START([find_torrent called]) --> CREATE_UC[Create SearchTorrentsUseCase]
CREATE_UC --> EXEC_SEARCH[Execute Search via Knaben API]
EXEC_SEARCH --> SEARCH_OK{Results Found?}
SEARCH_OK -->|No| RETURN_ERR([Return Error])
SEARCH_OK -->|Yes| GET_MEM[Get Memory]
GET_MEM --> STORE_RESULTS[Store in Episodic Memory<br/>with indexes 1,2,3...]
STORE_RESULTS --> SET_TOPIC[Set Topic: selecting_torrent]
SET_TOPIC --> RETURN_RESULTS([Return Results])
end
subgraph "Get by Index"
GET_START([get_torrent_by_index called]) --> GET_MEM2[Get Memory]
GET_MEM2 --> HAS_RESULTS{Has Search Results?}
HAS_RESULTS -->|No| NO_RESULTS([Return not_found Error])
HAS_RESULTS -->|Yes| FIND_INDEX[Find Result by Index]
FIND_INDEX --> FOUND{Found?}
FOUND -->|No| NOT_FOUND([Return not_found Error])
FOUND -->|Yes| RETURN_TORRENT([Return Torrent Data])
end
subgraph "Add by Index"
ADD_START([add_torrent_by_index called]) --> CALL_GET[Call get_torrent_by_index]
CALL_GET --> GET_OK{Got Torrent?}
GET_OK -->|No| RETURN_GET_ERR([Return Error])
GET_OK -->|Yes| HAS_MAGNET{Has Magnet Link?}
HAS_MAGNET -->|No| NO_MAGNET([Return no_magnet Error])
HAS_MAGNET -->|Yes| CALL_ADD[Call add_torrent_to_qbittorrent]
CALL_ADD --> ADD_OK{Added OK?}
ADD_OK -->|No| RETURN_ADD_ERR([Return Error])
ADD_OK -->|Yes| ADD_NAME[Add torrent_name to Result]
ADD_NAME --> RETURN_SUCCESS([Return Success])
end
subgraph "Add to qBittorrent"
QB_START([add_torrent_to_qbittorrent called]) --> CREATE_UC2[Create AddTorrentUseCase]
CREATE_UC2 --> EXEC_ADD[Execute Add via qBittorrent API]
EXEC_ADD --> QB_OK{Added OK?}
QB_OK -->|No| QB_ERR([Return Error])
QB_OK -->|Yes| GET_MEM3[Get Memory]
GET_MEM3 --> FIND_NAME[Find Torrent Name from Search]
FIND_NAME --> ADD_DOWNLOAD[Add to Active Downloads]
ADD_DOWNLOAD --> SET_TOPIC2[Set Topic: downloading]
SET_TOPIC2 --> END_WORKFLOW[End Current Workflow]
END_WORKFLOW --> QB_SUCCESS([Return Success])
end
```
## 7. Filesystem Operations Flow
```mermaid
flowchart TD
subgraph "Set Folder Path"
SET_START([set_path_for_folder called]) --> VALIDATE_NAME[Validate Folder Name]
VALIDATE_NAME --> NAME_OK{Valid Name?}
NAME_OK -->|No| INVALID_NAME([Return validation_failed])
NAME_OK -->|Yes| RESOLVE_PATH[Resolve Path]
RESOLVE_PATH --> PATH_EXISTS{Path Exists?}
PATH_EXISTS -->|No| NOT_EXISTS([Return invalid_path])
PATH_EXISTS -->|Yes| IS_DIR{Is Directory?}
IS_DIR -->|No| NOT_DIR([Return invalid_path])
IS_DIR -->|Yes| IS_READABLE{Is Readable?}
IS_READABLE -->|No| NO_READ([Return permission_denied])
IS_READABLE -->|Yes| GET_MEM[Get Memory]
GET_MEM --> SET_CONFIG[Set in LTM Config]
SET_CONFIG --> SAVE_MEM[Save Memory]
SAVE_MEM --> SET_SUCCESS([Return Success])
end
subgraph "List Folder"
LIST_START([list_folder called]) --> VALIDATE_TYPE[Validate Folder Type]
VALIDATE_TYPE --> TYPE_OK{Valid Type?}
TYPE_OK -->|No| INVALID_TYPE([Return validation_failed])
TYPE_OK -->|Yes| SANITIZE[Sanitize Path]
SANITIZE --> SAFE{Path Safe?}
SAFE -->|No| TRAVERSAL([Return forbidden])
SAFE -->|Yes| GET_MEM2[Get Memory]
GET_MEM2 --> GET_CONFIG[Get Folder from Config]
GET_CONFIG --> CONFIGURED{Folder Configured?}
CONFIGURED -->|No| NOT_SET([Return folder_not_set])
CONFIGURED -->|Yes| BUILD_TARGET[Build Target Path]
BUILD_TARGET --> CHECK_SAFE[Check Path is Safe]
CHECK_SAFE --> SAFE2{Within Base?}
SAFE2 -->|No| FORBIDDEN([Return forbidden])
SAFE2 -->|Yes| TARGET_EXISTS{Target Exists?}
TARGET_EXISTS -->|No| NOT_FOUND([Return not_found])
TARGET_EXISTS -->|Yes| TARGET_DIR{Is Directory?}
TARGET_DIR -->|No| NOT_A_DIR([Return not_a_directory])
TARGET_DIR -->|Yes| LIST_DIR[List Directory Contents]
LIST_DIR --> LIST_OK{Permission OK?}
LIST_OK -->|No| PERM_DENIED([Return permission_denied])
LIST_OK -->|Yes| LIST_SUCCESS([Return Entries])
end
```
## 8. LLM Communication Flow
```mermaid
flowchart TD
subgraph "DeepSeek Client"
DS_START([complete called]) --> DS_BUILD[Build Request Body]
DS_BUILD --> DS_HEADERS[Set Headers with API Key]
DS_HEADERS --> DS_POST[POST to DeepSeek API]
DS_POST --> DS_OK{Response OK?}
DS_OK -->|No| DS_ERR{Error Type?}
DS_ERR -->|401| DS_AUTH([Raise LLMAuthenticationError])
DS_ERR -->|429| DS_RATE([Raise LLMRateLimitError])
DS_ERR -->|Other| DS_API([Raise LLMAPIError])
DS_OK -->|Yes| DS_PARSE[Parse JSON Response]
DS_PARSE --> DS_EXTRACT[Extract Content]
DS_EXTRACT --> DS_RETURN([Return Content String])
end
subgraph "Ollama Client"
OL_START([complete called]) --> OL_BUILD[Build Request Body]
OL_BUILD --> OL_POST[POST to Ollama API]
OL_POST --> OL_OK{Response OK?}
OL_OK -->|No| OL_ERR([Raise LLMAPIError])
OL_OK -->|Yes| OL_PARSE[Parse JSON Response]
OL_PARSE --> OL_EXTRACT[Extract Message Content]
OL_EXTRACT --> OL_RETURN([Return Content String])
end
```
## Legend
| Symbol | Meaning |
|--------|---------|
| `([text])` | Start/End (Terminal) |
| `[text]` | Process |
| `{text}` | Decision |
| `/text/` | Input/Output |
| `-->` | Flow direction |
| `-->\|label\|` | Conditional flow |

View File

@@ -0,0 +1,264 @@
# Sequence Diagrams - Agent Media
## 1. Torrent Search and Download Flow
```mermaid
sequenceDiagram
autonumber
participant User
participant FastAPI as FastAPI Server
participant Agent
participant PromptBuilder
participant LLM as LLM (DeepSeek/Ollama)
participant Tools as Tool Registry
participant Memory
participant Knaben as Knaben API
participant qBit as qBittorrent
User->>FastAPI: POST /v1/chat/completions<br/>"Find torrents for Inception 1080p"
FastAPI->>Agent: step(user_input)
Agent->>Memory: stm.get_recent_history()
Memory-->>Agent: conversation history
Agent->>PromptBuilder: build_system_prompt(memory)
PromptBuilder->>Memory: ltm.config, episodic state
Memory-->>PromptBuilder: context data
PromptBuilder-->>Agent: system prompt with context
Agent->>LLM: complete(messages)
LLM-->>Agent: {"action": {"name": "find_torrents", "args": {...}}}
Agent->>Agent: _parse_intent(response)
Agent->>Tools: execute find_torrents
Tools->>Knaben: search("Inception 1080p")
Knaben-->>Tools: torrent results
Tools->>Memory: episodic.store_search_results()
Memory-->>Tools: stored with indexes (1, 2, 3...)
Tools-->>Agent: {"status": "ok", "torrents": [...]}
Agent->>LLM: complete(messages + tool_result)
LLM-->>Agent: "I found 5 torrents for Inception..."
Agent->>Memory: stm.add_message("user", input)
Agent->>Memory: stm.add_message("assistant", response)
Agent->>Memory: save()
Agent-->>FastAPI: final response
FastAPI-->>User: JSON response
Note over User,qBit: User selects a torrent
User->>FastAPI: POST /v1/chat/completions<br/>"Download the 2nd one"
FastAPI->>Agent: step(user_input)
Agent->>PromptBuilder: build_system_prompt(memory)
PromptBuilder->>Memory: episodic.last_search_results
Note right of Memory: Results still in memory:<br/>1. Inception.2010.1080p...<br/>2. Inception.1080p.BluRay...
Memory-->>PromptBuilder: context with search results
PromptBuilder-->>Agent: prompt showing available results
Agent->>LLM: complete(messages)
LLM-->>Agent: {"action": {"name": "add_torrent_by_index", "args": {"index": 2}}}
Agent->>Tools: execute add_torrent_by_index(index=2)
Tools->>Memory: episodic.get_result_by_index(2)
Memory-->>Tools: torrent data with magnet link
Tools->>qBit: add_torrent(magnet_link)
qBit-->>Tools: success
Tools->>Memory: episodic.add_active_download()
Tools-->>Agent: {"status": "ok", "torrent_name": "Inception.1080p.BluRay"}
Agent->>LLM: complete(messages + tool_result)
LLM-->>Agent: "I've added Inception to qBittorrent!"
Agent-->>FastAPI: final response
FastAPI-->>User: JSON response
```
## 2. Folder Configuration Flow
```mermaid
sequenceDiagram
autonumber
participant User
participant FastAPI as FastAPI Server
participant Agent
participant LLM as LLM
participant Tools as Tool Registry
participant FileManager
participant Memory
participant FS as Filesystem
User->>FastAPI: POST /v1/chat/completions<br/>"Set download folder to /mnt/media/downloads"
FastAPI->>Agent: step(user_input)
Agent->>LLM: complete(messages)
LLM-->>Agent: {"action": {"name": "set_path_for_folder", "args": {...}}}
Agent->>Tools: execute set_path_for_folder
Tools->>FileManager: set_folder_path("download", "/mnt/media/downloads")
FileManager->>FS: Path.exists()?
FS-->>FileManager: true
FileManager->>FS: Path.is_dir()?
FS-->>FileManager: true
FileManager->>FS: os.access(R_OK)?
FS-->>FileManager: true
FileManager->>Memory: ltm.set_config("download_folder", path)
FileManager->>Memory: save()
Memory->>FS: write ltm.json
FileManager-->>Tools: {"status": "ok", "path": "/mnt/media/downloads"}
Tools-->>Agent: result
Agent->>LLM: complete(messages + tool_result)
LLM-->>Agent: "Download folder set to /mnt/media/downloads"
Agent-->>FastAPI: final response
FastAPI-->>User: JSON response
```
## 3. Multi-Tool Workflow (Search Movie → Find Torrents → Download)
```mermaid
sequenceDiagram
autonumber
participant User
participant Agent
participant LLM as LLM
participant TMDB as TMDB API
participant Knaben as Knaben API
participant qBit as qBittorrent
participant Memory
User->>Agent: "Download Dune 2 in 4K"
rect rgb(240, 248, 255)
Note over Agent,TMDB: Step 1: Identify the movie
Agent->>LLM: complete(messages)
LLM-->>Agent: {"action": "find_media_imdb_id", "args": {"media_title": "Dune 2"}}
Agent->>TMDB: search_movie("Dune 2")
TMDB-->>Agent: {title: "Dune: Part Two", imdb_id: "tt15239678", year: 2024}
Agent->>Memory: stm.set_entity("last_media_search", {...})
end
rect rgb(255, 248, 240)
Note over Agent,Knaben: Step 2: Search for torrents
Agent->>LLM: complete(messages + movie_info)
LLM-->>Agent: {"action": "find_torrents", "args": {"media_title": "Dune Part Two 2024 4K"}}
Agent->>Knaben: search("Dune Part Two 2024 4K")
Knaben-->>Agent: [torrent1, torrent2, torrent3...]
Agent->>Memory: episodic.store_search_results()
end
rect rgb(240, 255, 240)
Note over Agent,qBit: Step 3: Add best torrent
Agent->>LLM: complete(messages + torrents)
LLM-->>Agent: {"action": "add_torrent_by_index", "args": {"index": 1}}
Agent->>Memory: episodic.get_result_by_index(1)
Memory-->>Agent: torrent with magnet
Agent->>qBit: add_torrent(magnet)
qBit-->>Agent: success
Agent->>Memory: episodic.add_active_download()
end
Agent->>LLM: complete(messages + all_results)
LLM-->>Agent: "I found Dune: Part Two (2024) and added the 4K torrent to qBittorrent!"
Agent-->>User: Final response
```
## 4. Error Handling Flow
```mermaid
sequenceDiagram
autonumber
participant User
participant Agent
participant LLM as LLM
participant Tools as Tool Registry
participant Memory
participant API as External API
User->>Agent: "Download the 5th torrent"
Agent->>LLM: complete(messages)
LLM-->>Agent: {"action": "add_torrent_by_index", "args": {"index": 5}}
Agent->>Tools: execute add_torrent_by_index(5)
Tools->>Memory: episodic.get_result_by_index(5)
alt No search results
Memory-->>Tools: None (no previous search)
Tools-->>Agent: {"status": "error", "error": "not_found"}
Agent->>Memory: episodic.add_error("add_torrent_by_index", "not_found")
else Index out of range
Memory-->>Tools: None (only 3 results)
Tools-->>Agent: {"status": "error", "error": "not_found"}
Agent->>Memory: episodic.add_error("add_torrent_by_index", "not_found")
end
Agent->>LLM: complete(messages + error)
LLM-->>Agent: "I couldn't find torrent #5. Please search for torrents first."
Agent-->>User: Error explanation
Note over User,API: User searches first
User->>Agent: "Search for Matrix 1999"
Agent->>API: search("Matrix 1999")
API-->>Agent: [3 results]
Agent->>Memory: episodic.store_search_results()
Agent-->>User: "Found 3 torrents..."
User->>Agent: "Download the 2nd one"
Agent->>Memory: episodic.get_result_by_index(2)
Memory-->>Agent: torrent data ✓
Agent-->>User: "Added to qBittorrent!"
```
## 5. Background Events Flow
```mermaid
sequenceDiagram
autonumber
participant User
participant Agent
participant Memory
participant qBit as qBittorrent
participant LLM as LLM
Note over qBit,Memory: Background: Download completes
qBit--)Memory: episodic.complete_download(task_id, file_path)
Memory->>Memory: add_background_event("download_complete", {...})
Note over User,LLM: Later: User sends a message
User->>Agent: "What's new?"
Agent->>Memory: episodic.get_unread_events()
Memory-->>Agent: [{type: "download_complete", data: {name: "Inception.1080p"}}]
Agent->>Agent: _check_unread_events()
Note right of Agent: Formats notification:<br/>"Download completed: Inception.1080p"
Agent->>LLM: complete(messages + notification)
LLM-->>Agent: "Good news! Inception.1080p has finished downloading."
Agent-->>User: Response with notification
```
## Legend
| Element | Description |
|---------|-------------|
| `rect rgb(...)` | Grouped steps in a workflow |
| `alt/else` | Conditional branches |
| `Note` | Explanatory notes |
| `-->>` | Response/return |
| `->>` | Request/call |
| `--))` | Async event |

1
brain/domain/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Domain layer - Business logic and entities."""

View File

@@ -0,0 +1,16 @@
"""Movies domain - Business logic for movie management."""
from .entities import Movie
from .exceptions import InvalidMovieData, MovieNotFound
from .services import MovieService
from .value_objects import MovieTitle, Quality, ReleaseYear
__all__ = [
"Movie",
"MovieTitle",
"ReleaseYear",
"Quality",
"MovieNotFound",
"InvalidMovieData",
"MovieService",
]

View File

@@ -0,0 +1,88 @@
"""Movie domain entities."""
from dataclasses import dataclass, field
from datetime import datetime
from ..shared.value_objects import FilePath, FileSize, ImdbId
from .value_objects import MovieTitle, Quality, ReleaseYear
@dataclass
class Movie:
"""
Movie entity representing a movie in the media library.
This is the main aggregate root for the movies domain.
"""
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)
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 has_file(self) -> bool:
"""Check if the movie has an associated file."""
return self.file_path is not None and self.file_path.exists()
def is_downloaded(self) -> bool:
"""Check if the movie is downloaded (has a file)."""
return self.has_file()
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}')"

View File

@@ -0,0 +1,21 @@
"""Movie domain exceptions."""
from ..shared.exceptions import DomainException, NotFoundError
class MovieNotFound(NotFoundError):
"""Raised when a movie is not found."""
pass
class InvalidMovieData(DomainException):
"""Raised when movie data is invalid."""
pass
class MovieAlreadyExists(DomainException):
"""Raised when trying to add a movie that already exists."""
pass

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

View File

@@ -0,0 +1,192 @@
"""Movie domain services - Business logic."""
import logging
import re
from ..shared.value_objects import FilePath, ImdbId
from .entities import Movie
from .exceptions import MovieAlreadyExists, MovieNotFound
from .repositories import MovieRepository
from .value_objects import Quality
logger = logging.getLogger(__name__)
class MovieService:
"""
Domain service for movie-related business logic.
This service contains business rules that don't naturally fit
within a single entity.
"""
def __init__(self, repository: MovieRepository):
"""
Initialize movie service.
Args:
repository: Movie repository for persistence
"""
self.repository = repository
def add_movie(self, movie: Movie) -> None:
"""
Add a new movie to the library.
Args:
movie: Movie entity to add
Raises:
MovieAlreadyExists: If movie with same IMDb ID already exists
"""
if self.repository.exists(movie.imdb_id):
raise MovieAlreadyExists(
f"Movie with IMDb ID {movie.imdb_id} already exists"
)
self.repository.save(movie)
logger.info(f"Added movie: {movie.title.value} ({movie.imdb_id})")
def get_movie(self, imdb_id: ImdbId) -> Movie:
"""
Get a movie by IMDb ID.
Args:
imdb_id: IMDb ID of the movie
Returns:
Movie entity
Raises:
MovieNotFound: If movie not found
"""
movie = self.repository.find_by_imdb_id(imdb_id)
if not movie:
raise MovieNotFound(f"Movie with IMDb ID {imdb_id} not found")
return movie
def get_all_movies(self) -> list[Movie]:
"""
Get all movies in the library.
Returns:
List of all movies
"""
return self.repository.find_all()
def update_movie(self, movie: Movie) -> None:
"""
Update an existing movie.
Args:
movie: Movie entity with updated data
Raises:
MovieNotFound: If movie doesn't exist
"""
if not self.repository.exists(movie.imdb_id):
raise MovieNotFound(f"Movie with IMDb ID {movie.imdb_id} not found")
self.repository.save(movie)
logger.info(f"Updated movie: {movie.title.value} ({movie.imdb_id})")
def remove_movie(self, imdb_id: ImdbId) -> None:
"""
Remove a movie from the library.
Args:
imdb_id: IMDb ID of the movie to remove
Raises:
MovieNotFound: If movie not found
"""
if not self.repository.delete(imdb_id):
raise MovieNotFound(f"Movie with IMDb ID {imdb_id} not found")
logger.info(f"Removed movie with IMDb ID: {imdb_id}")
def detect_quality_from_filename(self, filename: str) -> Quality:
"""
Detect video quality from filename.
Args:
filename: Filename to analyze
Returns:
Detected quality or UNKNOWN
"""
filename_lower = filename.lower()
# Check for quality indicators
if "2160p" in filename_lower or "4k" in filename_lower:
return Quality.UHD_4K
elif "1080p" in filename_lower:
return Quality.FULL_HD
elif "720p" in filename_lower:
return Quality.HD
elif "480p" in filename_lower:
return Quality.SD
return Quality.UNKNOWN
def extract_year_from_filename(self, filename: str) -> int | None:
"""
Extract release year from filename.
Args:
filename: Filename to analyze
Returns:
Year if found, None otherwise
"""
# Look for 4-digit year in parentheses or standalone
# Examples: "Movie (2010)", "Movie.2010.1080p"
patterns = [
r"\((\d{4})\)", # (2010)
r"\.(\d{4})\.", # .2010.
r"\s(\d{4})\s", # 2010
]
for pattern in patterns:
match = re.search(pattern, filename)
if match:
year = int(match.group(1))
# Validate year is reasonable
if 1888 <= year <= 2100:
return year
return None
def validate_movie_file(self, file_path: FilePath) -> bool:
"""
Validate that a file is a valid movie file.
Args:
file_path: Path to the file
Returns:
True if valid movie file, False otherwise
"""
if not file_path.exists():
logger.warning(f"File does not exist: {file_path}")
return False
if not file_path.is_file():
logger.warning(f"Path is not a file: {file_path}")
return False
# Check file extension
valid_extensions = {".mkv", ".mp4", ".avi", ".mov", ".wmv", ".flv", ".webm"}
if file_path.value.suffix.lower() not in valid_extensions:
logger.warning(f"Invalid file extension: {file_path.value.suffix}")
return False
# Check file size (should be at least 100 MB for a movie)
min_size = 100 * 1024 * 1024 # 100 MB
if file_path.value.stat().st_size < min_size:
logger.warning(
f"File too small to be a movie: {file_path.value.stat().st_size} bytes"
)
return False
return True

View File

@@ -0,0 +1,108 @@
"""Movie domain value objects."""
import re
from dataclasses import dataclass
from enum import Enum
from ..shared.exceptions import ValidationError
class Quality(Enum):
"""Video quality levels."""
SD = "480p"
HD = "720p"
FULL_HD = "1080p"
UHD_4K = "2160p"
UNKNOWN = "unknown"
@classmethod
def from_string(cls, quality_str: str) -> "Quality":
"""
Parse quality from string.
Args:
quality_str: Quality string (e.g., "1080p", "720p")
Returns:
Quality enum value
"""
quality_map = {
"480p": cls.SD,
"720p": cls.HD,
"1080p": cls.FULL_HD,
"2160p": cls.UHD_4K,
}
return quality_map.get(quality_str, cls.UNKNOWN)
@dataclass(frozen=True)
class MovieTitle:
"""
Value object representing a movie title.
Ensures the title is valid and normalized.
"""
value: str
def __post_init__(self):
"""Validate movie title."""
if not self.value:
raise ValidationError("Movie title cannot be empty")
if not isinstance(self.value, str):
raise ValidationError(
f"Movie title must be a string, got {type(self.value)}"
)
if len(self.value) > 500:
raise ValidationError(
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.
"""
# Remove special characters except spaces, dots, and hyphens
cleaned = re.sub(r"[^\w\s\.\-]", "", self.value)
# Replace spaces with dots
normalized = cleaned.replace(" ", ".")
return normalized
def __str__(self) -> str:
return self.value
def __repr__(self) -> str:
return f"MovieTitle('{self.value}')"
@dataclass(frozen=True)
class ReleaseYear:
"""
Value object representing a movie release year.
Validates that the year is reasonable.
"""
value: int
def __post_init__(self):
"""Validate release year."""
if not isinstance(self.value, int):
raise ValidationError(
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)
def __repr__(self) -> str:
return f"ReleaseYear({self.value})"

View File

@@ -0,0 +1,12 @@
"""Shared kernel - Common domain concepts used across subdomains."""
from .exceptions import DomainException, ValidationError
from .value_objects import FilePath, FileSize, ImdbId
__all__ = [
"DomainException",
"ValidationError",
"ImdbId",
"FilePath",
"FileSize",
]

View File

@@ -0,0 +1,25 @@
"""Shared domain exceptions."""
class DomainException(Exception):
"""Base exception for all domain-related errors."""
pass
class ValidationError(DomainException):
"""Raised when domain validation fails."""
pass
class NotFoundError(DomainException):
"""Raised when a domain entity is not found."""
pass
class AlreadyExistsError(DomainException):
"""Raised when trying to create an entity that already exists."""
pass

View File

@@ -0,0 +1,133 @@
"""Shared value objects used across multiple domains."""
import re
from dataclasses import dataclass
from pathlib import Path
from .exceptions import ValidationError
@dataclass(frozen=True)
class ImdbId:
"""
Value object representing an IMDb ID.
IMDb IDs follow the format: tt followed by 7-8 digits (e.g., tt1375666)
"""
value: str
def __post_init__(self):
"""Validate IMDb ID format."""
if not self.value:
raise ValidationError("IMDb ID cannot be empty")
if not isinstance(self.value, str):
raise ValidationError(f"IMDb ID must be a string, got {type(self.value)}")
# IMDb ID format: tt + 7-8 digits
pattern = r"^tt\d{7,8}$"
if not re.match(pattern, self.value):
raise ValidationError(
f"Invalid IMDb ID format: {self.value}. "
"Expected format: tt followed by 7-8 digits (e.g., tt1375666)"
)
def __str__(self) -> str:
return self.value
def __repr__(self) -> str:
return f"ImdbId('{self.value}')"
@dataclass(frozen=True)
class FilePath:
"""
Value object representing a file path with validation.
Ensures the path is valid and optionally checks existence.
"""
value: Path
def __init__(self, path: str | Path):
"""
Initialize FilePath.
Args:
path: String or Path object representing the file path
"""
if isinstance(path, str):
path_obj = Path(path)
elif isinstance(path, Path):
path_obj = path
else:
raise ValidationError(f"Path must be str or Path, got {type(path)}")
# Use object.__setattr__ because dataclass is frozen
object.__setattr__(self, "value", path_obj)
def exists(self) -> bool:
"""Check if the path exists."""
return self.value.exists()
def is_file(self) -> bool:
"""Check if the path is a file."""
return self.value.is_file()
def is_dir(self) -> bool:
"""Check if the path is a directory."""
return self.value.is_dir()
def __str__(self) -> str:
return str(self.value)
def __repr__(self) -> str:
return f"FilePath('{self.value}')"
@dataclass(frozen=True)
class FileSize:
"""
Value object representing a file size in bytes.
Provides human-readable formatting.
"""
bytes: int
def __post_init__(self):
"""Validate file size."""
if not isinstance(self.bytes, int):
raise ValidationError(
f"File size must be an integer, got {type(self.bytes)}"
)
if self.bytes < 0:
raise ValidationError(f"File size cannot be negative: {self.bytes}")
def to_human_readable(self) -> str:
"""
Convert bytes to human-readable format.
Returns:
String like "1.5 GB", "500 MB", etc.
"""
units = ["B", "KB", "MB", "GB", "TB"]
size = float(self.bytes)
unit_index = 0
while size >= 1024 and unit_index < len(units) - 1:
size /= 1024
unit_index += 1
if unit_index == 0:
return f"{int(size)} {units[unit_index]}"
else:
return f"{size:.2f} {units[unit_index]}"
def __str__(self) -> str:
return self.to_human_readable()
def __repr__(self) -> str:
return f"FileSize({self.bytes})"

View File

@@ -0,0 +1,14 @@
"""Subtitles domain - Business logic for subtitle management (shared across movies and TV shows)."""
from .entities import Subtitle
from .exceptions import SubtitleNotFound
from .services import SubtitleService
from .value_objects import Language, SubtitleFormat
__all__ = [
"Subtitle",
"Language",
"SubtitleFormat",
"SubtitleNotFound",
"SubtitleService",
]

View File

@@ -0,0 +1,96 @@
"""Subtitle domain entities."""
from dataclasses import dataclass
from ..shared.value_objects import FilePath, ImdbId
from .value_objects import Language, SubtitleFormat, TimingOffset
@dataclass
class Subtitle:
"""
Subtitle entity representing a subtitle file.
Can be associated with either a movie or a TV show episode.
"""
media_imdb_id: ImdbId
language: Language
format: SubtitleFormat
file_path: FilePath
# Optional: for TV shows
season_number: int | None = None
episode_number: int | None = None
# Subtitle metadata
timing_offset: TimingOffset = TimingOffset(0)
hearing_impaired: bool = False
forced: bool = False # Forced subtitles (for foreign language parts)
# Source information
source: str | None = None # e.g., "OpenSubtitles", "Subscene"
uploader: str | None = None
download_count: int | None = None
rating: float | None = None
def __post_init__(self):
"""Validate subtitle entity."""
# Ensure ImdbId is actually an ImdbId instance
if not isinstance(self.media_imdb_id, ImdbId):
if isinstance(self.media_imdb_id, str):
object.__setattr__(self, "media_imdb_id", ImdbId(self.media_imdb_id))
# Ensure Language is actually a Language instance
if not isinstance(self.language, Language):
if isinstance(self.language, str):
object.__setattr__(self, "language", Language.from_code(self.language))
# Ensure SubtitleFormat is actually a SubtitleFormat instance
if not isinstance(self.format, SubtitleFormat):
if isinstance(self.format, str):
object.__setattr__(
self, "format", SubtitleFormat.from_extension(self.format)
)
# Ensure FilePath is actually a FilePath instance
if not isinstance(self.file_path, FilePath):
object.__setattr__(self, "file_path", FilePath(self.file_path))
def is_for_movie(self) -> bool:
"""Check if this subtitle is for a movie."""
return self.season_number is None and self.episode_number is None
def is_for_episode(self) -> bool:
"""Check if this subtitle is for a TV show episode."""
return self.season_number is not None and self.episode_number is not None
def get_filename(self) -> str:
"""
Get the suggested filename for this subtitle.
Format for movies: "Movie.Title.{lang}.{format}"
Format for episodes: "S01E05.{lang}.{format}"
"""
if self.is_for_episode():
base = f"S{self.season_number:02d}E{self.episode_number:02d}"
else:
# For movies, use the file path stem
base = self.file_path.value.stem
parts = [base, self.language.value]
if self.hearing_impaired:
parts.append("hi")
if self.forced:
parts.append("forced")
return f"{'.'.join(parts)}.{self.format.value}"
def __str__(self) -> str:
if self.is_for_episode():
return f"Subtitle S{self.season_number:02d}E{self.episode_number:02d} ({self.language.value})"
return f"Subtitle ({self.language.value})"
def __repr__(self) -> str:
return f"Subtitle(media={self.media_imdb_id}, lang={self.language.value})"

View File

@@ -0,0 +1,15 @@
"""Subtitle domain exceptions."""
from ..shared.exceptions import DomainException, NotFoundError
class SubtitleNotFound(NotFoundError):
"""Raised when a subtitle is not found."""
pass
class InvalidSubtitleFormat(DomainException):
"""Raised when subtitle format is invalid."""
pass

View File

@@ -0,0 +1,60 @@
"""Subtitle repository interfaces (abstract)."""
from abc import ABC, abstractmethod
from ..shared.value_objects import ImdbId
from .entities import Subtitle
from .value_objects import Language
class SubtitleRepository(ABC):
"""
Abstract repository for subtitle persistence.
This defines the interface that infrastructure implementations must follow.
"""
@abstractmethod
def save(self, subtitle: Subtitle) -> None:
"""
Save a subtitle to the repository.
Args:
subtitle: Subtitle entity to save
"""
pass
@abstractmethod
def find_by_media(
self,
media_imdb_id: ImdbId,
language: Language | None = None,
season: int | None = None,
episode: int | None = None,
) -> list[Subtitle]:
"""
Find subtitles for a media item.
Args:
media_imdb_id: IMDb ID of the media
language: Optional language filter
season: Optional season number (for TV shows)
episode: Optional episode number (for TV shows)
Returns:
List of matching subtitles
"""
pass
@abstractmethod
def delete(self, subtitle: Subtitle) -> bool:
"""
Delete a subtitle from the repository.
Args:
subtitle: Subtitle to delete
Returns:
True if deleted, False if not found
"""
pass

View File

@@ -0,0 +1,149 @@
"""Subtitle domain services - Business logic."""
import logging
from ..shared.value_objects import FilePath, ImdbId
from .entities import Subtitle
from .exceptions import SubtitleNotFound
from .repositories import SubtitleRepository
from .value_objects import Language, SubtitleFormat
logger = logging.getLogger(__name__)
class SubtitleService:
"""
Domain service for subtitle-related business logic.
This service is SHARED between movies and TV shows domains.
Both can use this service to manage subtitles.
"""
def __init__(self, repository: SubtitleRepository):
"""
Initialize subtitle service.
Args:
repository: Subtitle repository for persistence
"""
self.repository = repository
def add_subtitle(self, subtitle: Subtitle) -> None:
"""
Add a subtitle to the library.
Args:
subtitle: Subtitle entity to add
"""
self.repository.save(subtitle)
logger.info(
f"Added subtitle: {subtitle.language.value} for {subtitle.media_imdb_id}"
)
def find_subtitles_for_movie(
self, imdb_id: ImdbId, languages: list[Language] | None = None
) -> list[Subtitle]:
"""
Find subtitles for a movie.
Args:
imdb_id: IMDb ID of the movie
languages: Optional list of languages to filter by
Returns:
List of matching subtitles
"""
if languages:
all_subtitles = []
for lang in languages:
subs = self.repository.find_by_media(imdb_id, language=lang)
all_subtitles.extend(subs)
return all_subtitles
else:
return self.repository.find_by_media(imdb_id)
def find_subtitles_for_episode(
self,
imdb_id: ImdbId,
season: int,
episode: int,
languages: list[Language] | None = None,
) -> list[Subtitle]:
"""
Find subtitles for a TV show episode.
Args:
imdb_id: IMDb ID of the TV show
season: Season number
episode: Episode number
languages: Optional list of languages to filter by
Returns:
List of matching subtitles
"""
if languages:
all_subtitles = []
for lang in languages:
subs = self.repository.find_by_media(
imdb_id, language=lang, season=season, episode=episode
)
all_subtitles.extend(subs)
return all_subtitles
else:
return self.repository.find_by_media(
imdb_id, season=season, episode=episode
)
def remove_subtitle(self, subtitle: Subtitle) -> None:
"""
Remove a subtitle from the library.
Args:
subtitle: Subtitle to remove
Raises:
SubtitleNotFound: If subtitle not found
"""
if not self.repository.delete(subtitle):
raise SubtitleNotFound(f"Subtitle not found: {subtitle}")
logger.info(f"Removed subtitle: {subtitle}")
def detect_format_from_file(self, file_path: FilePath) -> SubtitleFormat:
"""
Detect subtitle format from file extension.
Args:
file_path: Path to subtitle file
Returns:
Detected subtitle format
"""
extension = file_path.value.suffix
return SubtitleFormat.from_extension(extension)
def validate_subtitle_file(self, file_path: FilePath) -> bool:
"""
Validate that a file is a valid subtitle file.
Args:
file_path: Path to the file
Returns:
True if valid subtitle file, False otherwise
"""
if not file_path.exists():
logger.warning(f"File does not exist: {file_path}")
return False
if not file_path.is_file():
logger.warning(f"Path is not a file: {file_path}")
return False
# Check file extension
try:
self.detect_format_from_file(file_path)
return True
except Exception as e:
logger.warning(f"Invalid subtitle format: {e}")
return False

View File

@@ -0,0 +1,91 @@
"""Subtitle domain value objects."""
from dataclasses import dataclass
from enum import Enum
from ..shared.exceptions import ValidationError
class Language(Enum):
"""Supported subtitle languages."""
ENGLISH = "en"
FRENCH = "fr"
@classmethod
def from_code(cls, code: str) -> "Language":
"""
Get language from ISO 639-1 code.
Args:
code: Two-letter language code
Returns:
Language enum value
Raises:
ValidationError: If code is not supported
"""
code_lower = code.lower()
for lang in cls:
if lang.value == code_lower:
return lang
raise ValidationError(f"Unsupported language code: {code}")
class SubtitleFormat(Enum):
"""Supported subtitle formats."""
SRT = "srt" # SubRip
ASS = "ass" # Advanced SubStation Alpha
SSA = "ssa" # SubStation Alpha
VTT = "vtt" # WebVTT
SUB = "sub" # MicroDVD
@classmethod
def from_extension(cls, extension: str) -> "SubtitleFormat":
"""
Get format from file extension.
Args:
extension: File extension (with or without dot)
Returns:
SubtitleFormat enum value
Raises:
ValidationError: If extension is not supported
"""
ext = extension.lower().lstrip(".")
for fmt in cls:
if fmt.value == ext:
return fmt
raise ValidationError(f"Unsupported subtitle format: {extension}")
@dataclass(frozen=True)
class TimingOffset:
"""
Value object representing subtitle timing offset in milliseconds.
Used for synchronizing subtitles with video.
"""
milliseconds: int
def __post_init__(self):
"""Validate timing offset."""
if not isinstance(self.milliseconds, int):
raise ValidationError(
f"Timing offset must be an integer, got {type(self.milliseconds)}"
)
def to_seconds(self) -> float:
"""Convert to seconds."""
return self.milliseconds / 1000.0
def __str__(self) -> str:
return f"{self.milliseconds}ms"
def __repr__(self) -> str:
return f"TimingOffset({self.milliseconds})"

View File

@@ -0,0 +1,19 @@
"""TV Shows domain - Business logic for TV show management."""
from .entities import Episode, Season, TVShow
from .exceptions import InvalidEpisode, SeasonNotFound, TVShowNotFound
from .services import TVShowService
from .value_objects import EpisodeNumber, SeasonNumber, ShowStatus
__all__ = [
"TVShow",
"Season",
"Episode",
"ShowStatus",
"SeasonNumber",
"EpisodeNumber",
"TVShowNotFound",
"InvalidEpisode",
"SeasonNotFound",
"TVShowService",
]

View File

@@ -0,0 +1,204 @@
"""TV Show domain entities."""
import re
from dataclasses import dataclass, field
from datetime import datetime
from ..shared.value_objects import FilePath, FileSize, ImdbId
from .value_objects import EpisodeNumber, SeasonNumber, ShowStatus
@dataclass
class TVShow:
"""
TV Show entity representing a TV show in the media library.
This is the main aggregate root for the TV shows domain.
Migrated from agent/models/tv_show.py
"""
imdb_id: ImdbId
title: str
seasons_count: int
status: ShowStatus
tmdb_id: int | None = None
first_air_date: str | None = None
added_at: datetime = field(default_factory=datetime.now)
def __post_init__(self):
"""Validate TV show entity."""
# Ensure ImdbId is actually an ImdbId instance
if not isinstance(self.imdb_id, ImdbId):
if isinstance(self.imdb_id, str):
object.__setattr__(self, "imdb_id", ImdbId(self.imdb_id))
else:
raise ValueError(
f"imdb_id must be ImdbId or str, got {type(self.imdb_id)}"
)
# Ensure ShowStatus is actually a ShowStatus instance
if not isinstance(self.status, ShowStatus):
if isinstance(self.status, str):
object.__setattr__(self, "status", ShowStatus.from_string(self.status))
else:
raise ValueError(
f"status must be ShowStatus or str, got {type(self.status)}"
)
# Validate seasons_count
if not isinstance(self.seasons_count, int) or self.seasons_count < 0:
raise ValueError(
f"seasons_count must be a non-negative integer, got {self.seasons_count}"
)
def is_ongoing(self) -> bool:
"""Check if the show is still ongoing."""
return self.status == ShowStatus.ONGOING
def is_ended(self) -> bool:
"""Check if the show has ended."""
return self.status == ShowStatus.ENDED
def get_folder_name(self) -> str:
"""
Get the folder name for this TV show.
Format: "Title"
Example: "Breaking.Bad"
"""
# Remove special characters and replace spaces with dots
cleaned = re.sub(r"[^\w\s\.\-]", "", self.title)
return cleaned.replace(" ", ".")
def __str__(self) -> str:
return f"{self.title} ({self.status.value}, {self.seasons_count} seasons)"
def __repr__(self) -> str:
return f"TVShow(imdb_id={self.imdb_id}, title='{self.title}')"
@dataclass
class Season:
"""
Season entity representing a season of a TV show.
"""
show_imdb_id: ImdbId
season_number: SeasonNumber
episode_count: int
name: str | None = None
overview: str | None = None
air_date: str | None = None
poster_path: str | None = None
def __post_init__(self):
"""Validate season entity."""
# Ensure ImdbId is actually an ImdbId instance
if not isinstance(self.show_imdb_id, ImdbId):
if isinstance(self.show_imdb_id, str):
object.__setattr__(self, "show_imdb_id", ImdbId(self.show_imdb_id))
# Ensure SeasonNumber is actually a SeasonNumber instance
if not isinstance(self.season_number, SeasonNumber):
if isinstance(self.season_number, int):
object.__setattr__(
self, "season_number", SeasonNumber(self.season_number)
)
# Validate episode_count
if not isinstance(self.episode_count, int) or self.episode_count < 0:
raise ValueError(
f"episode_count must be a non-negative integer, got {self.episode_count}"
)
def is_special(self) -> bool:
"""Check if this is the specials season."""
return self.season_number.is_special()
def get_folder_name(self) -> str:
"""
Get the folder name for this season.
Format: "Season 01" or "Specials" for season 0
"""
if self.is_special():
return "Specials"
return f"Season {self.season_number.value:02d}"
def __str__(self) -> str:
if self.name:
return f"Season {self.season_number.value}: {self.name}"
return f"Season {self.season_number.value}"
def __repr__(self) -> str:
return f"Season(show={self.show_imdb_id}, number={self.season_number.value})"
@dataclass
class Episode:
"""
Episode entity representing an episode of a TV show.
"""
show_imdb_id: ImdbId
season_number: SeasonNumber
episode_number: EpisodeNumber
title: str
file_path: FilePath | None = None
file_size: FileSize | None = None
overview: str | None = None
air_date: str | None = None
still_path: str | None = None
vote_average: float | None = None
runtime: int | None = None # in minutes
def __post_init__(self):
"""Validate episode entity."""
# Ensure ImdbId is actually an ImdbId instance
if not isinstance(self.show_imdb_id, ImdbId):
if isinstance(self.show_imdb_id, str):
object.__setattr__(self, "show_imdb_id", ImdbId(self.show_imdb_id))
# Ensure SeasonNumber is actually a SeasonNumber instance
if not isinstance(self.season_number, SeasonNumber):
if isinstance(self.season_number, int):
object.__setattr__(
self, "season_number", SeasonNumber(self.season_number)
)
# Ensure EpisodeNumber is actually an EpisodeNumber instance
if not isinstance(self.episode_number, EpisodeNumber):
if isinstance(self.episode_number, int):
object.__setattr__(
self, "episode_number", EpisodeNumber(self.episode_number)
)
def has_file(self) -> bool:
"""Check if the episode has an associated file."""
return self.file_path is not None and self.file_path.exists()
def is_downloaded(self) -> bool:
"""Check if the episode is downloaded."""
return self.has_file()
def get_filename(self) -> str:
"""
Get the suggested filename for this episode.
Format: "S01E01 - Episode Title.ext"
Example: "S01E05 - Pilot.mkv"
"""
season_str = f"S{self.season_number.value:02d}"
episode_str = f"E{self.episode_number.value:02d}"
# Clean title for filename
clean_title = re.sub(r"[^\w\s\-]", "", self.title)
clean_title = clean_title.replace(" ", ".")
return f"{season_str}{episode_str}.{clean_title}"
def __str__(self) -> str:
return f"S{self.season_number.value:02d}E{self.episode_number.value:02d} - {self.title}"
def __repr__(self) -> str:
return f"Episode(show={self.show_imdb_id}, S{self.season_number.value:02d}E{self.episode_number.value:02d})"

View File

@@ -0,0 +1,33 @@
"""TV Show domain exceptions."""
from ..shared.exceptions import DomainException, NotFoundError
class TVShowNotFound(NotFoundError):
"""Raised when a TV show is not found."""
pass
class SeasonNotFound(NotFoundError):
"""Raised when a season is not found."""
pass
class EpisodeNotFound(NotFoundError):
"""Raised when an episode is not found."""
pass
class InvalidEpisode(DomainException):
"""Raised when episode data is invalid."""
pass
class TVShowAlreadyExists(DomainException):
"""Raised when trying to add a TV show that already exists."""
pass

View File

@@ -0,0 +1,126 @@
"""TV Show repository interfaces (abstract)."""
from abc import ABC, abstractmethod
from ..shared.value_objects import ImdbId
from .entities import Episode, Season, TVShow
from .value_objects import EpisodeNumber, SeasonNumber
class TVShowRepository(ABC):
"""
Abstract repository for TV show persistence.
This defines the interface that infrastructure implementations must follow.
"""
@abstractmethod
def save(self, show: TVShow) -> None:
"""
Save a TV show to the repository.
Args:
show: TVShow entity to save
"""
pass
@abstractmethod
def find_by_imdb_id(self, imdb_id: ImdbId) -> TVShow | None:
"""
Find a TV show by its IMDb ID.
Args:
imdb_id: IMDb ID to search for
Returns:
TVShow if found, None otherwise
"""
pass
@abstractmethod
def find_all(self) -> list[TVShow]:
"""
Get all TV shows in the repository.
Returns:
List of all TV shows
"""
pass
@abstractmethod
def delete(self, imdb_id: ImdbId) -> bool:
"""
Delete a TV show from the repository.
Args:
imdb_id: IMDb ID of the show to delete
Returns:
True if deleted, False if not found
"""
pass
@abstractmethod
def exists(self, imdb_id: ImdbId) -> bool:
"""
Check if a TV show exists in the repository.
Args:
imdb_id: IMDb ID to check
Returns:
True if exists, False otherwise
"""
pass
class SeasonRepository(ABC):
"""Abstract repository for season persistence."""
@abstractmethod
def save(self, season: Season) -> None:
"""Save a season."""
pass
@abstractmethod
def find_by_show_and_number(
self, show_imdb_id: ImdbId, season_number: SeasonNumber
) -> Season | None:
"""Find a season by show and season number."""
pass
@abstractmethod
def find_all_by_show(self, show_imdb_id: ImdbId) -> list[Season]:
"""Get all seasons for a show."""
pass
class EpisodeRepository(ABC):
"""Abstract repository for episode persistence."""
@abstractmethod
def save(self, episode: Episode) -> None:
"""Save an episode."""
pass
@abstractmethod
def find_by_show_season_episode(
self,
show_imdb_id: ImdbId,
season_number: SeasonNumber,
episode_number: EpisodeNumber,
) -> Episode | None:
"""Find an episode by show, season, and episode number."""
pass
@abstractmethod
def find_all_by_season(
self, show_imdb_id: ImdbId, season_number: SeasonNumber
) -> list[Episode]:
"""Get all episodes for a season."""
pass
@abstractmethod
def find_all_by_show(self, show_imdb_id: ImdbId) -> list[Episode]:
"""Get all episodes for a show."""
pass

View File

@@ -0,0 +1,234 @@
"""TV Show domain services - Business logic."""
import logging
import re
from ..shared.value_objects import ImdbId
from .entities import TVShow
from .exceptions import (
TVShowAlreadyExists,
TVShowNotFound,
)
from .repositories import EpisodeRepository, SeasonRepository, TVShowRepository
logger = logging.getLogger(__name__)
class TVShowService:
"""
Domain service for TV show-related business logic.
This service contains business rules that don't naturally fit
within a single entity.
"""
def __init__(
self,
show_repository: TVShowRepository,
season_repository: SeasonRepository | None = None,
episode_repository: EpisodeRepository | None = None,
):
"""
Initialize TV show service.
Args:
show_repository: TV show repository for persistence
season_repository: Optional season repository
episode_repository: Optional episode repository
"""
self.show_repository = show_repository
self.season_repository = season_repository
self.episode_repository = episode_repository
def track_show(self, show: TVShow) -> None:
"""
Start tracking a TV show.
Args:
show: TVShow entity to track
Raises:
TVShowAlreadyExists: If show is already being tracked
"""
if self.show_repository.exists(show.imdb_id):
raise TVShowAlreadyExists(
f"TV show with IMDb ID {show.imdb_id} is already tracked"
)
self.show_repository.save(show)
logger.info(f"Started tracking TV show: {show.title} ({show.imdb_id})")
def get_show(self, imdb_id: ImdbId) -> TVShow:
"""
Get a TV show by IMDb ID.
Args:
imdb_id: IMDb ID of the show
Returns:
TVShow entity
Raises:
TVShowNotFound: If show not found
"""
show = self.show_repository.find_by_imdb_id(imdb_id)
if not show:
raise TVShowNotFound(f"TV show with IMDb ID {imdb_id} not found")
return show
def get_all_shows(self) -> list[TVShow]:
"""
Get all tracked TV shows.
Returns:
List of all TV shows
"""
return self.show_repository.find_all()
def get_ongoing_shows(self) -> list[TVShow]:
"""
Get all ongoing TV shows.
Returns:
List of ongoing TV shows
"""
all_shows = self.show_repository.find_all()
return [show for show in all_shows if show.is_ongoing()]
def get_ended_shows(self) -> list[TVShow]:
"""
Get all ended TV shows.
Returns:
List of ended TV shows
"""
all_shows = self.show_repository.find_all()
return [show for show in all_shows if show.is_ended()]
def update_show(self, show: TVShow) -> None:
"""
Update an existing TV show.
Args:
show: TVShow entity with updated data
Raises:
TVShowNotFound: If show doesn't exist
"""
if not self.show_repository.exists(show.imdb_id):
raise TVShowNotFound(f"TV show with IMDb ID {show.imdb_id} not found")
self.show_repository.save(show)
logger.info(f"Updated TV show: {show.title} ({show.imdb_id})")
def untrack_show(self, imdb_id: ImdbId) -> None:
"""
Stop tracking a TV show.
Args:
imdb_id: IMDb ID of the show to untrack
Raises:
TVShowNotFound: If show not found
"""
if not self.show_repository.delete(imdb_id):
raise TVShowNotFound(f"TV show with IMDb ID {imdb_id} not found")
logger.info(f"Stopped tracking TV show with IMDb ID: {imdb_id}")
def parse_episode_from_filename(self, filename: str) -> tuple[int, int] | None:
"""
Parse season and episode numbers from filename.
Supports formats:
- S01E05
- 1x05
- Season 1 Episode 5
Args:
filename: Filename to parse
Returns:
Tuple of (season, episode) if found, None otherwise
"""
filename_lower = filename.lower()
# Pattern 1: S01E05
pattern1 = r"s(\d{1,2})e(\d{1,2})"
match = re.search(pattern1, filename_lower)
if match:
return (int(match.group(1)), int(match.group(2)))
# Pattern 2: 1x05
pattern2 = r"(\d{1,2})x(\d{1,2})"
match = re.search(pattern2, filename_lower)
if match:
return (int(match.group(1)), int(match.group(2)))
# Pattern 3: Season 1 Episode 5
pattern3 = r"season\s*(\d{1,2})\s*episode\s*(\d{1,2})"
match = re.search(pattern3, filename_lower)
if match:
return (int(match.group(1)), int(match.group(2)))
return None
def validate_episode_file(self, filename: str) -> bool:
"""
Validate that a file is a valid episode file.
Args:
filename: Filename to validate
Returns:
True if valid episode file, False otherwise
"""
# Check file extension
valid_extensions = {".mkv", ".mp4", ".avi", ".mov", ".wmv", ".flv", ".webm"}
extension = filename[filename.rfind(".") :].lower() if "." in filename else ""
if extension not in valid_extensions:
logger.warning(f"Invalid file extension: {extension}")
return False
# Check if we can parse episode info
episode_info = self.parse_episode_from_filename(filename)
if not episode_info:
logger.warning(f"Could not parse episode info from filename: {filename}")
return False
return True
def find_next_episode(
self, show: TVShow, last_season: int, last_episode: int
) -> tuple[int, int] | None:
"""
Find the next episode to download for a show.
Args:
show: TVShow entity
last_season: Last downloaded season number
last_episode: Last downloaded episode number
Returns:
Tuple of (season, episode) for next episode, or None if show is complete
"""
# If show has ended and we've watched all seasons, no next episode
if show.is_ended() and last_season >= show.seasons_count:
return None
# Simple logic: next episode in same season, or first episode of next season
# This could be enhanced with actual episode counts per season
next_episode = last_episode + 1
next_season = last_season
# Assume max 50 episodes per season (could be improved with actual data)
if next_episode > 50:
next_season += 1
next_episode = 1
# Don't go beyond known seasons
if next_season > show.seasons_count:
return None
return (next_season, next_episode)

View File

@@ -0,0 +1,104 @@
"""TV Show domain value objects."""
from dataclasses import dataclass
from enum import Enum
from ..shared.exceptions import ValidationError
class ShowStatus(Enum):
"""Status of a TV show - whether it's still airing or has ended."""
ONGOING = "ongoing"
ENDED = "ended"
UNKNOWN = "unknown"
@classmethod
def from_string(cls, status_str: str) -> "ShowStatus":
"""
Parse status from string.
Args:
status_str: Status string (e.g., "ongoing", "ended")
Returns:
ShowStatus enum value
"""
status_map = {
"ongoing": cls.ONGOING,
"ended": cls.ENDED,
}
return status_map.get(status_str.lower(), cls.UNKNOWN)
@dataclass(frozen=True)
class SeasonNumber:
"""
Value object representing a season number.
Validates that the season number is valid (>= 0).
Season 0 is used for specials.
"""
value: int
def __post_init__(self):
"""Validate season number."""
if not isinstance(self.value, int):
raise ValidationError(
f"Season number must be an integer, got {type(self.value)}"
)
if self.value < 0:
raise ValidationError(f"Season number cannot be negative: {self.value}")
# Reasonable upper limit
if self.value > 100:
raise ValidationError(f"Season number too high: {self.value}")
def is_special(self) -> bool:
"""Check if this is the specials season (season 0)."""
return self.value == 0
def __str__(self) -> str:
return str(self.value)
def __repr__(self) -> str:
return f"SeasonNumber({self.value})"
def __int__(self) -> int:
return self.value
@dataclass(frozen=True)
class EpisodeNumber:
"""
Value object representing an episode number.
Validates that the episode number is valid (>= 1).
"""
value: int
def __post_init__(self):
"""Validate episode number."""
if not isinstance(self.value, int):
raise ValidationError(
f"Episode number must be an integer, got {type(self.value)}"
)
if self.value < 1:
raise ValidationError(f"Episode number must be >= 1, got {self.value}")
# Reasonable upper limit
if self.value > 1000:
raise ValidationError(f"Episode number too high: {self.value}")
def __str__(self) -> str:
return str(self.value)
def __repr__(self) -> str:
return f"EpisodeNumber({self.value})"
def __int__(self) -> int:
return self.value

View File

@@ -0,0 +1 @@
"""Infrastructure layer - External services, persistence, and technical concerns."""

View File

@@ -0,0 +1 @@
"""API clients for external services."""

View File

@@ -0,0 +1,23 @@
"""Knaben API client."""
from .client import KnabenClient
from .dto import TorrentResult
from .exceptions import (
KnabenAPIError,
KnabenConfigurationError,
KnabenError,
KnabenNotFoundError,
)
# Global Knaben client instance (singleton)
knaben_client = KnabenClient()
__all__ = [
"KnabenClient",
"TorrentResult",
"KnabenError",
"KnabenConfigurationError",
"KnabenAPIError",
"KnabenNotFoundError",
"knaben_client",
]

View File

@@ -1,49 +1,19 @@
"""Knaben torrent search API client."""
from typing import Dict, Any, Optional, List
from dataclasses import dataclass
import logging
import requests
from requests.exceptions import RequestException, Timeout, HTTPError
from ..config import Settings, settings
import logging
from typing import Any
import requests
from requests.exceptions import HTTPError, RequestException, Timeout
from agent.config import Settings, settings
from .dto import TorrentResult
from .exceptions import KnabenAPIError, KnabenNotFoundError
logger = logging.getLogger(__name__)
class KnabenError(Exception):
"""Base exception for Knaben-related errors."""
pass
class KnabenConfigurationError(KnabenError):
"""Raised when Knaben API is not properly configured."""
pass
class KnabenAPIError(KnabenError):
"""Raised when Knaben API returns an error."""
pass
class KnabenNotFoundError(KnabenError):
"""Raised when no torrents are found."""
pass
@dataclass
class TorrentResult:
"""Represents a torrent search result from Knaben."""
title: str
size: str
seeders: int
leechers: int
magnet: str
info_hash: Optional[str] = None
tracker: Optional[str] = None
upload_date: Optional[str] = None
category: Optional[str] = None
class KnabenClient:
"""
Client for interacting with Knaben torrent search API.
@@ -59,9 +29,9 @@ class KnabenClient:
def __init__(
self,
base_url: Optional[str] = None,
timeout: Optional[int] = None,
config: Optional[Settings] = None
base_url: str | None = None,
timeout: int | None = None,
config: Settings | None = None,
):
"""
Initialize Knaben client.
@@ -81,15 +51,11 @@ class KnabenClient:
logger.info("Knaben client initialized")
def _make_request(
self,
params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
def _make_request(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
"""
Make a request to Knaben API.
Args:
endpoint: API endpoint (e.g., '/search')
params: Query parameters
Returns:
@@ -124,24 +90,19 @@ class KnabenClient:
logger.error(f"Knaben API request failed: {e}")
raise KnabenAPIError(f"Failed to connect to Knaben API: {e}") from e
def search(
self,
query: str,
limit: int = 10
) -> List[TorrentResult]:
def search(self, query: str, limit: int = 10) -> list[TorrentResult]:
"""
Search for torrents.
Args:
query: Search query (e.g., "Inception 1080p")
limit: Maximum number of results (default: 50)
limit: Maximum number of results (default: 10)
Returns:
List of TorrentResult objects
Raises:
KnabenAPIError: If request fails
KnabenNotFoundError: If no results found
ValueError: If query is invalid
"""
if not query or not isinstance(query, str):
@@ -164,8 +125,7 @@ class KnabenClient:
try:
data = self._make_request(params)
except KnabenNotFoundError as e:
# No results found
except KnabenNotFoundError:
logger.info(f"No torrents found for '{query}'")
return []
except Exception as e:
@@ -174,7 +134,7 @@ class KnabenClient:
# Parse results
results = []
torrents = data.get('hits', [])
torrents = data.get("hits", [])
if not torrents:
logger.info(f"No torrents found for '{query}'")
@@ -191,7 +151,7 @@ class KnabenClient:
logger.info(f"Found {len(results)} torrents for '{query}'")
return results
def _parse_torrent(self, torrent: Dict[str, Any]) -> TorrentResult:
def _parse_torrent(self, torrent: dict[str, Any]) -> TorrentResult:
"""
Parse a torrent result into a TorrentResult object.
@@ -202,17 +162,17 @@ class KnabenClient:
TorrentResult object
"""
# Extract required fields (API uses camelCase)
title = torrent.get('title', 'Unknown')
size = torrent.get('size', 'Unknown')
seeders = int(torrent.get('seeders', 0) or 0)
leechers = int(torrent.get('leechers', 0) or 0)
magnet = torrent.get('magnetUrl', '')
title = torrent.get("title", "Unknown")
size = torrent.get("size", "Unknown")
seeders = int(torrent.get("seeders", 0) or 0)
leechers = int(torrent.get("leechers", 0) or 0)
magnet = torrent.get("magnetUrl", "")
# Extract optional fields
info_hash = torrent.get('hash')
tracker = torrent.get('tracker')
upload_date = torrent.get('date')
category = torrent.get('category')
info_hash = torrent.get("hash")
tracker = torrent.get("tracker")
upload_date = torrent.get("date")
category = torrent.get("category")
return TorrentResult(
title=title,
@@ -223,8 +183,5 @@ class KnabenClient:
info_hash=info_hash,
tracker=tracker,
upload_date=upload_date,
category=category
category=category,
)
# Global Knaben client instance (singleton)
knaben_client = KnabenClient()

View File

@@ -0,0 +1,18 @@
"""Knaben Data Transfer Objects."""
from dataclasses import dataclass
@dataclass
class TorrentResult:
"""Represents a torrent search result from Knaben."""
title: str
size: str
seeders: int
leechers: int
magnet: str
info_hash: str | None = None
tracker: str | None = None
upload_date: str | None = None
category: str | None = None

View File

@@ -0,0 +1,25 @@
"""Knaben API exceptions."""
class KnabenError(Exception):
"""Base exception for Knaben-related errors."""
pass
class KnabenConfigurationError(KnabenError):
"""Raised when Knaben API is not properly configured."""
pass
class KnabenAPIError(KnabenError):
"""Raised when Knaben API returns an error."""
pass
class KnabenNotFoundError(KnabenError):
"""Raised when no torrents are found."""
pass

View File

@@ -0,0 +1,23 @@
"""qBittorrent API client."""
from .client import QBittorrentClient
from .dto import TorrentInfo
from .exceptions import (
QBittorrentAPIError,
QBittorrentAuthError,
QBittorrentConfigurationError,
QBittorrentError,
)
# Global qBittorrent client instance (singleton)
qbittorrent_client = QBittorrentClient()
__all__ = [
"QBittorrentClient",
"TorrentInfo",
"QBittorrentError",
"QBittorrentConfigurationError",
"QBittorrentAPIError",
"QBittorrentAuthError",
"qbittorrent_client",
]

View File

@@ -1,53 +1,19 @@
"""qBittorrent Web API client."""
from typing import Dict, Any, Optional, List
from dataclasses import dataclass
import logging
import requests
from requests.exceptions import RequestException, Timeout, HTTPError
from ..config import Settings, settings
import logging
from typing import Any
import requests
from requests.exceptions import HTTPError, RequestException, Timeout
from agent.config import Settings, settings
from .dto import TorrentInfo
from .exceptions import QBittorrentAPIError, QBittorrentAuthError
logger = logging.getLogger(__name__)
class QBittorrentError(Exception):
"""Base exception for qBittorrent-related errors."""
pass
class QBittorrentConfigurationError(QBittorrentError):
"""Raised when qBittorrent is not properly configured."""
pass
class QBittorrentAPIError(QBittorrentError):
"""Raised when qBittorrent API returns an error."""
pass
class QBittorrentAuthError(QBittorrentError):
"""Raised when authentication fails."""
pass
@dataclass
class TorrentInfo:
"""Represents a torrent in qBittorrent."""
hash: str
name: str
size: int
progress: float
state: str
download_speed: int
upload_speed: int
eta: int
num_seeds: int
num_leechs: int
ratio: float
category: Optional[str] = None
save_path: Optional[str] = None
class QBittorrentClient:
"""
Client for interacting with qBittorrent Web API.
@@ -64,11 +30,11 @@ class QBittorrentClient:
def __init__(
self,
host: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
timeout: Optional[int] = None,
config: Optional[Settings] = None
host: str | None = None,
username: str | None = None,
password: str | None = None,
timeout: int | None = None,
config: Settings | None = None,
):
"""
Initialize qBittorrent client.
@@ -96,8 +62,8 @@ class QBittorrentClient:
self,
method: str,
endpoint: str,
data: Optional[Dict[str, Any]] = None,
files: Optional[Dict[str, Any]] = None
data: dict[str, Any] | None = None,
files: dict[str, Any] | None = None,
) -> Any:
"""
Make a request to qBittorrent API.
@@ -122,7 +88,9 @@ class QBittorrentClient:
if method.upper() == "GET":
response = self.session.get(url, params=data, timeout=self.timeout)
elif method.upper() == "POST":
response = self.session.post(url, data=data, files=files, timeout=self.timeout)
response = self.session.post(
url, data=data, files=files, timeout=self.timeout
)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
@@ -136,14 +104,18 @@ class QBittorrentClient:
except Timeout as e:
logger.error(f"qBittorrent API timeout: {e}")
raise QBittorrentAPIError(f"Request timeout after {self.timeout} seconds") from e
raise QBittorrentAPIError(
f"Request timeout after {self.timeout} seconds"
) from e
except HTTPError as e:
logger.error(f"qBittorrent API HTTP error: {e}")
if e.response is not None:
status_code = e.response.status_code
if status_code == 403:
raise QBittorrentAuthError("Authentication required or forbidden") from e
raise QBittorrentAuthError(
"Authentication required or forbidden"
) from e
else:
raise QBittorrentAPIError(f"HTTP {status_code}: {e}") from e
raise QBittorrentAPIError(f"HTTP error: {e}") from e
@@ -163,10 +135,7 @@ class QBittorrentClient:
QBittorrentAuthError: If authentication fails
"""
try:
data = {
"username": self.username,
"password": self.password
}
data = {"username": self.username, "password": self.password}
response = self._make_request("POST", "/api/v2/auth/login", data=data)
@@ -198,10 +167,8 @@ class QBittorrentClient:
return False
def get_torrents(
self,
filter: Optional[str] = None,
category: Optional[str] = None
) -> List[TorrentInfo]:
self, filter: str | None = None, category: str | None = None
) -> list[TorrentInfo]:
"""
Get list of torrents.
@@ -249,9 +216,9 @@ class QBittorrentClient:
def add_torrent(
self,
magnet: str,
category: Optional[str] = None,
save_path: Optional[str] = None,
paused: bool = False
category: str | None = None,
save_path: str | None = None,
paused: bool = False,
) -> bool:
"""
Add a torrent via magnet link.
@@ -271,10 +238,7 @@ class QBittorrentClient:
if not self._authenticated:
self.login()
data = {
"urls": magnet,
"paused": "true" if paused else "false"
}
data = {"urls": magnet, "paused": "true" if paused else "false"}
if category:
data["category"] = category
@@ -285,7 +249,7 @@ class QBittorrentClient:
response = self._make_request("POST", "/api/v2/torrents/add", data=data)
if response == "Ok.":
logger.info(f"Successfully added torrent")
logger.info("Successfully added torrent")
return True
else:
logger.warning(f"Unexpected response: {response}")
@@ -295,11 +259,7 @@ class QBittorrentClient:
logger.error(f"Failed to add torrent: {e}")
raise
def delete_torrent(
self,
torrent_hash: str,
delete_files: bool = False
) -> bool:
def delete_torrent(self, torrent_hash: str, delete_files: bool = False) -> bool:
"""
Delete a torrent.
@@ -318,11 +278,11 @@ class QBittorrentClient:
data = {
"hashes": torrent_hash,
"deleteFiles": "true" if delete_files else "false"
"deleteFiles": "true" if delete_files else "false",
}
try:
response = self._make_request("POST", "/api/v2/torrents/delete", data=data)
self._make_request("POST", "/api/v2/torrents/delete", data=data)
logger.info(f"Deleted torrent {torrent_hash}")
return True
@@ -376,7 +336,7 @@ class QBittorrentClient:
logger.error(f"Failed to resume torrent: {e}")
raise
def get_torrent_properties(self, torrent_hash: str) -> Dict[str, Any]:
def get_torrent_properties(self, torrent_hash: str) -> dict[str, Any]:
"""
Get detailed properties of a torrent.
@@ -398,7 +358,7 @@ class QBittorrentClient:
logger.error(f"Failed to get torrent properties: {e}")
raise
def _parse_torrent(self, torrent: Dict[str, Any]) -> TorrentInfo:
def _parse_torrent(self, torrent: dict[str, Any]) -> TorrentInfo:
"""
Parse a torrent dict into a TorrentInfo object.
@@ -421,9 +381,5 @@ class QBittorrentClient:
num_leechs=torrent.get("num_leechs", 0),
ratio=torrent.get("ratio", 0.0),
category=torrent.get("category"),
save_path=torrent.get("save_path")
save_path=torrent.get("save_path"),
)
# Global qBittorrent client instance (singleton)
qbittorrent_client = QBittorrentClient()

View File

@@ -0,0 +1,22 @@
"""qBittorrent Data Transfer Objects."""
from dataclasses import dataclass
@dataclass
class TorrentInfo:
"""Represents a torrent in qBittorrent."""
hash: str
name: str
size: int
progress: float
state: str
download_speed: int
upload_speed: int
eta: int
num_seeds: int
num_leechs: int
ratio: float
category: str | None = None
save_path: str | None = None

View File

@@ -0,0 +1,25 @@
"""qBittorrent API exceptions."""
class QBittorrentError(Exception):
"""Base exception for qBittorrent-related errors."""
pass
class QBittorrentConfigurationError(QBittorrentError):
"""Raised when qBittorrent is not properly configured."""
pass
class QBittorrentAPIError(QBittorrentError):
"""Raised when qBittorrent API returns an error."""
pass
class QBittorrentAuthError(QBittorrentError):
"""Raised when authentication fails."""
pass

View File

@@ -0,0 +1,24 @@
"""TMDB API client."""
from .client import TMDBClient
from .dto import ExternalIds, MediaResult
from .exceptions import (
TMDBAPIError,
TMDBConfigurationError,
TMDBError,
TMDBNotFoundError,
)
# Global TMDB client instance (singleton)
tmdb_client = TMDBClient()
__all__ = [
"TMDBClient",
"MediaResult",
"ExternalIds",
"TMDBError",
"TMDBConfigurationError",
"TMDBAPIError",
"TMDBNotFoundError",
"tmdb_client",
]

View File

@@ -1,133 +1,106 @@
"""TMDB (The Movie Database) API client."""
from typing import Dict, Any, Optional, List
from dataclasses import dataclass
import logging
import requests
from requests.exceptions import RequestException, Timeout, HTTPError
from ..config import Settings, settings
import logging
from typing import Any
import requests
from requests.exceptions import HTTPError, RequestException, Timeout
from agent.config import Settings, settings
from .dto import MediaResult
from .exceptions import (
TMDBAPIError,
TMDBConfigurationError,
TMDBNotFoundError,
)
logger = logging.getLogger(__name__)
class TMDBError(Exception):
"""Base exception for TMDB-related errors."""
pass
class TMDBConfigurationError(TMDBError):
"""Raised when TMDB API is not properly configured."""
pass
class TMDBAPIError(TMDBError):
"""Raised when TMDB API returns an error."""
pass
class TMDBNotFoundError(TMDBError):
"""Raised when media is not found."""
pass
@dataclass
class MediaResult:
"""Represents a media search result from TMDB."""
tmdb_id: int
title: str
media_type: str # 'movie' or 'tv'
imdb_id: Optional[str] = None
overview: Optional[str] = None
release_date: Optional[str] = None
poster_path: Optional[str] = None
vote_average: Optional[float] = None
class TMDBClient:
"""
Client for interacting with The Movie Database (TMDB) API.
This client provides methods to search for movies and TV shows,
retrieve their details, and get external IDs (like IMDb).
Example:
>>> client = TMDBClient()
>>> result = client.search_media("Inception")
>>> print(result.imdb_id)
'tt1375666'
"""
def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
timeout: Optional[int] = None,
config: Optional[Settings] = None
api_key: str | None = None,
base_url: str | None = None,
timeout: int | None = None,
config: Settings | None = None,
):
"""
Initialize TMDB client.
Args:
api_key: TMDB API key (defaults to settings)
base_url: TMDB API base URL (defaults to settings)
timeout: Request timeout in seconds (defaults to settings)
config: Optional Settings instance (for testing)
Raises:
TMDBConfigurationError: If API key is missing
"""
cfg = config or settings
self.api_key = api_key or cfg.tmdb_api_key
self.base_url = base_url or cfg.tmdb_base_url
self.timeout = timeout or cfg.request_timeout
if not self.api_key:
raise TMDBConfigurationError(
"TMDB API key is required. Set TMDB_API_KEY environment variable."
)
if not self.base_url:
raise TMDBConfigurationError(
"TMDB base URL is required. Set TMDB_BASE_URL environment variable."
)
logger.info("TMDB client initialized")
def _make_request(
self,
endpoint: str,
params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
self, endpoint: str, params: dict[str, Any] | None = None
) -> dict[str, Any]:
"""
Make a request to TMDB API.
Args:
endpoint: API endpoint (e.g., '/search/multi')
params: Query parameters
Returns:
JSON response as dict
Raises:
TMDBAPIError: If request fails
"""
url = f"{self.base_url}{endpoint}"
# Add API key to params
request_params = params or {}
request_params['api_key'] = self.api_key
request_params["api_key"] = self.api_key
try:
logger.debug(f"TMDB request: {endpoint}")
response = requests.get(url, params=request_params, timeout=self.timeout)
response.raise_for_status()
return response.json()
except Timeout as e:
logger.error(f"TMDB API timeout: {e}")
raise TMDBAPIError(f"Request timeout after {self.timeout} seconds") from e
except HTTPError as e:
logger.error(f"TMDB API HTTP error: {e}")
if e.response is not None:
@@ -139,129 +112,132 @@ class TMDBClient:
else:
raise TMDBAPIError(f"HTTP {status_code}: {e}") from e
raise TMDBAPIError(f"HTTP error: {e}") from e
except RequestException as e:
logger.error(f"TMDB API request failed: {e}")
raise TMDBAPIError(f"Failed to connect to TMDB API: {e}") from e
def search_multi(self, query: str) -> List[Dict[str, Any]]:
def search_multi(self, query: str) -> list[dict[str, Any]]:
"""
Search for movies and TV shows.
Args:
query: Search query (movie or TV show title)
Returns:
List of search results
Raises:
TMDBAPIError: If request fails
TMDBNotFoundError: If no results found
"""
if not query or not isinstance(query, str):
raise ValueError("Query must be a non-empty string")
if len(query) > 500:
raise ValueError("Query is too long (max 500 characters)")
data = self._make_request('/search/multi', {'query': query})
results = data.get('results', [])
data = self._make_request("/search/multi", {"query": query})
results = data.get("results", [])
if not results:
raise TMDBNotFoundError(f"No results found for '{query}'")
logger.info(f"Found {len(results)} results for '{query}'")
return results
def get_external_ids(self, media_type: str, tmdb_id: int) -> Dict[str, Any]:
def get_external_ids(self, media_type: str, tmdb_id: int) -> dict[str, Any]:
"""
Get external IDs (IMDb, TVDB, etc.) for a media item.
Args:
media_type: Type of media ('movie' or 'tv')
tmdb_id: TMDB ID of the media
Returns:
Dict with external IDs
Raises:
TMDBAPIError: If request fails
"""
if media_type not in ('movie', 'tv'):
raise ValueError(f"Invalid media_type: {media_type}. Must be 'movie' or 'tv'")
if media_type not in ("movie", "tv"):
raise ValueError(
f"Invalid media_type: {media_type}. Must be 'movie' or 'tv'"
)
endpoint = f"/{media_type}/{tmdb_id}/external_ids"
return self._make_request(endpoint)
def search_media(self, title: str) -> MediaResult:
"""
Search for a media item and return detailed information including IMDb ID.
This is a convenience method that combines search and external ID lookup.
Args:
title: Title of the movie or TV show
Returns:
MediaResult with all available information
Raises:
TMDBAPIError: If request fails
TMDBNotFoundError: If media not found
"""
# Search for media
results = self.search_multi(title)
# Get the first (most relevant) result
top_result = results[0]
# Validate result structure
if 'id' not in top_result or 'media_type' not in top_result:
if "id" not in top_result or "media_type" not in top_result:
raise TMDBAPIError("Invalid TMDB response structure")
tmdb_id = top_result['id']
media_type = top_result['media_type']
media_type = top_result["media_type"]
# Skip if not movie or TV show
if media_type not in ('movie', 'tv'):
if media_type not in ("movie", "tv"):
logger.warning(f"Skipping result of type: {media_type}")
if len(results) > 1:
# Try next result
return self._parse_result(results[1])
raise TMDBNotFoundError(f"No movie or TV show found for '{title}'")
return self._parse_result(top_result)
def _parse_result(self, result: Dict[str, Any]) -> MediaResult:
def _parse_result(self, result: dict[str, Any]) -> MediaResult:
"""
Parse a TMDB result into a MediaResult object.
Args:
result: Raw TMDB result dict
Returns:
MediaResult object
"""
tmdb_id = result['id']
media_type = result['media_type']
title = result.get('title') or result.get('name', 'Unknown')
tmdb_id = result["id"]
media_type = result["media_type"]
title = result.get("title") or result.get("name", "Unknown")
# Get external IDs (including IMDb)
try:
external_ids = self.get_external_ids(media_type, tmdb_id)
imdb_id = external_ids.get('imdb_id')
imdb_id = external_ids.get("imdb_id")
except TMDBAPIError as e:
logger.warning(f"Failed to get external IDs: {e}")
imdb_id = None
# Extract other useful information
overview = result.get('overview')
release_date = result.get('release_date') or result.get('first_air_date')
poster_path = result.get('poster_path')
vote_average = result.get('vote_average')
logger.info(f"Found: {title} (Type: {media_type}, TMDB ID: {tmdb_id}, IMDb: {imdb_id})")
overview = result.get("overview")
release_date = result.get("release_date") or result.get("first_air_date")
poster_path = result.get("poster_path")
vote_average = result.get("vote_average")
logger.info(
f"Found: {title} (Type: {media_type}, TMDB ID: {tmdb_id}, IMDb: {imdb_id})"
)
return MediaResult(
tmdb_id=tmdb_id,
title=title,
@@ -270,48 +246,44 @@ class TMDBClient:
overview=overview,
release_date=release_date,
poster_path=poster_path,
vote_average=vote_average
vote_average=vote_average,
)
def get_movie_details(self, movie_id: int) -> Dict[str, Any]:
def get_movie_details(self, movie_id: int) -> dict[str, Any]:
"""
Get detailed information about a movie.
Args:
movie_id: TMDB movie ID
Returns:
Dict with movie details
Raises:
TMDBAPIError: If request fails
"""
return self._make_request(f'/movie/{movie_id}')
def get_tv_details(self, tv_id: int) -> Dict[str, Any]:
return self._make_request(f"/movie/{movie_id}")
def get_tv_details(self, tv_id: int) -> dict[str, Any]:
"""
Get detailed information about a TV show.
Args:
tv_id: TMDB TV show ID
Returns:
Dict with TV show details
Raises:
TMDBAPIError: If request fails
"""
return self._make_request(f'/tv/{tv_id}')
return self._make_request(f"/tv/{tv_id}")
def is_configured(self) -> bool:
"""
Check if TMDB client is properly configured.
Returns:
True if configured, False otherwise
"""
return bool(self.api_key and self.base_url)
# Global TMDB client instance (singleton)
tmdb_client = TMDBClient()

View File

@@ -0,0 +1,28 @@
"""TMDB Data Transfer Objects."""
from dataclasses import dataclass
@dataclass
class MediaResult:
"""Represents a media search result from TMDB."""
tmdb_id: int
title: str
media_type: str # 'movie' or 'tv'
imdb_id: str | None = None
overview: str | None = None
release_date: str | None = None
poster_path: str | None = None
vote_average: float | None = None
@dataclass
class ExternalIds:
"""External IDs for a media item."""
imdb_id: str | None = None
tvdb_id: int | None = None
facebook_id: str | None = None
instagram_id: str | None = None
twitter_id: str | None = None

View File

@@ -0,0 +1,25 @@
"""TMDB API exceptions."""
class TMDBError(Exception):
"""Base exception for TMDB-related errors."""
pass
class TMDBConfigurationError(TMDBError):
"""Raised when TMDB API is not properly configured."""
pass
class TMDBAPIError(TMDBError):
"""Raised when TMDB API returns an error."""
pass
class TMDBNotFoundError(TMDBError):
"""Raised when media is not found."""
pass

View File

@@ -0,0 +1,12 @@
"""Filesystem operations."""
from .exceptions import FilesystemError, PathTraversalError
from .file_manager import FileManager
from .organizer import MediaOrganizer
__all__ = [
"FileManager",
"MediaOrganizer",
"FilesystemError",
"PathTraversalError",
]

View File

@@ -0,0 +1,25 @@
"""Filesystem exceptions."""
class FilesystemError(Exception):
"""Base exception for filesystem operations."""
pass
class PathTraversalError(FilesystemError):
"""Raised when path traversal attack is detected."""
pass
class FileNotFoundError(FilesystemError):
"""Raised when a file is not found."""
pass
class PermissionDeniedError(FilesystemError):
"""Raised when permission is denied."""
pass

View File

@@ -0,0 +1,311 @@
"""File manager for filesystem operations."""
import logging
import os
import shutil
from enum import Enum
from pathlib import Path
from typing import Any
from infrastructure.persistence import get_memory
from .exceptions import PathTraversalError
logger = logging.getLogger(__name__)
class FolderName(Enum):
"""Types of folders that can be managed."""
DOWNLOAD = "download"
TVSHOW = "tvshow"
MOVIE = "movie"
TORRENT = "torrent"
class FileManager:
"""
File manager for filesystem operations.
Handles folder configuration, listing, and file operations
with security checks to prevent path traversal attacks.
"""
def set_folder_path(self, folder_name: str, path_value: str) -> dict[str, Any]:
"""
Set a folder path in the configuration.
Validates that the path exists, is a directory, and is readable.
Args:
folder_name: Name of folder (download, tvshow, movie, torrent).
path_value: Absolute path to the folder.
Returns:
Dict with status or error information.
"""
try:
self._validate_folder_name(folder_name)
path_obj = Path(path_value).resolve()
if not path_obj.exists():
logger.warning(f"Path does not exist: {path_value}")
return {
"error": "invalid_path",
"message": f"Path does not exist: {path_value}",
}
if not path_obj.is_dir():
logger.warning(f"Path is not a directory: {path_value}")
return {
"error": "invalid_path",
"message": f"Path is not a directory: {path_value}",
}
if not os.access(path_obj, os.R_OK):
logger.warning(f"Path is not readable: {path_value}")
return {
"error": "permission_denied",
"message": f"Path is not readable: {path_value}",
}
memory = get_memory()
memory.ltm.set_config(f"{folder_name}_folder", str(path_obj))
memory.save()
logger.info(f"Set {folder_name}_folder to: {path_obj}")
return {"status": "ok", "folder_name": folder_name, "path": str(path_obj)}
except ValueError as e:
logger.error(f"Validation error: {e}")
return {"error": "validation_failed", "message": str(e)}
except Exception as e:
logger.error(f"Unexpected error setting path: {e}", exc_info=True)
return {"error": "internal_error", "message": "Failed to set path"}
def list_folder( # noqa: PLR0911
self, folder_type: str, path: str = "."
) -> dict[str, Any]:
"""
List contents of a configured folder.
Includes security checks to prevent path traversal.
Args:
folder_type: Type of folder (download, tvshow, movie, torrent).
path: Relative path within the folder (default: root).
Returns:
Dict with folder contents or error information.
"""
try:
self._validate_folder_name(folder_type)
safe_path = self._sanitize_path(path)
memory = get_memory()
folder_key = f"{folder_type}_folder"
folder_path = memory.ltm.get_config(folder_key)
if not folder_path:
logger.warning(f"Folder not configured: {folder_type}")
return {
"error": "folder_not_set",
"message": f"{folder_type.capitalize()} folder not configured.",
}
root = Path(folder_path)
target = root / safe_path
if not self._is_safe_path(root, target):
logger.warning(f"Path traversal attempt: {path}")
return {
"error": "forbidden",
"message": "Access denied: path outside allowed directory",
}
if not target.exists():
logger.warning(f"Path does not exist: {target}")
return {
"error": "not_found",
"message": f"Path does not exist: {safe_path}",
}
if not target.is_dir():
logger.warning(f"Path is not a directory: {target}")
return {
"error": "not_a_directory",
"message": f"Path is not a directory: {safe_path}",
}
try:
entries = [entry.name for entry in target.iterdir()]
logger.debug(f"Listed {len(entries)} entries in {target}")
return {
"status": "ok",
"folder_type": folder_type,
"path": safe_path,
"entries": sorted(entries),
"count": len(entries),
}
except PermissionError:
logger.warning(f"Permission denied: {target}")
return {
"error": "permission_denied",
"message": f"Permission denied: {safe_path}",
}
except PathTraversalError as e:
logger.warning(f"Path traversal attempt: {e}")
return {"error": "forbidden", "message": str(e)}
except ValueError as e:
logger.error(f"Validation error: {e}")
return {"error": "validation_failed", "message": str(e)}
except Exception as e:
logger.error(f"Unexpected error listing folder: {e}", exc_info=True)
return {"error": "internal_error", "message": "Failed to list folder"}
def move_file( # noqa: PLR0911
self, source: str, destination: str
) -> dict[str, Any]:
"""
Move a file from one location to another.
Includes validation and verification after move.
Args:
source: Source file path.
destination: Destination file path.
Returns:
Dict with status or error information.
"""
try:
source_path = Path(source).resolve()
dest_path = Path(destination).resolve()
logger.info(f"Moving file: {source_path} -> {dest_path}")
if not source_path.exists():
return {
"error": "source_not_found",
"message": f"Source does not exist: {source}",
}
if not source_path.is_file():
return {
"error": "source_not_file",
"message": f"Source is not a file: {source}",
}
source_size = source_path.stat().st_size
dest_parent = dest_path.parent
if not dest_parent.exists():
return {
"error": "destination_dir_not_found",
"message": f"Destination directory does not exist: {dest_parent}",
}
if dest_path.exists():
return {
"error": "destination_exists",
"message": f"Destination already exists: {destination}",
}
shutil.move(str(source_path), str(dest_path))
# Verify move
if not dest_path.exists():
return {
"error": "move_verification_failed",
"message": "File was not moved successfully",
}
dest_size = dest_path.stat().st_size
if dest_size != source_size:
return {
"error": "size_mismatch",
"message": "File size mismatch after move",
}
logger.info(f"File moved successfully: {dest_path.name}")
return {
"status": "ok",
"source": str(source_path),
"destination": str(dest_path),
"filename": dest_path.name,
"size": dest_size,
}
except Exception as e:
logger.error(f"Error moving file: {e}", exc_info=True)
return {"error": "move_failed", "message": str(e)}
def _validate_folder_name(self, folder_name: str) -> bool:
"""
Validate folder name against allowed values.
Args:
folder_name: Name to validate.
Returns:
True if valid.
Raises:
ValueError: If folder name is invalid.
"""
valid_names = [fn.value for fn in FolderName]
if folder_name not in valid_names:
raise ValueError(
f"Invalid folder_name '{folder_name}'. "
f"Must be one of: {', '.join(valid_names)}"
)
return True
def _sanitize_path(self, path: str) -> str:
"""
Sanitize path to prevent path traversal attacks.
Args:
path: Path to sanitize.
Returns:
Sanitized path.
Raises:
PathTraversalError: If path contains traversal attempts.
"""
normalized = os.path.normpath(path)
if os.path.isabs(normalized):
raise PathTraversalError("Absolute paths are not allowed")
if normalized.startswith("..") or "/.." in normalized or "\\.." in normalized:
raise PathTraversalError("Parent directory references not allowed")
if "\x00" in normalized:
raise PathTraversalError("Null bytes in path not allowed")
return normalized
def _is_safe_path(self, base_path: Path, target_path: Path) -> bool:
"""
Check if target path is within base path.
Args:
base_path: The allowed base directory.
target_path: The path to check.
Returns:
True if target is within base, False otherwise.
"""
try:
base_resolved = base_path.resolve()
target_resolved = target_path.resolve()
target_resolved.relative_to(base_resolved)
return True
except (ValueError, OSError):
return False

View File

@@ -0,0 +1,143 @@
"""Media organizer - Organizes movies and TV shows into proper folder structures."""
import logging
from pathlib import Path
from domain.movies.entities import Movie
from domain.tv_shows.entities import Episode, Season, TVShow
from domain.tv_shows.value_objects import SeasonNumber
logger = logging.getLogger(__name__)
class MediaOrganizer:
"""
Organizes media files into proper folder structures.
This service knows how to organize movies and TV shows according to
common media server conventions (Plex, Jellyfin, etc.).
"""
def __init__(self, movie_folder: Path, tvshow_folder: Path):
"""
Initialize media organizer.
Args:
movie_folder: Root folder for movies
tvshow_folder: Root folder for TV shows
"""
self.movie_folder = movie_folder
self.tvshow_folder = tvshow_folder
def get_movie_destination(self, movie: Movie, filename: str) -> Path:
"""
Get the destination path for a movie file.
Structure: /movies/Movie Title (Year)/Movie.Title.Year.Quality.ext
Args:
movie: Movie entity
filename: Original filename (to extract extension)
Returns:
Full destination path
"""
# Create movie folder
folder_name = movie.get_folder_name()
movie_dir = self.movie_folder / folder_name
# Get extension from original filename
extension = Path(filename).suffix
# Create new filename
new_filename = movie.get_filename() + extension
return movie_dir / new_filename
def get_episode_destination(
self, show: TVShow, episode: Episode, filename: str
) -> Path:
"""
Get the destination path for a TV show episode file.
Structure: /tvshows/Show.Name/Season 01/S01E05.Episode.Title.ext
Args:
show: TVShow entity
episode: Episode entity
filename: Original filename (to extract extension)
Returns:
Full destination path
"""
# Create show folder
show_folder_name = show.get_folder_name()
show_dir = self.tvshow_folder / show_folder_name
# Create season folder
season = Season(
show_imdb_id=show.imdb_id,
season_number=episode.season_number,
episode_count=0, # Not needed for folder name
)
season_folder_name = season.get_folder_name()
season_dir = show_dir / season_folder_name
# Get extension from original filename
extension = Path(filename).suffix
# Create new filename
new_filename = episode.get_filename() + extension
return season_dir / new_filename
def create_movie_directory(self, movie: Movie) -> bool:
"""
Create the directory structure for a movie.
Args:
movie: Movie entity
Returns:
True if successful
"""
folder_name = movie.get_folder_name()
movie_dir = self.movie_folder / folder_name
try:
movie_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Created movie directory: {movie_dir}")
return True
except Exception as e:
logger.error(f"Failed to create movie directory: {e}")
return False
def create_episode_directory(self, show: TVShow, season_number: int) -> bool:
"""
Create the directory structure for a TV show season.
Args:
show: TVShow entity
season_number: Season number
Returns:
True if successful
"""
show_folder_name = show.get_folder_name()
show_dir = self.tvshow_folder / show_folder_name
season = Season(
show_imdb_id=show.imdb_id,
season_number=SeasonNumber(season_number),
episode_count=0,
)
season_folder_name = season.get_folder_name()
season_dir = show_dir / season_folder_name
try:
season_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Created season directory: {season_dir}")
return True
except Exception as e:
logger.error(f"Failed to create season directory: {e}")
return False

View File

@@ -0,0 +1,25 @@
"""Persistence layer - Data storage implementations."""
from .context import (
get_memory,
has_memory,
init_memory,
set_memory,
)
from .memory import (
EpisodicMemory,
LongTermMemory,
Memory,
ShortTermMemory,
)
__all__ = [
"Memory",
"LongTermMemory",
"ShortTermMemory",
"EpisodicMemory",
"init_memory",
"set_memory",
"get_memory",
"has_memory",
]

View File

@@ -0,0 +1,79 @@
"""
Memory context using contextvars.
Provides thread-safe and async-safe access to the Memory instance
without passing it explicitly through all function calls.
Usage:
# At application startup
from infrastructure.persistence import init_memory, get_memory
init_memory("memory_data")
# Anywhere in the code
memory = get_memory()
memory.ltm.set_config("key", "value")
"""
from contextvars import ContextVar
from .memory import Memory
_memory_ctx: ContextVar[Memory | None] = ContextVar("memory", default=None)
def init_memory(storage_dir: str = "memory_data") -> Memory:
"""
Initialize the memory and set it in the context.
Call this once at application startup.
Args:
storage_dir: Directory for persistent storage.
Returns:
The initialized Memory instance.
"""
memory = Memory(storage_dir=storage_dir)
_memory_ctx.set(memory)
return memory
def set_memory(memory: Memory) -> None:
"""
Set an existing Memory instance in the context.
Useful for testing or when injecting a specific instance.
Args:
memory: Memory instance to set.
"""
_memory_ctx.set(memory)
def get_memory() -> Memory:
"""
Get the Memory instance from the context.
Returns:
The Memory instance.
Raises:
RuntimeError: If memory has not been initialized.
"""
memory = _memory_ctx.get()
if memory is None:
raise RuntimeError(
"Memory not initialized. Call init_memory() at application startup."
)
return memory
def has_memory() -> bool:
"""
Check if memory has been initialized.
Returns:
True if memory is available, False otherwise.
"""
return _memory_ctx.get() is not None

View File

@@ -0,0 +1,11 @@
"""JSON-based repository implementations."""
from .movie_repository import JsonMovieRepository
from .subtitle_repository import JsonSubtitleRepository
from .tvshow_repository import JsonTVShowRepository
__all__ = [
"JsonMovieRepository",
"JsonTVShowRepository",
"JsonSubtitleRepository",
]

View File

@@ -0,0 +1,144 @@
"""JSON-based movie repository implementation."""
import logging
from datetime import datetime
from typing import Any
from domain.movies.entities import Movie
from domain.movies.repositories import MovieRepository
from domain.movies.value_objects import MovieTitle, Quality, ReleaseYear
from domain.shared.value_objects import FilePath, FileSize, ImdbId
from infrastructure.persistence import get_memory
logger = logging.getLogger(__name__)
class JsonMovieRepository(MovieRepository):
"""
JSON-based implementation of MovieRepository.
Stores movies in the LTM library using the memory context.
"""
def save(self, movie: Movie) -> None:
"""
Save a movie to the repository.
Updates existing movie if IMDb ID matches.
Args:
movie: Movie entity to save.
"""
memory = get_memory()
movies = memory.ltm.library.get("movies", [])
# Remove existing movie with same IMDb ID
movies = [m for m in movies if m.get("imdb_id") != str(movie.imdb_id)]
movies.append(self._to_dict(movie))
memory.ltm.library["movies"] = movies
memory.save()
logger.debug(f"Saved movie: {movie.imdb_id}")
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.
"""
memory = get_memory()
movies = memory.ltm.library.get("movies", [])
for movie_dict in movies:
if movie_dict.get("imdb_id") == str(imdb_id):
return self._from_dict(movie_dict)
return None
def find_all(self) -> list[Movie]:
"""
Get all movies in the repository.
Returns:
List of all Movie entities.
"""
memory = get_memory()
movies_dict = memory.ltm.library.get("movies", [])
return [self._from_dict(m) for m in movies_dict]
def delete(self, imdb_id: ImdbId) -> bool:
"""
Delete a movie from the repository.
Args:
imdb_id: IMDb ID of movie to delete.
Returns:
True if deleted, False if not found.
"""
memory = get_memory()
movies = memory.ltm.library.get("movies", [])
initial_count = len(movies)
movies = [m for m in movies if m.get("imdb_id") != str(imdb_id)]
if len(movies) < initial_count:
memory.ltm.library["movies"] = movies
memory.save()
logger.debug(f"Deleted movie: {imdb_id}")
return True
return False
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.
"""
return self.find_by_imdb_id(imdb_id) is not None
def _to_dict(self, movie: Movie) -> dict[str, Any]:
"""Convert Movie entity to dict for storage."""
return {
"imdb_id": str(movie.imdb_id),
"title": movie.title.value,
"release_year": movie.release_year.value if movie.release_year else None,
"quality": movie.quality.value,
"file_path": str(movie.file_path) if movie.file_path else None,
"file_size": movie.file_size.bytes if movie.file_size else None,
"tmdb_id": movie.tmdb_id,
"added_at": movie.added_at.isoformat(),
}
def _from_dict(self, data: dict[str, Any]) -> Movie:
"""Convert dict from storage to Movie entity."""
# Parse quality string to enum
quality_str = data.get("quality", "unknown")
quality = Quality.from_string(quality_str)
return Movie(
imdb_id=ImdbId(data["imdb_id"]),
title=MovieTitle(data["title"]),
release_year=(
ReleaseYear(data["release_year"]) if data.get("release_year") else None
),
quality=quality,
file_path=FilePath(data["file_path"]) if data.get("file_path") else None,
file_size=FileSize(data["file_size"]) if data.get("file_size") else None,
tmdb_id=data.get("tmdb_id"),
added_at=(
datetime.fromisoformat(data["added_at"])
if data.get("added_at")
else datetime.now()
),
)

View File

@@ -0,0 +1,144 @@
"""JSON-based subtitle repository implementation."""
import logging
from typing import Any
from domain.shared.value_objects import FilePath, ImdbId
from domain.subtitles.entities import Subtitle
from domain.subtitles.repositories import SubtitleRepository
from domain.subtitles.value_objects import Language, SubtitleFormat, TimingOffset
from infrastructure.persistence import get_memory
logger = logging.getLogger(__name__)
class JsonSubtitleRepository(SubtitleRepository):
"""
JSON-based implementation of SubtitleRepository.
Stores subtitles in the LTM library using the memory context.
"""
def save(self, subtitle: Subtitle) -> None:
"""
Save a subtitle to the repository.
Multiple subtitles can exist for the same media.
Args:
subtitle: Subtitle entity to save.
"""
memory = get_memory()
subtitles = memory.ltm.library.get("subtitles", [])
subtitles.append(self._to_dict(subtitle))
if "subtitles" not in memory.ltm.library:
memory.ltm.library["subtitles"] = []
memory.ltm.library["subtitles"] = subtitles
memory.save()
logger.debug(f"Saved subtitle for: {subtitle.media_imdb_id}")
def find_by_media(
self,
media_imdb_id: ImdbId,
language: Language | None = None,
season: int | None = None,
episode: int | None = None,
) -> list[Subtitle]:
"""
Find subtitles for a media item.
Args:
media_imdb_id: IMDb ID of the media.
language: Optional language filter.
season: Optional season number filter.
episode: Optional episode number filter.
Returns:
List of matching Subtitle entities.
"""
memory = get_memory()
subtitles = memory.ltm.library.get("subtitles", [])
results = []
for sub_dict in subtitles:
if sub_dict.get("media_imdb_id") != str(media_imdb_id):
continue
if language and sub_dict.get("language") != language.value:
continue
if season is not None and sub_dict.get("season_number") != season:
continue
if episode is not None and sub_dict.get("episode_number") != episode:
continue
results.append(self._from_dict(sub_dict))
return results
def delete(self, subtitle: Subtitle) -> bool:
"""
Delete a subtitle from the repository.
Matches by file path.
Args:
subtitle: Subtitle entity to delete.
Returns:
True if deleted, False if not found.
"""
memory = get_memory()
subtitles = memory.ltm.library.get("subtitles", [])
initial_count = len(subtitles)
subtitles = [
s for s in subtitles if s.get("file_path") != str(subtitle.file_path)
]
if len(subtitles) < initial_count:
memory.ltm.library["subtitles"] = subtitles
memory.save()
logger.debug(f"Deleted subtitle: {subtitle.file_path}")
return True
return False
def _to_dict(self, subtitle: Subtitle) -> dict[str, Any]:
"""Convert Subtitle entity to dict for storage."""
return {
"media_imdb_id": str(subtitle.media_imdb_id),
"language": subtitle.language.value,
"format": subtitle.format.value,
"file_path": str(subtitle.file_path),
"season_number": subtitle.season_number,
"episode_number": subtitle.episode_number,
"timing_offset": subtitle.timing_offset.milliseconds,
"hearing_impaired": subtitle.hearing_impaired,
"forced": subtitle.forced,
"source": subtitle.source,
"uploader": subtitle.uploader,
"download_count": subtitle.download_count,
"rating": subtitle.rating,
}
def _from_dict(self, data: dict[str, Any]) -> Subtitle:
"""Convert dict from storage to Subtitle entity."""
return Subtitle(
media_imdb_id=ImdbId(data["media_imdb_id"]),
language=Language.from_code(data["language"]),
format=SubtitleFormat.from_extension(data["format"]),
file_path=FilePath(data["file_path"]),
season_number=data.get("season_number"),
episode_number=data.get("episode_number"),
timing_offset=TimingOffset(data.get("timing_offset", 0)),
hearing_impaired=data.get("hearing_impaired", False),
forced=data.get("forced", False),
source=data.get("source"),
uploader=data.get("uploader"),
download_count=data.get("download_count"),
rating=data.get("rating"),
)

View File

@@ -0,0 +1,136 @@
"""JSON-based TV show repository implementation."""
import logging
from datetime import datetime
from typing import Any
from domain.shared.value_objects import ImdbId
from domain.tv_shows.entities import TVShow
from domain.tv_shows.repositories import TVShowRepository
from domain.tv_shows.value_objects import ShowStatus
from infrastructure.persistence import get_memory
logger = logging.getLogger(__name__)
class JsonTVShowRepository(TVShowRepository):
"""
JSON-based implementation of TVShowRepository.
Stores TV shows in the LTM library using the memory context.
"""
def save(self, show: TVShow) -> None:
"""
Save a TV show to the repository.
Updates existing show if IMDb ID matches.
Args:
show: TVShow entity to save.
"""
memory = get_memory()
shows = memory.ltm.library.get("tv_shows", [])
# Remove existing show with same IMDb ID
shows = [s for s in shows if s.get("imdb_id") != str(show.imdb_id)]
shows.append(self._to_dict(show))
memory.ltm.library["tv_shows"] = shows
memory.save()
logger.debug(f"Saved TV show: {show.imdb_id}")
def find_by_imdb_id(self, imdb_id: ImdbId) -> TVShow | None:
"""
Find a TV show by its IMDb ID.
Args:
imdb_id: IMDb ID to search for.
Returns:
TVShow if found, None otherwise.
"""
memory = get_memory()
shows = memory.ltm.library.get("tv_shows", [])
for show_dict in shows:
if show_dict.get("imdb_id") == str(imdb_id):
return self._from_dict(show_dict)
return None
def find_all(self) -> list[TVShow]:
"""
Get all TV shows in the repository.
Returns:
List of all TVShow entities.
"""
memory = get_memory()
shows_dict = memory.ltm.library.get("tv_shows", [])
return [self._from_dict(s) for s in shows_dict]
def delete(self, imdb_id: ImdbId) -> bool:
"""
Delete a TV show from the repository.
Args:
imdb_id: IMDb ID of show to delete.
Returns:
True if deleted, False if not found.
"""
memory = get_memory()
shows = memory.ltm.library.get("tv_shows", [])
initial_count = len(shows)
shows = [s for s in shows if s.get("imdb_id") != str(imdb_id)]
if len(shows) < initial_count:
memory.ltm.library["tv_shows"] = shows
memory.save()
logger.debug(f"Deleted TV show: {imdb_id}")
return True
return False
def exists(self, imdb_id: ImdbId) -> bool:
"""
Check if a TV show exists in the repository.
Args:
imdb_id: IMDb ID to check.
Returns:
True if exists, False otherwise.
"""
return self.find_by_imdb_id(imdb_id) is not None
def _to_dict(self, show: TVShow) -> dict[str, Any]:
"""Convert TVShow entity to dict for storage."""
return {
"imdb_id": str(show.imdb_id),
"title": show.title,
"seasons_count": show.seasons_count,
"status": show.status.value,
"tmdb_id": show.tmdb_id,
"first_air_date": show.first_air_date,
"added_at": show.added_at.isoformat(),
}
def _from_dict(self, data: dict[str, Any]) -> TVShow:
"""Convert dict from storage to TVShow entity."""
return TVShow(
imdb_id=ImdbId(data["imdb_id"]),
title=data["title"],
seasons_count=data["seasons_count"],
status=ShowStatus.from_string(data["status"]),
tmdb_id=data.get("tmdb_id"),
first_air_date=data.get("first_air_date"),
added_at=(
datetime.fromisoformat(data["added_at"])
if data.get("added_at")
else datetime.now()
),
)

View File

@@ -0,0 +1,577 @@
"""
Memory - Unified management of 3 memory types.
Architecture:
- LTM (Long-Term Memory): Configuration, library, preferences - Persistent
- STM (Short-Term Memory): Conversation, current workflow - Volatile
- Episodic Memory: Search results, transient states - Very volatile
"""
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# =============================================================================
# LONG-TERM MEMORY (LTM) - Persistent
# =============================================================================
@dataclass
class LongTermMemory:
"""
Long-term memory - Persistent and static.
Stores:
- User configuration (folders, URLs)
- Preferences (quality, languages)
- Library (owned movies/TV shows)
- Followed shows (watchlist)
"""
# Folder and service configuration
config: dict[str, str] = field(default_factory=dict)
# User preferences
preferences: dict[str, Any] = field(
default_factory=lambda: {
"preferred_quality": "1080p",
"preferred_languages": ["en", "fr"],
"auto_organize": False,
"naming_format": "{title}.{year}.{quality}",
}
)
# Library of owned media
library: dict[str, list[dict]] = field(
default_factory=lambda: {"movies": [], "tv_shows": []}
)
# Followed shows (watchlist)
following: list[dict] = field(default_factory=list)
def get_config(self, key: str, default: Any = None) -> Any:
"""Get a configuration value."""
return self.config.get(key, default)
def set_config(self, key: str, value: Any) -> None:
"""Set a configuration value."""
self.config[key] = value
logger.debug(f"LTM: Set config {key}")
def has_config(self, key: str) -> bool:
"""Check if a configuration exists."""
return key in self.config and self.config[key] is not None
def add_to_library(self, media_type: str, media: dict) -> None:
"""Add a media item to the library."""
if media_type not in self.library:
self.library[media_type] = []
# Avoid duplicates by imdb_id
existing_ids = [m.get("imdb_id") for m in self.library[media_type]]
if media.get("imdb_id") not in existing_ids:
media["added_at"] = datetime.now().isoformat()
self.library[media_type].append(media)
logger.info(f"LTM: Added {media.get('title')} to {media_type}")
def get_library(self, media_type: str) -> list[dict]:
"""Get the library for a media type."""
return self.library.get(media_type, [])
def follow_show(self, show: dict) -> None:
"""Add a show to the watchlist."""
existing_ids = [s.get("imdb_id") for s in self.following]
if show.get("imdb_id") not in existing_ids:
show["followed_at"] = datetime.now().isoformat()
self.following.append(show)
logger.info(f"LTM: Now following {show.get('title')}")
def to_dict(self) -> dict:
"""Convert to dictionary for serialization."""
return {
"config": self.config,
"preferences": self.preferences,
"library": self.library,
"following": self.following,
}
@classmethod
def from_dict(cls, data: dict) -> "LongTermMemory":
"""Create an instance from a dictionary."""
return cls(
config=data.get("config", {}),
preferences=data.get(
"preferences",
{
"preferred_quality": "1080p",
"preferred_languages": ["en", "fr"],
"auto_organize": False,
"naming_format": "{title}.{year}.{quality}",
},
),
library=data.get("library", {"movies": [], "tv_shows": []}),
following=data.get("following", []),
)
# =============================================================================
# SHORT-TERM MEMORY (STM) - Conversation
# =============================================================================
@dataclass
class ShortTermMemory:
"""
Short-term memory - Volatile and conversational.
Stores:
- Current conversation history
- Current workflow (what we're doing)
- Extracted entities from conversation
- Current discussion topic
"""
# Conversation message history
conversation_history: list[dict[str, str]] = field(default_factory=list)
# Current workflow
current_workflow: dict | None = None
# Extracted entities (title, year, requested quality, etc.)
extracted_entities: dict[str, Any] = field(default_factory=dict)
# Current conversation topic
current_topic: str | None = None
# Conversation language
language: str = "en"
# History message limit
max_history: int = 20
def add_message(self, role: str, content: str) -> None:
"""Add a message to history."""
self.conversation_history.append(
{"role": role, "content": content, "timestamp": datetime.now().isoformat()}
)
# Keep only the last N messages
if len(self.conversation_history) > self.max_history:
self.conversation_history = self.conversation_history[-self.max_history :]
logger.debug(f"STM: Added {role} message")
def get_recent_history(self, n: int = 10) -> list[dict]:
"""Get the last N messages."""
return self.conversation_history[-n:]
def start_workflow(self, workflow_type: str, target: dict) -> None:
"""Start a new workflow."""
self.current_workflow = {
"type": workflow_type,
"target": target,
"stage": "started",
"started_at": datetime.now().isoformat(),
}
logger.info(f"STM: Started workflow '{workflow_type}'")
def update_workflow_stage(self, stage: str) -> None:
"""Update the workflow stage."""
if self.current_workflow:
self.current_workflow["stage"] = stage
logger.debug(f"STM: Workflow stage -> {stage}")
def end_workflow(self) -> None:
"""End the current workflow."""
if self.current_workflow:
logger.info(f"STM: Ended workflow '{self.current_workflow.get('type')}'")
self.current_workflow = None
def set_entity(self, key: str, value: Any) -> None:
"""Store an extracted entity."""
self.extracted_entities[key] = value
logger.debug(f"STM: Set entity {key}={value}")
def get_entity(self, key: str, default: Any = None) -> Any:
"""Get an extracted entity."""
return self.extracted_entities.get(key, default)
def clear_entities(self) -> None:
"""Clear extracted entities."""
self.extracted_entities = {}
def set_topic(self, topic: str) -> None:
"""Set the current topic."""
self.current_topic = topic
logger.debug(f"STM: Topic -> {topic}")
def set_language(self, language: str) -> None:
"""Set the conversation language."""
self.language = language
logger.debug(f"STM: Language -> {language}")
def clear(self) -> None:
"""Reset short-term memory."""
self.conversation_history = []
self.current_workflow = None
self.extracted_entities = {}
self.current_topic = None
self.language = "en"
logger.info("STM: Cleared")
def to_dict(self) -> dict:
"""Convert to dictionary."""
return {
"conversation_history": self.conversation_history,
"current_workflow": self.current_workflow,
"extracted_entities": self.extracted_entities,
"current_topic": self.current_topic,
"language": self.language,
}
# =============================================================================
# EPISODIC MEMORY - Transient states
# =============================================================================
@dataclass
class EpisodicMemory:
"""
Episodic/sensory memory - Temporary and event-driven.
Stores:
- Last search results
- Active downloads
- Recent errors
- Pending questions awaiting user response
- Background events
"""
# Last search results
last_search_results: dict | None = None
# Active downloads
active_downloads: list[dict] = field(default_factory=list)
# Recent errors
recent_errors: list[dict] = field(default_factory=list)
# Pending question awaiting user response
pending_question: dict | None = None
# Background events (download complete, new files, etc.)
background_events: list[dict] = field(default_factory=list)
# Limits for errors/events kept
max_errors: int = 5
max_events: int = 10
def store_search_results(
self, query: str, results: list[dict], search_type: str = "torrent"
) -> None:
"""
Store search results with index.
Args:
query: The search query
results: List of results
search_type: Type of search (torrent, movie, tvshow)
"""
self.last_search_results = {
"query": query,
"type": search_type,
"timestamp": datetime.now().isoformat(),
"results": [{"index": i + 1, **r} for i, r in enumerate(results)],
}
logger.info(f"Episodic: Stored {len(results)} search results for '{query}'")
def get_result_by_index(self, index: int) -> dict | None:
"""
Get a result by its number (1-indexed).
Args:
index: Result number (1, 2, 3, ...)
Returns:
The result or None if not found
"""
if not self.last_search_results:
logger.warning("Episodic: No search results stored")
return None
for result in self.last_search_results.get("results", []):
if result.get("index") == index:
return result
logger.warning(f"Episodic: Result #{index} not found")
return None
def get_search_results(self) -> dict | None:
"""Get the last search results."""
return self.last_search_results
def clear_search_results(self) -> None:
"""Clear search results."""
self.last_search_results = None
def add_active_download(self, download: dict) -> None:
"""Add an active download."""
download["started_at"] = datetime.now().isoformat()
self.active_downloads.append(download)
logger.info(f"Episodic: Added download '{download.get('name')}'")
def update_download_progress(
self, task_id: str, progress: int, status: str = "downloading"
) -> None:
"""Update download progress."""
for dl in self.active_downloads:
if dl.get("task_id") == task_id:
dl["progress"] = progress
dl["status"] = status
dl["updated_at"] = datetime.now().isoformat()
break
def complete_download(self, task_id: str, file_path: str) -> dict | None:
"""Mark a download as complete and remove it."""
for i, dl in enumerate(self.active_downloads):
if dl.get("task_id") == task_id:
completed = self.active_downloads.pop(i)
completed["status"] = "completed"
completed["file_path"] = file_path
completed["completed_at"] = datetime.now().isoformat()
# Add a background event
self.add_background_event(
"download_complete",
{"name": completed.get("name"), "file_path": file_path},
)
logger.info(f"Episodic: Download completed '{completed.get('name')}'")
return completed
return None
def get_active_downloads(self) -> list[dict]:
"""Get active downloads."""
return self.active_downloads
def add_error(self, action: str, error: str, context: dict | None = None) -> None:
"""Record a recent error."""
self.recent_errors.append(
{
"timestamp": datetime.now().isoformat(),
"action": action,
"error": error,
"context": context or {},
}
)
# Keep only the last N errors
self.recent_errors = self.recent_errors[-self.max_errors :]
logger.warning(f"Episodic: Error in '{action}': {error}")
def get_recent_errors(self) -> list[dict]:
"""Get recent errors."""
return self.recent_errors
def set_pending_question(
self,
question: str,
options: list[dict],
context: dict,
question_type: str = "choice",
) -> None:
"""
Record a question awaiting user response.
Args:
question: The question asked
options: List of possible options
context: Question context
question_type: Type of question (choice, confirmation, input)
"""
self.pending_question = {
"type": question_type,
"question": question,
"options": options,
"context": context,
"timestamp": datetime.now().isoformat(),
}
logger.info(f"Episodic: Pending question set ({question_type})")
def get_pending_question(self) -> dict | None:
"""Get the pending question."""
return self.pending_question
def resolve_pending_question(self, answer_index: int | None = None) -> dict | None:
"""
Resolve the pending question and return the chosen option.
Args:
answer_index: Answer index (1-indexed) or None to cancel
Returns:
The chosen option or None
"""
if not self.pending_question:
return None
result = None
if answer_index is not None and self.pending_question.get("options"):
for opt in self.pending_question["options"]:
if opt.get("index") == answer_index:
result = opt
break
self.pending_question = None
logger.info("Episodic: Pending question resolved")
return result
def add_background_event(self, event_type: str, data: dict) -> None:
"""Add a background event."""
self.background_events.append(
{
"type": event_type,
"timestamp": datetime.now().isoformat(),
"data": data,
"read": False,
}
)
# Keep only the last N events
self.background_events = self.background_events[-self.max_events :]
logger.info(f"Episodic: Background event '{event_type}'")
def get_unread_events(self) -> list[dict]:
"""Get unread events and mark them as read."""
unread = [e for e in self.background_events if not e.get("read")]
for e in self.background_events:
e["read"] = True
return unread
def clear(self) -> None:
"""Reset episodic memory."""
self.last_search_results = None
self.active_downloads = []
self.recent_errors = []
self.pending_question = None
self.background_events = []
logger.info("Episodic: Cleared")
def to_dict(self) -> dict:
"""Convert to dictionary."""
return {
"last_search_results": self.last_search_results,
"active_downloads": self.active_downloads,
"recent_errors": self.recent_errors,
"pending_question": self.pending_question,
"background_events": self.background_events,
}
# =============================================================================
# MEMORY MANAGER - Unified manager
# =============================================================================
class Memory:
"""
Unified manager for the 3 memory types.
Usage:
memory = Memory("memory_data")
memory.ltm.set_config("download_folder", "/path")
memory.stm.add_message("user", "Hello")
memory.episodic.store_search_results("query", results)
memory.save()
"""
def __init__(self, storage_dir: str = "memory_data"):
"""
Initialize the memory.
Args:
storage_dir: Directory for persistent storage
"""
self.storage_dir = Path(storage_dir)
self.storage_dir.mkdir(parents=True, exist_ok=True)
self.ltm_file = self.storage_dir / "ltm.json"
# Initialize the 3 memory types
self.ltm = self._load_ltm()
self.stm = ShortTermMemory()
self.episodic = EpisodicMemory()
logger.info(f"Memory initialized (storage: {storage_dir})")
def _load_ltm(self) -> LongTermMemory:
"""Load LTM from file."""
if self.ltm_file.exists():
try:
data = json.loads(self.ltm_file.read_text(encoding="utf-8"))
logger.info("LTM loaded from file")
return LongTermMemory.from_dict(data)
except (OSError, json.JSONDecodeError) as e:
logger.warning(f"Could not load LTM: {e}")
return LongTermMemory()
def save(self) -> None:
"""Save LTM (the only persistent memory)."""
try:
self.ltm_file.write_text(
json.dumps(self.ltm.to_dict(), indent=2, ensure_ascii=False),
encoding="utf-8",
)
logger.debug("LTM saved to file")
except OSError as e:
logger.error(f"Failed to save LTM: {e}")
raise
def get_context_for_prompt(self) -> dict:
"""
Generate context to include in the system prompt.
Returns:
Dictionary with relevant context from all 3 memories
"""
return {
"config": self.ltm.config,
"preferences": self.ltm.preferences,
"current_workflow": self.stm.current_workflow,
"current_topic": self.stm.current_topic,
"extracted_entities": self.stm.extracted_entities,
"last_search": {
"query": (
self.episodic.last_search_results.get("query")
if self.episodic.last_search_results
else None
),
"result_count": (
len(self.episodic.last_search_results.get("results", []))
if self.episodic.last_search_results
else 0
),
},
"active_downloads_count": len(self.episodic.active_downloads),
"pending_question": self.episodic.pending_question is not None,
"unread_events": len(
[e for e in self.episodic.background_events if not e.get("read")]
),
}
def get_full_state(self) -> dict:
"""Return the full state of all 3 memories (for debug)."""
return {
"ltm": self.ltm.to_dict(),
"stm": self.stm.to_dict(),
"episodic": self.episodic.to_dict(),
}
def clear_session(self) -> None:
"""Clear session memories (STM + Episodic)."""
self.stm.clear()
self.episodic.clear()
logger.info("Session memories cleared")

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