95 Commits

Author SHA1 Message Date
Renovate Bot
0b3c4b135b chore(deps): update dependency pytest to v9
Some checks failed
renovate/artifacts Artifact file update failure
2026-02-09 04:02:34 +00:00
aa89a3fb00 doc: updated README.md
All checks were successful
Renovate Bot / renovate (push) Successful in 22s
2026-01-04 13:30:54 +01:00
64aeb5fc80 chore: removed deprecated cli.py file 2026-01-04 13:29:38 +01:00
9540520dc4 feat: updated default start mode from core to full 2026-01-03 05:49:51 +01:00
300ed387f5 fix: fixed build vars generation not being called 2026-01-01 06:00:55 +01:00
dea81de5b5 fix: updated CI workflow and added .env.make generation for CI 2026-01-01 05:33:39 +01:00
01a00a12af chore: bump version 0.1.6 → 0.1.7
Some checks failed
CI/CD Awesome Pipeline / Test (push) Successful in 5m54s
CI/CD Awesome Pipeline / Build & Push to Registry (push) Failing after 1m9s
2026-01-01 05:04:37 +01:00
504d0162bb infra: added proper settings handling & orchestration and app bootstrap (.env)
Reviewed-on: https://gitea.iswearihadsomethingforthis.net/francwa/alfred/pulls/18
2026-01-01 03:55:35 +00:00
cda23d074f feat: added current alfred version from pyproject.py to healthcheck 2026-01-01 04:51:45 +01:00
0357108077 infra: added orchestration and app bootstrap (.env) 2026-01-01 04:48:32 +01:00
ab1df3dd0f fix: forgot to lint/format 2026-01-01 04:48:32 +01:00
c50091f6bf feat: added proper settings handling 2026-01-01 04:48:32 +01:00
8b406370f1 chore: updated some dependencies 2026-01-01 03:08:22 +01:00
c56bf2b92c feat: added Claude.AI to available providers 2025-12-29 02:13:11 +01:00
b1507db4d0 fix: fixed test image not being built before running tests
All checks were successful
Renovate Bot / renovate (push) Successful in 1m26s
2025-12-28 13:24:03 +01:00
3074962314 infra: proper librechat integration & improved configuration handling
Reviewed-on: https://gitea.iswearihadsomethingforthis.net/francwa/alfred/pulls/11
2025-12-28 11:56:53 +00:00
84799879bb fix: added data dir to .dockerignore 2025-12-28 12:02:44 +01:00
1052c1b619 infra: added modules version to .env.example 2025-12-28 06:07:34 +01:00
9958b8e848 feat: created placeholders for various API models 2025-12-28 06:06:28 +01:00
b15161dad7 fix: provided OpenAI API key and fixed docker-compose configuration to enable RAG service 2025-12-28 06:04:08 +01:00
52f025ae32 chore: ran linter & formatter again 2025-12-27 20:07:48 +01:00
2cfe7a035b infra: moved makefile logic to python and added .env setup 2025-12-27 19:51:40 +01:00
2441c2dc29 infra: rewrote docker-compose for proper integration of librechat 2025-12-27 19:49:43 +01:00
261a1f3918 fix: fixed real data directory being used in tests 2025-12-27 19:48:13 +01:00
253903a1e5 infra: made pyproject the SSOT 2025-12-27 19:46:27 +01:00
20a113e335 infra: updated .env.example 2025-12-27 19:43:31 +01:00
fed83e7d79 chore: bumped fastapi version 2025-12-27 19:42:16 +01:00
3880a4ec49 chore: ran linter and formatter 2025-12-27 19:41:22 +01:00
6195abbaa5 chore: fixed imports and tests configuration 2025-12-27 19:39:36 +01:00
b132554631 infra: updated .gitignore 2025-12-27 02:30:14 +01:00
561796cec1 infra: removed CORE_DIR variable from Makefile (not relevant anymore) 2025-12-24 11:28:28 +01:00
26d90acc16 infra: moved manifest files in librechat dir 2025-12-24 09:46:25 +01:00
d8234b2958 chore: cleaned up .env.example 2025-12-24 08:17:47 +01:00
156d1fe567 fix: fixed Dockerfile 2025-12-24 08:17:12 +01:00
f8eee120cf infra: backed up old deploy-compose 2025-12-24 08:16:30 +01:00
c5e4a5e1a7 infra: removed occurences of alfred_api_key (not implemented after all) 2025-12-24 08:11:30 +01:00
d10c9160f3 infra: renamed broken references to alfred 2025-12-24 08:09:26 +01:00
1f88e99e8b infra: reorganized repo 2025-12-24 07:50:09 +01:00
e097a13221 chore: upgraded to python 3.14 and some dependencies
Reviewed-on: https://gitea.iswearihadsomethingforthis.net/francwa/agent_media/pulls/8
2025-12-22 14:04:46 +00:00
086fff803d feat: configured CI/CD pipeline to allow build for feature branches 2025-12-22 14:48:50 +01:00
45fbf975b3 feat: improved rules for renovate 2025-12-22 14:25:16 +01:00
b8f2798e29 chore: updated fastapi and uvicorn 2025-12-22 14:20:22 +01:00
c762d91eb1 chore: updated to python 3.14 2025-12-22 14:08:27 +01:00
35a68387ab infra: use python version from pyproject.toml 2025-12-22 13:46:40 +01:00
9b13c69631 chore: updated meilisearch 2025-12-22 13:25:54 +01:00
2ca1ea29b2 infra: added meilisearch to renovate exclusion list 2025-12-22 13:22:55 +01:00
5e86615bde fix: added renovate token with proper permissions 2025-12-22 12:59:24 +01:00
6701a4b392 infra: added Renovate bot 2025-12-22 12:50:59 +01:00
68372405d6 fix: downgraded upload-artifact action to v3 from v4 2025-12-22 12:13:50 +01:00
f1ea0de247 fix: fixed indentation error 2025-12-22 12:04:47 +01:00
974d008825 feat: finalized CI/CD pipeline setup 2025-12-22 11:59:36 +01:00
8a87d94e6d fix: use docker image for trivy vulnerability scanner
Some checks failed
CI/CD Awesome Pipeline / Test (push) Successful in 1m23s
CI/CD Awesome Pipeline / Build & Push to Registry (push) Failing after 5m9s
2025-12-22 11:38:35 +01:00
ec99a501fc fix! added directive to Dockerfile 2025-12-22 11:37:48 +01:00
c256b26601 fix: fixed vulnerability scanner issue in CI/CD pipeline
Some checks failed
CI/CD Awesome Pipeline / Test (push) Successful in 48s
CI/CD Awesome Pipeline / Build & Push to Registry (push) Failing after 6m18s
2025-12-22 10:59:34 +01:00
56a3c1257d infra: added trivy vulnerability scanner to CI/CD
Some checks failed
CI/CD Awesome Pipeline / Test (push) Successful in 1m36s
CI/CD Awesome Pipeline / Build & Push to Registry (push) Failing after 7m10s
2025-12-22 10:01:52 +01:00
79d23f936a fix: fixed typo 2025-12-22 09:40:43 +01:00
f02e916d33 fix: fixed config gathering in ci.yml
Some checks failed
CI/CD Awesome Pipeline / Test (push) Successful in 1m34s
CI/CD Awesome Pipeline / Build & Push to Registry (push) Failing after 14m19s
2025-12-22 09:16:55 +01:00
4e64c83c4b fix: updated build and push CI/CD configuration
Some checks failed
CI/CD Awesome Pipeline / Test (push) Successful in 1m4s
CI/CD Awesome Pipeline / Build & Push to Registry (push) Failing after 2m32s
2025-12-22 08:45:31 +01:00
07cae9abd1 chore: bump version 0.1.5 → 0.1.6
All checks were successful
CI/CD Awesome Pipeline / Test (push) Successful in 1m23s
CI/CD Awesome Pipeline / Build & Push to Registry (push) Successful in 1m4s
2025-12-21 13:54:02 +01:00
21b2dffc37 fix: added gitea token 2025-12-21 13:53:49 +01:00
2d1055cccf chore: bump version 0.1.4 → 0.1.5
Some checks failed
CI/CD Awesome Pipeline / Test (push) Successful in 40s
CI/CD Awesome Pipeline / Build & Push to Registry (push) Failing after 20s
2025-12-21 13:01:37 +01:00
fdb2447862 debug: tired 2025-12-21 13:01:23 +01:00
13746ee8cc chore: bump version 0.1.3 → 0.1.4
Some checks failed
CI/CD Awesome Pipeline / Test (push) Successful in 37s
CI/CD Awesome Pipeline / Build & Push to Registry (push) Failing after 24s
2025-12-21 12:58:36 +01:00
49f31e492f debug: boring 2025-12-21 12:58:17 +01:00
f1fd1b11a1 chore: bump version 0.1.2 → 0.1.3
Some checks failed
CI/CD Awesome Pipeline / Test (push) Successful in 37s
CI/CD Awesome Pipeline / Build & Push to Registry (push) Failing after 25s
2025-12-21 12:54:38 +01:00
6f3b21ab17 debug: ... 2025-12-21 12:54:25 +01:00
566f0f6ea2 debug: wondering why prod image is not starting to build
All checks were successful
CI/CD Awesome Pipeline / Test (push) Successful in 37s
CI/CD Awesome Pipeline / Build & Push to Registry (push) Has been skipped
2025-12-21 12:49:24 +01:00
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
139 changed files with 17809 additions and 1819 deletions

18
.bumpversion.toml Normal file
View File

@@ -0,0 +1,18 @@
[tool.bumpversion]
current_version = "0.1.7"
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"

53
.dockerignore Normal file
View File

@@ -0,0 +1,53 @@
# 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
# Data
data/
memory_data/
logs/
*.log
# Misc
*.bak
*.tmp
.DS_Store
Thumbs.db

View File

@@ -1,16 +1,93 @@
# 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
MAX_HISTORY_MESSAGES=10
MAX_TOOL_ITERATIONS=10
REQUEST_TIMEOUT=30
# TMDB API Configuration
TMDB_API_KEY=your_tmdb_api_key_here
# LLM Settings
LLM_TEMPERATURE=0.2
# Persistence
DATA_STORAGE_DIR=data
# Network configuration
HOST=0.0.0.0
PORT=3080
# Build informations (Synced with pyproject.toml via bootstrap)
ALFRED_VERSION=
IMAGE_NAME=
LIBRECHAT_VERSION=
PYTHON_VERSION=
PYTHON_VERSION_SHORT=
RAG_VERSION=
RUNNER=
SERVICE_NAME=
# --- SECURITY KEYS (CRITICAL) ---
# These are used for session tokens and encrypting sensitive data in MongoDB.
# If you lose these, you lose access to encrypted stored credentials.
JWT_SECRET=
JWT_REFRESH_SECRET=
CREDS_KEY=
CREDS_IV=
# --- DATABASES (AUTO-SECURED) ---
# Alfred uses MongoDB for application state and PostgreSQL for Vector RAG.
# Passwords will be generated as 24-character secure tokens if left blank.
# MongoDB (Application Data)
MONGO_URI=
MONGO_HOST=mongodb
MONGO_PORT=27017
MONGO_USER=alfred
MONGO_PASSWORD=
MONGO_DB_NAME=alfred
# PostgreSQL (Vector Database / RAG)
POSTGRES_URI=
POSTGRES_HOST=vectordb
POSTGRES_PORT=5432
POSTGRES_USER=alfred
POSTGRES_PASSWORD=
POSTGRES_DB_NAME=alfred
# --- EXTERNAL SERVICES ---
# Media Metadata (Required)
# Get your key at https://www.themoviedb.org/
TMDB_API_KEY=
TMDB_BASE_URL=https://api.themoviedb.org/3
# Storage Configuration
MEMORY_FILE=memory.json
# qBittorrent integration
QBITTORRENT_URL=http://qbittorrent:16140
QBITTORRENT_USERNAME=admin
QBITTORRENT_PASSWORD=
QBITTORRENT_PORT=16140
# Security Configuration
MAX_TOOL_ITERATIONS=5
REQUEST_TIMEOUT=30
# Meilisearch
MEILI_ENABLED=FALSE
MEILI_NO_ANALYTICS=TRUE
MEILI_HOST=http://meilisearch:7700
MEILI_MASTER_KEY=
# --- LLM CONFIGURATION ---
# Providers: 'local', 'openai', 'anthropic', 'deepseek', 'google', 'kimi'
DEFAULT_LLM_PROVIDER=local
# Local LLM (Ollama)
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_MODEL=llama3.3:latest
# --- API KEYS (OPTIONAL) ---
# Fill only the ones you intend to use.
ANTHROPIC_API_KEY=
DEEPSEEK_API_KEY=
GOOGLE_API_KEY=
KIMI_API_KEY=
OPENAI_API_KEY=
# --- RAG ENGINE ---
# Enable/Disable the Retrieval Augmented Generation system
RAG_ENABLED=TRUE
RAG_API_URL=http://rag_api:8000
RAG_API_PORT=8000
EMBEDDINGS_PROVIDER=ollama
EMBEDDINGS_MODEL=nomic-embed-text

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

@@ -0,0 +1,92 @@
name: CI/CD Awesome Pipeline
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
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
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Generate build variables
run: python scripts/generate_build_vars.py
- name: Load config from Makefile
id: config
run: make -s _ci-dump-config >> $GITHUB_OUTPUT
- name: 🏷️ Docker Metadata (Tags & Labels)
id: meta
uses: docker/metadata-action@v5
with:
images: gitea.iswearihadsomethingforthis.net/francwa/${{ steps.config.outputs.image_name }}
tags: |
# Tagged (v1.2.3)
type=semver,pattern={{ version }}
# Latest (main)
type=raw,value=latest,enable={{ is_default_branch }}
# Feature branches
type=ref,event=branch
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: gitea.iswearihadsomethingforthis.net
username: ${{ gitea.actor }}
password: ${{ secrets.G1T34_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
PYTHON_VERSION=${{ steps.config.outputs.python_version }}
PYTHON_VERSION_SHORT=${{ steps.config.outputs.python_version_short }}
RUNNER=${{ steps.config.outputs.runner }}
- name: 🛡️ Run Trivy Vulnerability Scanner
uses: docker://aquasec/trivy:latest
env:
TRIVY_USERNAME: ${{ gitea.actor }}
TRIVY_PASSWORD: ${{ secrets.G1T34_TOKEN }}
# Unset the fake GITHUB_TOKEN injected by Gitea
GITHUB_TOKEN: ""
with:
args: image --format table --output trivy-report.txt --exit-code 0 --ignore-unfixed --severity CRITICAL,HIGH gitea.iswearihadsomethingforthis.net/francwa/${{ steps.config.outputs.image_name }}:latest
- name: 📤 Upload Security Report
uses: actions/upload-artifact@v3
with:
name: security-report
path: trivy-report.txt
retention-days: 7

View File

@@ -0,0 +1,22 @@
name: Renovate Bot
on:
schedule:
# Every Monday 4AM
- cron: '0 4 * * 1'
workflow_dispatch:
jobs:
renovate:
runs-on: ubuntu-latest
steps:
- name: Run Renovate
uses: docker://renovate/renovate:latest
env:
RENOVATE_PLATFORM: "gitea"
RENOVATE_ENDPOINT: "https://gitea.iswearihadsomethingforthis.net/api/v1"
RENOVATE_TOKEN: "${{ secrets.RENOVATE_TOKEN }}"
RENOVATE_REPOSITORIES: '["${{ gitea.repository }}"]'
RENOVATE_GIT_AUTHOR: "Renovate Bot <renovate@bot.local>"
# Might need a free github token if lots of depencies
# RENOVATE_GITHUB_TOKEN: "${{ secrets.GITHUB_COM_TOKEN }}"

21
.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,12 @@ Thumbs.db
# Secrets
.env
# Backup files
*.backup
# Application data dir
data/*
# Application logs
logs/*

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

122
Dockerfile Normal file
View File

@@ -0,0 +1,122 @@
# syntax=docker/dockerfile:1
# check=skip=InvalidDefaultArgInFrom
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 pyproject.toml poetry.lock* 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
COPY scripts/ ./scripts/
COPY .env.example ./
# ===========================================
# 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 alfred/ ./alfred
COPY scripts ./scripts
COPY tests/ ./tests
# ===========================================
# Stage 3: Runtime
# ===========================================
FROM python:${PYTHON_VERSION}-slim-bookworm AS runtime
ARG PYTHON_VERSION_SHORT
# TODO: A-t-on encore besoin de toutes les clés ?
ENV LLM_PROVIDER=deepseek \
MEMORY_STORAGE_DIR=/data/memory \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/home/appuser \
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 /logs \
&& chown -R appuser:appuser /data /logs
# Switch to non-root user
USER appuser
# Set working directory (owned by appuser)
WORKDIR /home/appuser
# Copy Python packages from builder stage
COPY --from=builder /usr/local/lib/python${PYTHON_VERSION_SHORT}/site-packages /usr/local/lib/python${PYTHON_VERSION_SHORT}/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# Copy application code (already owned by appuser)
COPY --chown=appuser:appuser alfred/ ./alfred
COPY --chown=appuser:appuser scripts/ ./scripts
COPY --chown=appuser:appuser .env.example ./
COPY --chown=appuser:appuser pyproject.toml ./
# Create volumes for persistent data
VOLUME ["/data", "/logs"]
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/health', timeout=5).raise_for_status()" || exit 1
CMD ["python", "-m", "uvicorn", "alfred.app:app", "--host", "0.0.0.0", "--port", "8000"]

182
Makefile Normal file
View File

@@ -0,0 +1,182 @@
.DEFAULT_GOAL := help
# --- Load Config from pyproject.toml ---
-include .env.make
# --- Profiles management ---
# Usage: make up p=rag,meili
p ?= full
PROFILES_PARAM := COMPOSE_PROFILES=$(p)
# --- Commands ---
DOCKER_COMPOSE := docker compose
DOCKER_BUILD := docker build --no-cache \
--build-arg PYTHON_VERSION=$(PYTHON_VERSION) \
--build-arg PYTHON_VERSION_SHORT=$(PYTHON_VERSION_SHORT) \
--build-arg RUNNER=$(RUNNER)
# --- Phony ---
.PHONY: .env bootstrap up down restart logs ps shell build build-test install \
update install-hooks test coverage lint format clean major minor patch help
# --- Setup ---
.env .env.make:
@echo "Initializing environment..."
@python scripts/bootstrap.py \
&& echo "✓ Environment ready" \
|| (echo "✗ Environment setup failed" && exit 1)
bootstrap: .env .env.make
# --- Docker ---
up: .env
@echo "Starting containers with profiles: [full]..."
@$(PROFILES_PARAM) $(DOCKER_COMPOSE) up -d --remove-orphans \
&& echo "✓ Containers started" \
|| (echo "✗ Failed to start containers" && exit 1)
down:
@echo "Stopping containers..."
@$(PROFILES_PARAM) $(DOCKER_COMPOSE) down \
&& echo "✓ Containers stopped" \
|| (echo "✗ Failed to stop containers" && exit 1)
restart:
@echo "Restarting containers..."
@$(PROFILES_PARAM) $(DOCKER_COMPOSE) restart \
&& echo "✓ Containers restarted" \
|| (echo "✗ Failed to restart containers" && exit 1)
logs:
@echo "Following logs (Ctrl+C to exit)..."
@$(PROFILES_PARAM) $(DOCKER_COMPOSE) logs -f
ps:
@echo "Container status:"
@$(PROFILES_PARAM) $(DOCKER_COMPOSE) ps
shell:
@echo "Opening shell in $(SERVICE_NAME)..."
@$(DOCKER_COMPOSE) exec $(SERVICE_NAME) /bin/bash
# --- Build ---
build: .env.make
@echo "Building image $(IMAGE_NAME):latest ..."
@$(DOCKER_BUILD) -t $(IMAGE_NAME):latest . \
&& echo "✓ Build complete" \
|| (echo "✗ Build failed" && exit 1)
build-test: .env.make
@echo "Building test image $(IMAGE_NAME):test..."
@$(DOCKER_BUILD) --target test -t $(IMAGE_NAME):test . \
&& echo "✓ Test image built" \
|| (echo "✗ Build failed" && exit 1)
# --- Dependencies ---
install:
@echo "Installing dependencies with $(RUNNER)..."
@$(RUNNER) install \
&& echo "✓ Dependencies installed" \
|| (echo "✗ Installation failed" && exit 1)
install-hooks:
@echo "Installing pre-commit hooks..."
@$(RUNNER) run pre-commit install \
&& echo "✓ Hooks installed" \
|| (echo "✗ Hook installation failed" && exit 1)
update:
@echo "Updating dependencies with $(RUNNER)..."
@$(RUNNER) update \
&& echo "✓ Dependencies updated" \
|| (echo "✗ Update failed" && exit 1)
# --- Quality ---
test:
@echo "Running tests..."
@$(RUNNER) run pytest \
&& echo "✓ Tests passed" \
|| (echo "✗ Tests failed" && exit 1)
coverage:
@echo "Running tests with coverage..."
@$(RUNNER) run pytest --cov=. --cov-report=html --cov-report=term \
&& echo "✓ Coverage report generated" \
|| (echo "✗ Coverage failed" && exit 1)
lint:
@echo "Linting code..."
@$(RUNNER) run ruff check --fix . \
&& echo "✓ Linting complete" \
|| (echo "✗ Linting failed" && exit 1)
format:
@echo "Formatting code..."
@$(RUNNER) run ruff format . && $(RUNNER) run ruff check --fix . \
&& echo "✓ Code formatted" \
|| (echo "✗ Formatting failed" && exit 1)
clean:
@echo "Cleaning build artifacts..."
@rm -rf .ruff_cache __pycache__ .pytest_cache htmlcov .coverage
@find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
@echo "✓ Cleanup complete"
# --- Versioning ---
major minor patch: _check-main
@echo "Bumping $@ version..."
@$(RUNNER) run bump-my-version bump $@ \
&& echo "✓ Version bumped" \
|| (echo "✗ Version bump failed" && exit 1)
@echo "Pushing tags..."
@git push --tags \
&& echo "✓ Tags pushed" \
|| (echo "✗ Push failed" && exit 1)
# CI/CD helpers
_ci-dump-config:
@echo "image_name=$(IMAGE_NAME)"
@echo "python_version=$(PYTHON_VERSION)"
@echo "python_version_short=$(PYTHON_VERSION_SHORT)"
@echo "runner=$(RUNNER)"
@echo "service_name=$(SERVICE_NAME)"
_ci-run-tests:build-test
@echo "Running tests in Docker..."
docker run --rm \
-e DEEPSEEK_API_KEY \
-e TMDB_API_KEY \
-e QBITTORRENT_URL \
$(IMAGE_NAME):test pytest
@echo "✓ Tests passed."
_check-main:
@test "$$(git rev-parse --abbrev-ref HEAD)" = "main" \
|| (echo "✗ ERROR: Not on main branch" && exit 1)
# --- Help ---
help:
@echo "Cleverly Crafted Unawareness - Management Commands"
@echo ""
@echo "Usage: make [target] [p=profile1,profile2]"
@echo ""
@echo "Docker:"
@echo " up Start containers (default profile: core)"
@echo " Example: make up p=rag,meili"
@echo " down Stop all containers"
@echo " restart Restart containers (supports p=...)"
@echo " logs Follow logs (supports p=...)"
@echo " ps Status of containers"
@echo " shell Open bash in the core container"
@echo " build Build the production Docker image"
@echo ""
@echo "Dev & Quality:"
@echo " setup Bootstrap .env and security keys"
@echo " install Install dependencies via $(RUNNER)"
@echo " test Run pytest suite"
@echo " coverage Run tests and generate HTML report"
@echo " lint/format Quality and style checks"
@echo ""
@echo "Release:"
@echo " major|minor|patch Bump version and push tags (main branch only)"

595
README.md Normal file
View File

@@ -0,0 +1,595 @@
# Alfred Media Organizer 🎬
An AI-powered agent for managing your local media library with natural language. Search, download, and organize movies and TV shows effortlessly through a conversational interface.
[![Python 3.14](https://img.shields.io/badge/python-3.14-blue.svg)](https://www.python.org/downloads/)
[![Poetry](https://img.shields.io/badge/dependency%20manager-poetry-blue)](https://python-poetry.org/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff)
## ✨ Features
- 🤖 **Natural Language Interface** — Talk to your media library in plain language
- 🔍 **Smart Search** — Find movies and TV shows via TMDB with rich metadata
- 📥 **Torrent Integration** — Search and download via qBittorrent
- 🧠 **Contextual Memory** — Remembers your preferences and conversation history
- 📁 **Auto-Organization** — Keeps your media library tidy and well-structured
- 🌐 **OpenAI-Compatible API** — Works with any OpenAI-compatible client
- 🖥️ **LibreChat Frontend** — Beautiful web UI included out of the box
- 🔒 **Secure by Default** — Auto-generated secrets and encrypted credentials
## 🏗️ Architecture
Built with **Domain-Driven Design (DDD)** principles for clean separation of concerns:
```
alfred/
├── agent/ # AI agent orchestration
│ ├── llm/ # LLM clients (Ollama, DeepSeek)
│ └── tools/ # Tool implementations
├── application/ # Use cases & DTOs
│ ├── movies/ # Movie search use cases
│ ├── torrents/ # Torrent management
│ └── filesystem/ # File operations
├── domain/ # Business logic & entities
│ ├── movies/ # Movie entities
│ ├── tv_shows/ # TV show entities
│ └── subtitles/ # Subtitle entities
└── infrastructure/ # External services & persistence
├── api/ # External API clients (TMDB, qBittorrent)
├── filesystem/ # File system operations
└── persistence/ # Memory & repositories
```
See [docs/architecture_diagram.md](docs/architecture_diagram.md) for detailed architectural diagrams.
## 🚀 Quick Start
### Prerequisites
- **Python 3.14+** (required)
- **Poetry** (dependency manager)
- **Docker & Docker Compose** (recommended for full stack)
- **API Keys:**
- TMDB API key ([get one here](https://www.themoviedb.org/settings/api))
- Optional: DeepSeek, OpenAI, Anthropic, or other LLM provider keys
### Installation
```bash
# Clone the repository
git clone https://github.com/francwa/alfred_media_organizer.git
cd alfred_media_organizer
# Install dependencies
make install
# Bootstrap environment (generates .env with secure secrets)
make bootstrap
# Edit .env with your API keys
nano .env
```
### Running with Docker (Recommended)
```bash
# Start all services (LibreChat + Alfred + MongoDB + Ollama)
make up
# Or start with specific profiles
make up p=rag,meili # Include RAG and Meilisearch
make up p=qbittorrent # Include qBittorrent
make up p=full # Everything
# View logs
make logs
# Stop all services
make down
```
The web interface will be available at **http://localhost:3080**
### Running Locally (Development)
```bash
# Install dependencies
poetry install
# Start the API server
poetry run uvicorn alfred.app:app --reload --port 8000
```
## ⚙️ Configuration
### Environment Bootstrap
Alfred uses a smart bootstrap system that:
1. **Generates secure secrets** automatically (JWT tokens, database passwords, encryption keys)
2. **Syncs build variables** from `pyproject.toml` (versions, image names)
3. **Preserves existing secrets** when re-running (never overwrites your API keys)
4. **Computes database URIs** automatically from individual components
```bash
# First time setup
make bootstrap
# Re-run after updating pyproject.toml (secrets are preserved)
make bootstrap
```
### Configuration File (.env)
The `.env` file is generated from `.env.example` with secure defaults:
```bash
# --- CORE SETTINGS ---
HOST=0.0.0.0
PORT=3080
MAX_HISTORY_MESSAGES=10
MAX_TOOL_ITERATIONS=10
# --- LLM CONFIGURATION ---
# Providers: 'local' (Ollama), 'deepseek', 'openai', 'anthropic', 'google'
DEFAULT_LLM_PROVIDER=local
# Local LLM (Ollama - included in Docker stack)
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_MODEL=llama3.3:latest
LLM_TEMPERATURE=0.2
# --- API KEYS (fill only what you need) ---
TMDB_API_KEY=your-tmdb-key-here # Required for movie search
DEEPSEEK_API_KEY= # Optional
OPENAI_API_KEY= # Optional
ANTHROPIC_API_KEY= # Optional
# --- SECURITY (auto-generated, don't modify) ---
JWT_SECRET=<auto-generated>
JWT_REFRESH_SECRET=<auto-generated>
CREDS_KEY=<auto-generated>
CREDS_IV=<auto-generated>
# --- DATABASES (auto-generated passwords) ---
MONGO_PASSWORD=<auto-generated>
POSTGRES_PASSWORD=<auto-generated>
```
### Security Keys
Security keys are defined in `pyproject.toml` and generated automatically:
```toml
[tool.alfred.security]
jwt_secret = "32:b64" # 32 bytes, base64 URL-safe
jwt_refresh_secret = "32:b64"
creds_key = "32:hex" # 32 bytes, hexadecimal (AES-256)
creds_iv = "16:hex" # 16 bytes, hexadecimal (AES IV)
mongo_password = "16:hex"
postgres_password = "16:hex"
```
**Formats:**
- `b64` — Base64 URL-safe (for JWT tokens)
- `hex` — Hexadecimal (for encryption keys, passwords)
## 🐳 Docker Services
### Service Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ alfred-net (bridge) │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ LibreChat │───▶│ Alfred │───▶│ MongoDB │ │
│ │ :3080 │ │ (core) │ │ :27017 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌──────────────┐ │
│ │ │ Ollama │ │
│ │ │ (local) │ │
│ │ └──────────────┘ │
│ │ │
│ ┌──────┴───────────────────────────────────────────────┐ │
│ │ Optional Services (profiles) │ │
│ ├──────────────┬──────────────┬──────────────┬─────────┤ │
│ │ Meilisearch │ RAG API │ VectorDB │qBittor- │ │
│ │ :7700 │ :8000 │ :5432 │ rent │ │
│ │ [meili] │ [rag] │ [rag] │[qbit..] │ │
│ └──────────────┴──────────────┴──────────────┴─────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Docker Profiles
| Profile | Services | Use Case |
|---------|----------|----------|
| (default) | LibreChat, Alfred, MongoDB, Ollama | Basic setup |
| `meili` | + Meilisearch | Fast search |
| `rag` | + RAG API, VectorDB | Document retrieval |
| `qbittorrent` | + qBittorrent | Torrent downloads |
| `full` | All services | Complete setup |
```bash
# Start with specific profiles
make up p=rag,meili
make up p=full
```
### Docker Commands
```bash
make up # Start containers (default profile)
make up p=full # Start with all services
make down # Stop all containers
make restart # Restart containers
make logs # Follow logs
make ps # Show container status
make shell # Open bash in Alfred container
make build # Build production image
make build-test # Build test image
```
## 🛠️ Available Tools
The agent has access to these tools for interacting with your media library:
| Tool | Description |
|------|-------------|
| `find_media_imdb_id` | Search for movies/TV shows on TMDB by title |
| `find_torrent` | Search for torrents across multiple indexers |
| `get_torrent_by_index` | Get detailed info about a specific torrent result |
| `add_torrent_by_index` | Download a torrent by its index in search results |
| `add_torrent_to_qbittorrent` | Add a torrent via magnet link directly |
| `set_path_for_folder` | Configure folder paths for media organization |
| `list_folder` | List contents of a folder |
| `set_language` | Set preferred language for searches |
## 💬 Usage Examples
### Via Web Interface (LibreChat)
Navigate to **http://localhost:3080** and start chatting:
```
You: Find Inception in 1080p
Alfred: I found 3 torrents for Inception (2010):
1. Inception.2010.1080p.BluRay.x264 (150 seeders) - 2.1 GB
2. Inception.2010.1080p.WEB-DL.x265 (80 seeders) - 1.8 GB
3. Inception.2010.1080p.REMUX (45 seeders) - 25 GB
You: Download the first one
Alfred: ✓ Added to qBittorrent! Download started.
Saving to: /downloads/Movies/Inception (2010)/
You: What's downloading right now?
Alfred: You have 1 active download:
- Inception.2010.1080p.BluRay.x264 (45% complete, ETA: 12 min)
```
### Via API
```bash
# Health check
curl http://localhost:8000/health
# Chat with the agent (OpenAI-compatible)
curl -X POST http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "alfred",
"messages": [
{"role": "user", "content": "Find The Matrix 4K"}
]
}'
# List available models
curl http://localhost:8000/v1/models
# View memory state (debug)
curl http://localhost:8000/memory/state
# Clear session memory
curl -X POST http://localhost:8000/memory/clear-session
```
### Via OpenWebUI or Other Clients
Alfred is compatible with any OpenAI-compatible client:
1. Add as OpenAI-compatible endpoint: `http://localhost:8000/v1`
2. Model name: `alfred`
3. No API key required (or use any placeholder)
## 🧠 Memory System
Alfred uses a three-tier memory system for context management:
### Long-Term Memory (LTM)
- **Persistent** — Saved to JSON files
- **Contents:** Configuration, user preferences, media library state
- **Survives:** Application restarts
### Short-Term Memory (STM)
- **Session-based** — Stored in RAM
- **Contents:** Conversation history, current workflow state
- **Cleared:** On session end or restart
### Episodic Memory
- **Transient** — Stored in RAM
- **Contents:** Search results, active downloads, recent errors
- **Cleared:** Frequently, after task completion
## 🧪 Development
### Project Setup
```bash
# Install all dependencies (including dev)
poetry install
# Install pre-commit hooks
make install-hooks
# Run the development server
poetry run uvicorn alfred.app:app --reload
```
### Running Tests
```bash
# Run all tests (parallel execution)
make test
# Run with coverage report
make coverage
# Run specific test file
poetry run pytest tests/test_agent.py -v
# Run specific test
poetry run pytest tests/test_config_loader.py::TestBootstrapEnv -v
```
### Code Quality
```bash
# Lint and auto-fix
make lint
# Format code
make format
# Clean build artifacts
make clean
```
### Adding a New Tool
1. **Create the tool function** in `alfred/agent/tools/`:
```python
# alfred/agent/tools/api.py
def my_new_tool(param: str) -> dict[str, Any]:
"""
Short description of what this tool does.
This will be shown to the LLM to help it decide when to use this tool.
"""
memory = get_memory()
# Your implementation here
result = do_something(param)
return {
"status": "success",
"data": result
}
```
2. **Register in the registry** (`alfred/agent/registry.py`):
```python
tool_functions = [
# ... existing tools ...
api_tools.my_new_tool, # Add your tool here
]
```
The tool will be automatically registered with its parameters extracted from the function signature.
### Version Management
```bash
# Bump version (must be on main branch)
make patch # 0.1.7 -> 0.1.8
make minor # 0.1.7 -> 0.2.0
make major # 0.1.7 -> 1.0.0
```
## 📚 API Reference
### Endpoints
#### `GET /health`
Health check endpoint.
```json
{
"status": "healthy",
"version": "0.1.7"
}
```
#### `GET /v1/models`
List available models (OpenAI-compatible).
```json
{
"object": "list",
"data": [
{
"id": "alfred",
"object": "model",
"owned_by": "alfred"
}
]
}
```
#### `POST /v1/chat/completions`
Chat with the agent (OpenAI-compatible).
**Request:**
```json
{
"model": "alfred",
"messages": [
{"role": "user", "content": "Find Inception"}
],
"stream": false
}
```
**Response:**
```json
{
"id": "chatcmpl-xxx",
"object": "chat.completion",
"created": 1234567890,
"model": "alfred",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "I found Inception (2010)..."
},
"finish_reason": "stop"
}]
}
```
#### `GET /memory/state`
View full memory state (debug endpoint).
#### `POST /memory/clear-session`
Clear session memories (STM + Episodic).
## 🔧 Troubleshooting
### Agent doesn't respond
1. Check API keys in `.env`
2. Verify LLM provider is running:
```bash
# For Ollama
docker logs alfred-ollama
# Check if model is pulled
docker exec alfred-ollama ollama list
```
3. Check Alfred logs: `docker logs alfred-core`
### qBittorrent connection failed
1. Verify qBittorrent is running: `docker ps | grep qbittorrent`
2. Check Web UI is enabled in qBittorrent settings
3. Verify credentials in `.env`:
```bash
QBITTORRENT_URL=http://qbittorrent:16140
QBITTORRENT_USERNAME=admin
QBITTORRENT_PASSWORD=<check-your-env>
```
### Database connection issues
1. Check MongoDB is healthy: `docker logs alfred-mongodb`
2. Verify credentials match in `.env`
3. Try restarting: `make restart`
### Memory not persisting
1. Check `data/` directory exists and is writable
2. Verify volume mounts in `docker-compose.yaml`
3. Check file permissions: `ls -la data/`
### Bootstrap fails
1. Ensure `.env.example` exists
2. Check `pyproject.toml` has required sections:
```toml
[tool.alfred.settings]
[tool.alfred.security]
```
3. Run manually: `python scripts/bootstrap.py`
### Tests failing
1. Update dependencies: `poetry install`
2. Check Python version: `python --version` (needs 3.14+)
3. Run specific failing test with verbose output:
```bash
poetry run pytest tests/test_failing.py -v --tb=long
```
## 🤝 Contributing
Contributions are welcome! Please follow these steps:
1. **Fork** the repository
2. **Create** a feature branch: `git checkout -b feature/my-feature`
3. **Make** your changes
4. **Run** tests: `make test`
5. **Run** linting: `make lint && make format`
6. **Commit**: `git commit -m "feat: add my feature"`
7. **Push**: `git push origin feature/my-feature`
8. **Create** a Pull Request
### Commit Convention
We use [Conventional Commits](https://www.conventionalcommits.org/):
- `feat:` New feature
- `fix:` Bug fix
- `docs:` Documentation
- `refactor:` Code refactoring
- `test:` Adding tests
- `chore:` Maintenance
## 📖 Documentation
- [Architecture Diagram](docs/architecture_diagram.md) — System architecture overview
- [Class Diagram](docs/class_diagram.md) — Class structure and relationships
- [Component Diagram](docs/component_diagram.md) — Component interactions
- [Sequence Diagram](docs/sequence_diagram.md) — Sequence flows
- [Flowchart](docs/flowchart.md) — System flowcharts
## 📄 License
MIT License — see [LICENSE](LICENSE) file for details.
## 🙏 Acknowledgments
- [LibreChat](https://github.com/danny-avila/LibreChat) — Beautiful chat interface
- [Ollama](https://ollama.ai/) — Local LLM runtime
- [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
- [Pydantic](https://docs.pydantic.dev/) — Data validation
## 📬 Support
- 📧 Email: francois.hodiaumont@gmail.com
- 🐛 Issues: [GitHub Issues](https://github.com/francwa/alfred_media_organizer/issues)
- 💬 Discussions: [GitHub Discussions](https://github.com/francwa/alfred_media_organizer/discussions)
---
<p align="center">Made with ❤️ by <a href="https://github.com/francwa">Francwa</a></p>

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,78 +0,0 @@
"""Configuration management with validation."""
from dataclasses import dataclass, field
import os
from pathlib import Path
from typing import Optional
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
class ConfigurationError(Exception):
"""Raised when configuration is invalid."""
pass
@dataclass
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")))
# 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"))
# Storage Configuration
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")))
def __post_init__(self):
"""Validate settings after initialization."""
self._validate()
def _validate(self) -> None:
"""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}")
# 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}")
# 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}")
# Validate URLs
if not self.deepseek_base_url.startswith(("http://", "https://")):
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}")
# 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}")
def is_deepseek_configured(self) -> bool:
"""Check if DeepSeek API is properly configured."""
return bool(self.deepseek_api_key and self.deepseek_base_url)
def is_tmdb_configured(self) -> bool:
"""Check if TMDB API is properly configured."""
return bool(self.tmdb_api_key and self.tmdb_base_url)
# Global settings instance
settings = Settings()

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)}"
}

0
alfred/__init__.py Normal file
View File

7
alfred/agent/__init__.py Normal file
View File

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

374
alfred/agent/agent.py Normal file
View File

@@ -0,0 +1,374 @@
"""Main agent for media library management."""
import json
import logging
from collections.abc import AsyncGenerator
from typing import Any
from alfred.infrastructure.persistence import get_memory
from alfred.settings 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, settings, llm, max_tool_iterations: int = 5):
"""
Initialize the agent.
Args:
settings: Application settings instance
llm: LLM client with complete() method
max_tool_iterations: Maximum number of tool execution iterations
"""
self.settings = settings
self.llm = llm
self.tools: dict[str, Tool] = make_tools(settings)
self.prompt_builder = PromptBuilder(self.tools)
self.settings = settings
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.settings.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.settings.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

@@ -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,78 +1,71 @@
"""DeepSeek LLM client with robust error handling."""
from typing import List, Dict, Any, Optional
import logging
import requests
from requests.exceptions import RequestException, Timeout, HTTPError
from ..config import settings
import logging
from typing import Any
import requests
from requests.exceptions import HTTPError, RequestException, Timeout
from alfred.settings import Settings, 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,
settings: Settings | 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
"""
self.api_key = api_key or settings.deepseek_api_key
self.base_url = base_url or settings.deepseek_base_url
self.model = model or settings.model
self.timeout = timeout or settings.request_timeout
self.api_key = api_key or self.settings.deepseek_api_key
self.base_url = base_url or self.settings.deepseek_base_url
self.model = model or self.settings.deepseek_model
self.timeout = timeout or self.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 +73,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}",
@@ -97,39 +96,40 @@ class DeepSeekClient:
payload = {
"model": self.model,
"messages": messages,
"temperature": settings.temperature,
"temperature": settings.llm_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 +140,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

192
alfred/agent/llm/ollama.py Normal file
View File

@@ -0,0 +1,192 @@
"""Ollama LLM client with robust error handling."""
import logging
from typing import Any
import requests
from requests.exceptions import HTTPError, RequestException, Timeout
from alfred.settings 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,
settings: Settings | 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 settings.ollama_base_url
self.model = model or settings.ollama_model
self.timeout = timeout or settings.request_timeout
self.temperature = (
temperature if temperature is not None else settings.llm_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
alfred/agent/prompts.py Normal file
View File

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

115
alfred/agent/registry.py Normal file
View File

@@ -0,0 +1,115 @@
"""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(settings) -> dict[str, Tool]:
"""
Create and register all available tools.
Args:
settings: Application settings instance
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
alfred/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 alfred.application.movies import SearchMovieUseCase
from alfred.application.torrents import AddTorrentUseCase, SearchTorrentsUseCase
from alfred.infrastructure.api.knaben import knaben_client
from alfred.infrastructure.api.qbittorrent import qbittorrent_client
from alfred.infrastructure.api.tmdb import tmdb_client
from alfred.infrastructure.persistence import get_memory
logger = logging.getLogger(__name__)
def 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 alfred.application.filesystem import ListFolderUseCase, SetFolderPathUseCase
from alfred.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 alfred.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)}

260
alfred/app.py Normal file
View File

@@ -0,0 +1,260 @@
"""FastAPI application for the media library agent."""
import json
import logging
import time
import uuid
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field, validator
from alfred.agent.agent import Agent
from alfred.agent.llm.deepseek import DeepSeekClient
from alfred.agent.llm.exceptions import LLMAPIError, LLMConfigurationError
from alfred.agent.llm.ollama import OllamaClient
from alfred.infrastructure.persistence import get_memory, init_memory
from alfred.settings import settings
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",
)
memory_path = Path(settings.data_storage) / "memory"
init_memory(storage_dir=str(memory_path))
logger.info(f"Memory context initialized (path: {memory_path})")
# Initialize LLM based on environment variable
llm_provider = settings.default_llm_provider.lower()
try:
if llm_provider == "local":
logger.info("Using local Ollama LLM")
llm = OllamaClient(settings=settings)
elif llm_provider == "deepseek":
logger.info("Using DeepSeek LLM")
llm = DeepSeekClient()
elif llm_provider == "claude":
raise ValueError(f"LLM provider not fully implemented: {llm_provider}")
else:
raise ValueError(f"Unknown LLM provider: {llm_provider}")
except LLMConfigurationError as e:
logger.error(f"Failed to initialize LLM: {e}")
raise
# Initialize agent
agent = Agent(
settings=settings, 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": f"v{settings.alfred_version}"}
@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 alfred.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 alfred.infrastructure.filesystem import FileManager
from .dto import SetFolderPathResponse
logger = logging.getLogger(__name__)
class SetFolderPathUseCase:
"""
Use case for setting a folder path in configuration.
This orchestrates the FileManager to set folder paths.
"""
def __init__(self, file_manager: FileManager):
"""
Initialize use case.
Args:
file_manager: FileManager instance
"""
self.file_manager = file_manager
def execute(self, folder_name: str, path_value: str) -> SetFolderPathResponse:
"""
Set a folder path in configuration.
Args:
folder_name: Name of folder to set (download, tvshow, movie, torrent)
path_value: Absolute path to the folder
Returns:
SetFolderPathResponse with success or error information
"""
result = self.file_manager.set_folder_path(folder_name, path_value)
if result.get("status") == "ok":
return SetFolderPathResponse(
status="ok",
folder_name=result.get("folder_name"),
path=result.get("path"),
)
else:
return SetFolderPathResponse(
status="error", error=result.get("error"), message=result.get("message")
)

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 alfred.infrastructure.api.tmdb import (
TMDBAPIError,
TMDBClient,
TMDBConfigurationError,
TMDBNotFoundError,
)
from .dto import SearchMovieResponse
logger = logging.getLogger(__name__)
class SearchMovieUseCase:
"""
Use case for searching a movie and retrieving its IMDb ID.
This orchestrates the TMDB API client to find movie information.
"""
def __init__(self, tmdb_client: TMDBClient):
"""
Initialize use case.
Args:
tmdb_client: TMDB API client
"""
self.tmdb_client = tmdb_client
def execute(self, media_title: str) -> SearchMovieResponse:
"""
Search for a movie by title.
Args:
media_title: Title of the movie to search for
Returns:
SearchMovieResponse with movie information or error
"""
try:
# Use the TMDB client to search for media
result = self.tmdb_client.search_media(media_title)
# Check if IMDb ID was found
if result.imdb_id:
logger.info(f"IMDb ID found for '{media_title}': {result.imdb_id}")
return SearchMovieResponse(
status="ok",
imdb_id=result.imdb_id,
title=result.title,
media_type=result.media_type,
tmdb_id=result.tmdb_id,
overview=result.overview,
release_date=result.release_date,
vote_average=result.vote_average,
)
else:
logger.warning(f"No IMDb ID available for '{media_title}'")
return SearchMovieResponse(
status="ok",
title=result.title,
media_type=result.media_type,
tmdb_id=result.tmdb_id,
error="no_imdb_id",
message=f"No IMDb ID available for '{result.title}'",
)
except TMDBNotFoundError as e:
logger.info(f"Media not found: {e}")
return SearchMovieResponse(
status="error", error="not_found", message=str(e)
)
except TMDBConfigurationError as e:
logger.error(f"TMDB configuration error: {e}")
return SearchMovieResponse(
status="error", error="configuration_error", message=str(e)
)
except TMDBAPIError as e:
logger.error(f"TMDB API error: {e}")
return SearchMovieResponse(
status="error", error="api_error", message=str(e)
)
except ValueError as e:
logger.error(f"Validation error: {e}")
return SearchMovieResponse(
status="error", error="validation_failed", message=str(e)
)

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 alfred.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,94 @@
"""Search torrents use case."""
import logging
from alfred.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 @@
"""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 alfred.settings 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 alfred.settings 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 alfred.settings 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 alfred.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 alfred.domain.movies.entities import Movie
from alfred.domain.tv_shows.entities import Episode, Season, TVShow
from alfred.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 alfred.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 alfred.domain.movies.entities import Movie
from alfred.domain.movies.repositories import MovieRepository
from alfred.domain.movies.value_objects import MovieTitle, Quality, ReleaseYear
from alfred.domain.shared.value_objects import FilePath, FileSize, ImdbId
from alfred.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 alfred.domain.shared.value_objects import FilePath, ImdbId
from alfred.domain.subtitles.entities import Subtitle
from alfred.domain.subtitles.repositories import SubtitleRepository
from alfred.domain.subtitles.value_objects import Language, SubtitleFormat, TimingOffset
from alfred.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 alfred.domain.shared.value_objects import ImdbId
from alfred.domain.tv_shows.entities import TVShow
from alfred.domain.tv_shows.repositories import TVShowRepository
from alfred.domain.tv_shows.value_objects import ShowStatus
from alfred.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")

209
alfred/settings.py Normal file
View File

@@ -0,0 +1,209 @@
import secrets
from pathlib import Path
from typing import NamedTuple
import tomllib
from pydantic import Field, computed_field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
BASE_DIR = Path(__file__).resolve().parent.parent
ENV_FILE_PATH = BASE_DIR / ".env"
toml_path = BASE_DIR / "pyproject.toml"
class ConfigurationError(Exception):
"""Raised when configuration is invalid."""
pass
class ProjectVersions(NamedTuple):
"""
Immutable structure for project versions.
Forces explicit naming and prevents accidental swaps.
"""
librechat: str
rag: str
alfred: str
def get_versions_from_toml() -> ProjectVersions:
"""
Reads versioning information from pyproject.toml.
Returns the default value if the file or key is missing.
"""
if not toml_path.exists():
raise FileNotFoundError(f"pyproject.toml not found: {toml_path}")
with open(toml_path, "rb") as f:
data = tomllib.load(f)
try:
return ProjectVersions(
librechat=data["tool"]["alfred"]["settings"]["librechat_version"],
rag=data["tool"]["alfred"]["settings"]["rag_version"],
alfred=data["tool"]["poetry"]["version"],
)
except KeyError as e:
raise KeyError(f"Error: Missing key {e} in pyproject.toml") from e
# Load versions once
VERSIONS: ProjectVersions = get_versions_from_toml()
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=ENV_FILE_PATH,
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
)
# --- GENERAL SETTINGS ---
host: str = "0.0.0.0"
port: int = 3080
debug_logging: bool = False
debug_console: bool = False
data_storage: str = "data"
librechat_version: str = Field(VERSIONS.librechat, description="Librechat version")
rag_version: str = Field(VERSIONS.rag, description="RAG engine version")
alfred_version: str = Field(VERSIONS.alfred, description="Alfred version")
# --- CONTEXT SETTINGS ---
max_history_messages: int = 10
max_tool_iterations: int = 10
request_timeout: int = 30
# TODO: Finish
deepseek_base_url: str = "https://api.deepseek.com"
deepseek_model: str = "deepseek-chat"
# --- API KEYS ---
anthropic_api_key: str | None = Field(None, description="Claude API key")
deepseek_api_key: str | None = Field(None, description="Deepseek API key")
google_api_key: str | None = Field(None, description="Gemini API key")
kimi_api_key: str | None = Field(None, description="Kimi API key")
openai_api_key: str | None = Field(None, description="ChatGPT API key")
# --- SECURITY KEYS ---
# Generated automatically if not in .env to ensure "Secure by Default"
jwt_secret: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
jwt_refresh_secret: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
# We keep these for encryption of keys in MongoDB (AES-256 Hex format)
creds_key: str = Field(default_factory=lambda: secrets.token_hex(32))
creds_iv: str = Field(default_factory=lambda: secrets.token_hex(16))
# --- SERVICES ---
qbittorrent_url: str = "http://qbittorrent:16140"
qbittorrent_username: str = "admin"
qbittorrent_password: str = Field(default_factory=lambda: secrets.token_urlsafe(16))
mongo_host: str = "mongodb"
mongo_user: str = "alfred"
mongo_password: str = Field(
default_factory=lambda: secrets.token_urlsafe(24), repr=False, exclude=True
)
mongo_port: int = 27017
mongo_db_name: str = "alfred"
@computed_field(repr=False)
@property
def mongo_uri(self) -> str:
return (
f"mongodb://{self.mongo_user}:{self.mongo_password}"
f"@{self.mongo_host}:{self.mongo_port}/{self.mongo_db_name}"
f"?authSource=admin"
)
postgres_host: str = "vectordb"
postgres_user: str = "alfred"
postgres_password: str = Field(
default_factory=lambda: secrets.token_urlsafe(24), repr=False, exclude=True
)
postgres_port: int = 5432
postgres_db_name: str = "alfred"
@computed_field(repr=False)
@property
def postgres_uri(self) -> str:
return (
f"postgresql://{self.postgres_user}:{self.postgres_password}"
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db_name}"
)
tmdb_api_key: str | None = Field(None, description="The Movie Database API key")
tmdb_base_url: str = "https://api.themoviedb.org/3"
# --- LLM PICKER & CONFIG ---
# Providers: 'local', 'deepseek', ...
default_llm_provider: str = "local"
ollama_base_url: str = "http://ollama:11434"
# Models: ...
ollama_model: str = "llama3.3:latest"
llm_temperature: float = 0.2
# --- RAG ENGINE ---
rag_enabled: bool = True # TODO: Handle False
rag_api_url: str = "http://rag_api:8000"
embeddings_provider: str = "ollama"
# Models: ...
embeddings_model: str = "nomic-embed-text"
# --- MEILISEARCH ---
meili_enabled: bool = Field(True, description="Enable meili")
meili_no_analytics: bool = True
meili_host: str = "http://meilisearch:7700"
meili_master_key: str = Field(
default_factory=lambda: secrets.token_urlsafe(32),
description="Master key for Meilisearch",
repr=False,
)
# --- VALIDATORS ---
@field_validator("llm_temperature")
@classmethod
def validate_temperature(cls, v: float) -> float:
if not 0.0 <= v <= 2.0:
raise ConfigurationError(
f"Temperature must be between 0.0 and 2.0, got {v}"
)
return v
@field_validator("max_tool_iterations")
@classmethod
def validate_max_iterations(cls, v: int) -> int:
if not 1 <= v <= 20:
raise ConfigurationError(
f"max_tool_iterations must be between 1 and 50, got {v}"
)
return v
@field_validator("request_timeout")
@classmethod
def validate_timeout(cls, v: int) -> int:
if not 1 <= v <= 300:
raise ConfigurationError(
f"request_timeout must be between 1 and 300 seconds, got {v}"
)
return v
@field_validator("deepseek_base_url", "tmdb_base_url")
@classmethod
def validate_url(cls, v: str, info) -> str:
if not v.startswith(("http://", "https://")):
raise ConfigurationError(f"Invalid {info.field_name}")
return v
def is_tmdb_configured(self):
return bool(self.tmdb_api_key)
def is_deepseek_configured(self):
return bool(self.deepseek_api_key)
def dump_safe(self):
return self.model_dump(exclude_none=False)
settings = Settings()

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")

206
docker-compose.yaml Normal file
View File

@@ -0,0 +1,206 @@
services:
# - CORE SERVICES -
# --- .ENV INIT ---
alfred-init:
container_name: alfred-init
build:
context: .
target: builder
args:
PYTHON_VERSION: ${PYTHON_VERSION}
PYTHON_VERSION_SHORT: ${PYTHON_VERSION_SHORT}
RUNNER: ${RUNNER}
command: python scripts/bootstrap.py
networks:
- alfred-net
# --- MAIN APPLICATION ---
alfred:
container_name: alfred-core
build:
context: .
args:
PYTHON_VERSION: ${PYTHON_VERSION}
PYTHON_VERSION_SHORT: ${PYTHON_VERSION_SHORT}
RUNNER: ${RUNNER}
depends_on:
alfred-init:
condition: service_completed_successfully
restart: unless-stopped
env_file:
- path: .env
required: true
volumes:
- ./data:/data
- ./logs:/logs
# TODO: Hot reload (comment out in production)
#- ./alfred:/home/appuser/alfred
networks:
- alfred-net
# --- FRONTEND LIBRECHAT ---
librechat:
container_name: alfred-librechat
image: ghcr.io/danny-avila/librechat:${LIBRECHAT_VERSION}
depends_on:
alfred-init:
condition: service_completed_successfully
mongodb:
condition: service_healthy
restart: unless-stopped
env_file:
- path: .env
required: true
environment:
# Remap value name
- SEARCH=${MEILI_ENABLED}
ports:
- "${PORT}:${PORT}"
volumes:
- ./data/librechat/images:/app/client/public/images
- ./data/librechat/uploads:/app/client/uploads
- ./logs:/app/api/logs
# Mount custom endpoint
- ./librechat/manifests:/app/manifests:ro
- ./librechat/librechat.yaml:/app/librechat.yaml:ro
networks:
- alfred-net
# --- DATABASE #1 - APP STATE ---
mongodb:
container_name: alfred-mongodb
image: mongo:latest
restart: unless-stopped
depends_on:
alfred-init:
condition: service_completed_successfully
env_file:
- path: .env
required: true
environment:
# Remap value name
- MONGO_INITDB_ROOT_USERNAME=${MONGO_USER}
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASSWORD}
ports:
- "${MONGO_PORT}:${MONGO_PORT}"
volumes:
- ./data/mongo:/data/db
command: mongod --quiet --setParameter logComponentVerbosity='{"network":{"verbosity":0}}'
healthcheck:
test: |
mongosh --quiet --eval "db.adminCommand('ping')" || \
mongosh --quiet -u "${MONGO_USER}" -p "${MONGO_PASSWORD}" --authenticationDatabase admin --eval "db.adminCommand('ping')"
interval: 10s
timeout: 5s
retries: 5
networks:
- alfred-net
# --- OLLAMA - LOCAL LLM ENGINE ---
ollama:
image: ollama/ollama:latest
container_name: alfred-ollama
depends_on:
alfred-init:
condition: service_completed_successfully
restart: unless-stopped
env_file:
- path: .env
required: true
volumes:
- ./data/ollama:/root/.ollama
networks:
- alfred-net
# - OPTIONAL SERVICES -
# --- SEARCH ENGINE SUPER FAST (Optional) ---
meilisearch:
container_name: alfred-meilisearch
image: getmeili/meilisearch:v1.12.3
depends_on:
alfred-init:
condition: service_completed_successfully
restart: unless-stopped
env_file:
- path: .env
required: true
volumes:
- ./data/meilisearch:/meili_data
profiles: ["meili", "full"]
networks:
- alfred-net
# --- RETRIEVAL AUGMENTED GENERATION SYSTEM (Optional) ---
rag_api:
container_name: alfred-rag
image: ghcr.io/danny-avila/librechat-rag-api-dev-lite:${RAG_VERSION}
depends_on:
alfred-init:
condition: service_completed_successfully
vectordb:
condition: service_healthy
restart: unless-stopped
env_file:
- path: .env
required: true
ports:
- "${RAG_API_PORT}:${RAG_API_PORT}"
volumes:
- ./data/rag/uploads:/app/uploads
profiles: ["rag", "full"]
networks:
- alfred-net
# --- DATABASE #2 - Vector RAG (Optional) ---
vectordb:
container_name: alfred-vectordb
image: pgvector/pgvector:0.8.0-pg16-bookworm
depends_on:
alfred-init:
condition: service_completed_successfully
restart: unless-stopped
env_file:
- path: .env
required: true
ports:
- "${POSTGRES_PORT}:${POSTGRES_PORT}"
volumes:
- ./data/vectordb:/var/lib/postgresql/data
profiles: ["rag", "full"]
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-alfred} -d ${POSTGRES_DB_NAME:-alfred}" ]
interval: 5s
timeout: 5s
retries: 5
networks:
- alfred-net
# --- QBITTORENT (Optional) ---
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: alfred-qbittorrent
depends_on:
alfred-init:
condition: service_completed_successfully
restart: unless-stopped
env_file:
- path: .env
required: true
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Paris
- WEBUI_PORT=${QBITTORRENT_PORT}
volumes:
- ./data/qbittorrent/config:/config
- ./data/qbittorrent/downloads:/downloads
profiles: ["qbittorrent", "full"]
ports:
- "${QBITTORRENT_PORT}:${QBITTORRENT_PORT}"
networks:
- alfred-net
networks:
alfred-net:
name: alfred-internal
driver: bridge

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 |

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