Compare commits

..

6 Commits

432 changed files with 2808 additions and 42499 deletions

3
.gitignore vendored
View File

@@ -1,5 +1,2 @@
transcode/ transcode/
.worktrees/ .worktrees/
.superpowers/
/target

View File

@@ -1,13 +0,0 @@
## Agent skills
### Issue tracker
Issues tracked on Gitea at git.gabrielkaszewski.dev (GKaszewski/k-tv). See `docs/agents/issue-tracker.md`.
### Triage labels
Default label vocabulary. See `docs/agents/triage-labels.md`.
### Domain docs
Multi-context (frontend + backend). See `docs/agents/domain.md`.

View File

@@ -1,6 +0,0 @@
# K-TV Context Map
Two bounded contexts:
- **Backend** — `crates/CONTEXT.md` — domain model, scheduling engine, library, playout, streaming
- **Frontend** — `k-tv-frontend/CONTEXT.md` — EPG UI, channel viewer, dashboard

3548
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +0,0 @@
[workspace]
members = ["crates/domain", "crates/application", "crates/api-types", "crates/infra-wiring", "crates/adapters/adapter-common", "crates/adapters/sqlite", "crates/adapters/auth", "crates/adapters/jellyfin", "crates/adapters/local-files", "crates/adapters/event-publisher", "crates/presentation", "crates/worker", "crates/mcp", "crates/playout"]
exclude = ["k-tv-backend", "k-tv-frontend"]
resolver = "2"
[workspace.dependencies]
async-trait = "0.1"
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = { version = "0.10", features = ["serde"] }
email_address = "0.2"
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
url = { version = "2.5", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "macros", "chrono", "uuid"] }
axum = { version = "0.8" }
axum-extra = { version = "0.10" }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
reqwest = { version = "0.12", features = ["json"] }
utoipa = { version = "5", features = ["chrono", "uuid"] }
utoipa-scalar = { version = "0.3", features = ["axum"] }
jsonwebtoken = "9"
# Internal crates
domain = { path = "crates/domain" }
application = { path = "crates/application" }
api-types = { path = "crates/api-types" }
infra-wiring = { path = "crates/infra-wiring" }
adapter-common = { path = "crates/adapters/adapter-common" }
adapter-sqlite = { path = "crates/adapters/sqlite" }
adapter-auth = { path = "crates/adapters/auth" }
adapter-jellyfin = { path = "crates/adapters/jellyfin" }
adapter-local-files = { path = "crates/adapters/local-files" }
adapter-event-publisher = { path = "crates/adapters/event-publisher" }

View File

@@ -1,44 +0,0 @@
.DEFAULT_GOAL := check
# Run the full local check suite — same order as CI would.
check: fmt-check clippy test
@echo "✅ All checks passed"
# Apply rustfmt to all files.
fmt:
cargo fmt
# Check formatting without modifying files (CI-safe).
fmt-check:
cargo fmt --check
# Run Clippy and treat warnings as errors.
clippy:
cargo clippy -- -D warnings
# Run the test suite.
test:
cargo test
# Apply fmt + clippy auto-fixes in one shot.
fix:
cargo fmt
cargo clippy --fix --allow-dirty --allow-staged
# Build the frontend SPA.
build-app:
cd app && npm run build
# Run the backend (builds frontend first if needed).
dev: build-app
JWT_SECRET=dev-secret ALLOW_REGISTRATION=true cargo run -p presentation
# Run backend only (skip frontend build, assumes build-app was run).
dev-api:
JWT_SECRET=dev-secret ALLOW_REGISTRATION=true cargo run -p presentation
# Build and push Docker image to private registry.
deploy:
./deploy.sh
.PHONY: check fmt fmt-check clippy test fix build-app dev dev-api deploy

View File

@@ -70,5 +70,4 @@ docker compose -f compose.yml -f compose.traefik.yml up -d
| `NEXT_PUBLIC_API_URL` | frontend build arg | Baked in at build time — must point to the public backend URL | | `NEXT_PUBLIC_API_URL` | frontend build arg | Baked in at build time — must point to the public backend URL |
| `API_URL` | frontend runtime env | Server-side only (Next.js API routes). Set in compose. | | `API_URL` | frontend runtime env | Server-side only (Next.js API routes). Set in compose. |
| `DATABASE_URL` | backend | `sqlite:///app/data/k-tv.db` or postgres DSN | | `DATABASE_URL` | backend | `sqlite:///app/data/k-tv.db` or postgres DSN |
| `JWT_SECRET` | backend | JWT signing key — change in production (min 32 chars) | | `SESSION_SECRET` | backend | Change in production |
| `COOKIE_SECRET` | backend | OIDC state cookie encryption key — change in production (min 64 chars) |

View File

@@ -11,38 +11,24 @@
# TRAEFIK_CERT_RESOLVER cert resolver name for TLS (default: letsencrypt) # TRAEFIK_CERT_RESOLVER cert resolver name for TLS (default: letsencrypt)
# FRONTEND_HOST public hostname for the frontend e.g. tv.example.com # FRONTEND_HOST public hostname for the frontend e.g. tv.example.com
# BACKEND_HOST public hostname for the backend API e.g. tv-api.example.com # BACKEND_HOST public hostname for the backend API e.g. tv-api.example.com
# PLAYOUT_HOST public hostname for playout streams e.g. tv-playout.example.com
# #
# Remember: NEXT_PUBLIC_API_URL in .env must be the *public* backend URL, # Remember: NEXT_PUBLIC_API_URL in .env must be the *public* backend URL,
# e.g. https://tv-api.example.com/api/v1, and you must rebuild after changing it. # e.g. https://tv-api.example.com/api/v1, and you must rebuild after changing it.
services: services:
presentation: backend:
ports: [] ports: [] # Traefik handles ingress; no direct port exposure needed
networks: networks:
- default - default
- traefik - traefik
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=${TRAEFIK_NETWORK:-traefik_proxy}" - "traefik.docker.network=${TRAEFIK_NETWORK:-traefik_proxy}"
- "traefik.http.routers.ktv-presentation.rule=Host(`${BACKEND_HOST}`)" - "traefik.http.routers.ktv-backend.rule=Host(`${BACKEND_HOST}`)"
- "traefik.http.routers.ktv-presentation.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" - "traefik.http.routers.ktv-backend.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
- "traefik.http.routers.ktv-presentation.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}" - "traefik.http.routers.ktv-backend.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}"
- "traefik.http.services.ktv-presentation.loadbalancer.server.port=3000" - "traefik.http.services.ktv-backend.loadbalancer.server.port=3000"
playout:
ports: []
networks:
- default
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=${TRAEFIK_NETWORK:-traefik_proxy}"
- "traefik.http.routers.ktv-playout.rule=Host(`${PLAYOUT_HOST}`)"
- "traefik.http.routers.ktv-playout.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
- "traefik.http.routers.ktv-playout.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}"
- "traefik.http.services.ktv-playout.loadbalancer.server.port=9090"
frontend: frontend:
ports: [] ports: []

View File

@@ -1,19 +1,19 @@
services: services:
# ── Presentation (Rust / Axum — HTTP API) ──────────────────────────────── # ── Backend (Rust / Axum) ──────────────────────────────────────────────────
presentation: backend:
build: build: ./k-tv-backend
context: ./k-tv-backend
target: presentation
image: registry.gabrielkaszewski.dev/k-tv-presentation:latest
ports: ports:
- "${BACKEND_PORT:-3000}:3000" - "${BACKEND_PORT:-3000}:3000"
environment: environment:
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=3000 - PORT=3000
- DATABASE_URL=sqlite:///app/data/k-tv.db?mode=rwc - DATABASE_URL=sqlite:///app/data/k-tv.db?mode=rwc
# Allow requests from the browser (the user-facing frontend URL)
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS} - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS}
# Auth — generate with: openssl rand -hex 32
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
# Cookie secret — generate with: openssl rand -base64 64
- COOKIE_SECRET=${COOKIE_SECRET} - COOKIE_SECRET=${COOKIE_SECRET}
- JWT_EXPIRY_HOURS=${JWT_EXPIRY_HOURS:-24} - JWT_EXPIRY_HOURS=${JWT_EXPIRY_HOURS:-24}
- SECURE_COOKIE=${SECURE_COOKIE:-false} - SECURE_COOKIE=${SECURE_COOKIE:-false}
@@ -21,6 +21,7 @@ services:
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true} - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true}
- DB_MAX_CONNECTIONS=${DB_MAX_CONNECTIONS:-5} - DB_MAX_CONNECTIONS=${DB_MAX_CONNECTIONS:-5}
- DB_MIN_CONNECTIONS=${DB_MIN_CONNECTIONS:-1} - DB_MIN_CONNECTIONS=${DB_MIN_CONNECTIONS:-1}
# Jellyfin — all three required for schedule generation
- JELLYFIN_BASE_URL=${JELLYFIN_BASE_URL} - JELLYFIN_BASE_URL=${JELLYFIN_BASE_URL}
- JELLYFIN_API_KEY=${JELLYFIN_API_KEY} - JELLYFIN_API_KEY=${JELLYFIN_API_KEY}
- JELLYFIN_USER_ID=${JELLYFIN_USER_ID} - JELLYFIN_USER_ID=${JELLYFIN_USER_ID}
@@ -33,60 +34,40 @@ services:
timeout: 5s timeout: 5s
retries: 3 retries: 3
# ── Worker (background jobs) ─────────────────────────────────────────────
worker:
build:
context: ./k-tv-backend
target: worker
image: registry.gabrielkaszewski.dev/k-tv-worker:latest
environment:
- DATABASE_URL=sqlite:///app/data/k-tv.db?mode=rwc
- JELLYFIN_BASE_URL=${JELLYFIN_BASE_URL}
- JELLYFIN_API_KEY=${JELLYFIN_API_KEY}
- JELLYFIN_USER_ID=${JELLYFIN_USER_ID}
volumes:
- backend_data:/app/data
depends_on:
presentation:
condition: service_healthy
restart: unless-stopped
# ── Playout (HLS streaming) ──────────────────────────────────────────────
playout:
build:
context: ./k-tv-backend
target: playout
image: registry.gabrielkaszewski.dev/k-tv-playout:latest
ports:
- "${PLAYOUT_PORT:-9090}:9090"
environment:
- DATABASE_URL=sqlite:///app/data/k-tv.db?mode=rwc
- PLAYOUT_LISTEN_ADDR=0.0.0.0:9090
- PLAYOUT_STORAGE_PATH=/tmp/k-tv-playout
- PLAYOUT_SEGMENT_DURATION=${PLAYOUT_SEGMENT_DURATION:-6}
volumes:
- backend_data:/app/data
depends_on:
presentation:
condition: service_healthy
restart: unless-stopped
# ── Frontend (Next.js) ──────────────────────────────────────────────────── # ── Frontend (Next.js) ────────────────────────────────────────────────────
frontend: frontend:
build: build:
context: ./k-tv-frontend context: ./k-tv-frontend
args: args:
# Browser-visible backend URL — baked into the client bundle at build time.
# Rebuild the image after changing this.
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:4000/api/v1} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:4000/api/v1}
NEXT_PUBLIC_PLAYOUT_URL: ${NEXT_PUBLIC_PLAYOUT_URL:-http://localhost:9090}
image: registry.gabrielkaszewski.dev/k-tv-frontend:latest
ports: ports:
- "${FRONTEND_PORT:-3001}:3001" - "${FRONTEND_PORT:-3001}:3001"
environment: environment:
API_URL: http://presentation:3000/api/v1 # Server-side API URL — uses Docker's internal network, never exposed.
# Next.js API routes (e.g. /api/stream/[channelId]) use this.
API_URL: http://backend:3000/api/v1
depends_on: depends_on:
presentation: backend:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
volumes: volumes:
backend_data: backend_data:
# ── Optional: PostgreSQL ───────────────────────────────────────────────────
# Uncomment the db service and set DATABASE_URL in backend's environment:
# DATABASE_URL: postgres://ktv:${POSTGRES_PASSWORD}@db:5432/ktv
#
# db:
# image: postgres:16-alpine
# environment:
# POSTGRES_USER: ktv
# POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
# POSTGRES_DB: ktv
# volumes:
# - db_data:/var/lib/postgresql/data
# restart: unless-stopped
#
# db_data:

View File

@@ -1,133 +0,0 @@
# K-TV Backend — Domain Glossary
## MCP (Model Context Protocol)
Exposes the system to AI agents as a creative partner, not a CRUD proxy. An agent acts as the TV network's programming director — it analyzes the library, designs thematic channels, builds schedules, adjusts rotation policies, reviews what's been airing, and diversifies programming. MCP tools must support library exploration, schedule analysis, and EPG review alongside channel/schedule management.
## Industry Formats
No invented standards — use what exists:
- **M3U** — channel discovery for IPTV clients (playlist of available channels)
- **XMLTV** — concrete EPG export (what's playing when)
- **HLS (RFC 8216)** — video streaming from the Playout Service
- **SCTE-35** — mid-roll break signaling in HLS streams
- **iCalendar (RFC 5545)** — schedule template import/export. A ProgrammingBlock maps to a VEVENT with RRULE for recurrence. K-tv-specific properties (filter, strategy, interstitial rules) use X-KTV-* custom properties. Shareable, viewable in any calendar app.
## Deployment
Three binaries: presentation (HTTP API), worker (background jobs), playout (streaming). Default deployment is single-machine Docker Compose with SQLite (WAL mode). All three share the same SQLite file. Can be distributed across machines by swapping in Postgres via adapter. Images pushed to a private Docker registry, deployed via Ansible.
## General Principles
All runtime configuration is via environment variables — no recompilation to change behavior. Ports abstract infrastructure choices so implementations can be swapped in wiring without touching domain or application code.
## Operator
The system administrator who designs channels, manages the library, configures providers, and controls the system. The first registered user is automatically promoted to Operator. Maps to `is_admin` in the codebase.
## Viewer
A user who watches channels but cannot manage anything. May self-register if the Operator enables open registration. Needed for accessing Private channels — Public channels require no account.
## Channel
A virtual TV station that a viewer tunes to. Owned by a single User. A Channel owns its ScheduleConfig directly (1:1) — schedule templates are not shared across channels.
## Gap / No-Signal
Time between ProgrammingBlocks with no scheduled content. Behavior is configurable per Channel via a gap filler setting: off (no signal — the Playout Service serves nothing), or a MediaFilter that selects interstitial content to play during unscheduled time (e.g., a test-pattern clip on loop, random filler from an interstitials pool). Off by default.
## GeneratedSchedule
A concrete, time-bound schedule produced from a ScheduleConfig. Contains ScheduledSlots with specific start/end times. Uses a rolling window (default 7 days) that self-heals — the auto-scheduler ensures at least N days of schedule always exist ahead of the current time, regenerating before expiry. The generation counter (monotonically increasing per Channel) drives the RotationPolicy.
## ScheduleConfig
The programming template for a Channel. Maps each Weekday to a list of ProgrammingBlocks. This is the "what should play when" design — it is not the concrete schedule itself.
## MediaItem
A playable piece of media (movie, episode, short) in the system's library. Provider-agnostic — the library is the single source of truth. Providers sync items into the library; the schedule engine queries the library, never a provider directly. Stream URLs are the one exception — resolved from the originating provider at playback time.
Not to be confused with a "file" or "video" — a MediaItem is metadata about something playable, not the content itself.
## Library
The canonical inventory of all media available to the system. Populated by syncing from one or more Providers. All scheduling, browsing, and filtering operates against the library.
## Provider
An external media source (Jellyfin, Plex, Emby, local files, YouTube, etc.). A Provider has two jobs: sync items into the Library, and expose a source URI that the Playout Service can read from. The domain is completely blind to which provider an item came from — providers are an infrastructure concern.
## Shared Broadcast
All viewers tuning to the same Channel at the same moment see the same content at the same offset. There is no pause, rewind, or fast-forward — like old-school cable TV. A viewer joining mid-movie starts at whatever point the broadcast has reached. This means the Playout Service produces one HLS stream per Channel, shared by all viewers.
## Event Queue
The inter-process communication mechanism. Events published by any binary are persisted to a database-backed queue and consumed by other binaries via polling. Failed events are moved to a Dead-Letter Queue (DLQ) after exhausting retries. Abstracted behind EventPublisher/EventConsumer ports — the implementation can be swapped from database-backed to NATS JetStream without changing domain or application code.
## Dead-Letter Queue (DLQ)
Where events go after failing to process beyond the retry limit. Prevents poison messages from blocking the main queue. Must be inspectable for debugging.
## Worker
A separate binary responsible for all background processing: library sync from providers, auto-schedule regeneration, and event-driven jobs (webhooks, etc.). Communicates with the rest of the system via the event bus and shared database. Runs independently from the presentation and playout binaries so CPU-heavy work doesn't compete with request handling or stream serving.
## Playout Service
A separate binary that owns the video stream. Takes a source URI from a Provider, reads it via FFmpeg (network URL or local path — no full download), and produces an HLS stream with proper segmentation, all audio/subtitle tracks, SCTE-35 markers for mid-roll breaks, and timed metadata for overlays. Every viewer gets a stream from the Playout Service, never directly from a Provider. Uses a sliding window for segment retention — only segments near the current playback position are kept, older segments are deleted. Window size is configurable via environment variables.
## Segment Store
A port abstracting where HLS segments are persisted. Implementations may target local filesystem, a NAS mount, tmpfs/ramdisk, or any other storage backend. The Playout Service writes and cleans up segments through this port, never directly to a path. All storage configuration is via environment variables — no recompilation needed to change storage strategy or tune parameters like max disk usage.
## ContentType
What a MediaItem is: Movie, Episode, or Short. Describes the media itself, not how it's used.
## MediaRole
How the scheduling engine uses a MediaItem: Program (default — fills ProgrammingBlocks) or Interstitial (inserted between programs as bumpers, ads, station IDs). A 15-second station ID and a 15-minute short film are both ContentType::Short, but one is MediaRole::Interstitial and the other is MediaRole::Program.
## Interstitial
A short-form MediaItem (bumper, ad, station ID, promo) inserted between regular program items in the schedule timeline. Not a separate asset type — it's a regular MediaItem classified by role. Sourced from the same Library as programs.
## Mid-Roll Break
A point where a long program (e.g., a movie) is split and interstitial content is inserted in the middle, mimicking commercial breaks. Configured per ProgrammingBlock. Break points prefer chapter markers from the media file's metadata when available, with a fixed-interval fallback (every N minutes). Signaled via SCTE-35 markers in the HLS stream for IPTV client compatibility.
## Chapter
A named marker within a media file indicating a content boundary (e.g., scene breaks in a movie). Extracted during library sync and stored on the MediaItem. Used by mid-roll break logic to find natural break points. Not all media files have chapters — the system falls back to fixed-interval breaks when they're absent.
## Overlay
Metadata-driven visual content rendered on top of the current video stream (e.g., "Coming up next" banners). Not composited server-side — delivered as timed metadata that capable clients render. IPTV clients that don't support it simply ignore the metadata. Not a ScheduledSlot — a presentation-layer concern.
## AccessMode
Controls Channel visibility: Public (visible to anyone, no auth) or Private (requires authenticated user). Channel-level only — no block-level access control. For content separation (e.g., adult content), use user-level permissions, not channel passwords.
## ProgrammingBlock
A named time window within a single day of a ScheduleConfig (e.g., "Morning Cartoons 06:0008:00"). Defines when content plays and how it is sourced (Manual or Algorithmic).
## FillStrategy
The algorithm used to select and order MediaItems when filling a ProgrammingBlock. Six strategies:
- **Sequential** — one series in episode order, resumes across schedule generations (daily strip)
- **Random** — shuffle from filtered pool (variety block)
- **BestFit** — greedy bin-packing, picks longest item that fits remaining time (precise time-slot filling)
- **Alternating** — cycles through N series or pools (Mon=Show A, Tue=Show B, repeat)
- **Weighted** — random selection biased by recency or play count (fresh content surfaces more)
- **Marathon** — one series, sequential, fills the entire block from a starting point (weekend binge)
Marathon differs from Sequential: Sequential resumes where it left off across generations, Marathon intentionally burns through as many episodes as possible in a single block.
## RotationPolicy
Controls how frequently items repeat in a Channel's schedule. Prevents the same movie from airing twice in a week on a small library. Configured per Channel. Fields: cooldown_days (don't replay within N days), cooldown_generations (don't replay within N schedule generations), min_available_ratio (safety valve — if filtering would leave fewer than this fraction of items, ignore cooldown to prevent dead air). Formerly called RotationPolicy in the codebase — "rotation" matches broadcast TV terminology.

View File

@@ -1,14 +0,0 @@
[package]
name = "adapter-common"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
sqlx = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
tokio = { workspace = true }

View File

@@ -1,180 +0,0 @@
use domain::{Chapter, ContentType, SourceUri};
use serde::Deserialize;
const CHAPTER_PROBE_MIN_DURATION_SECS: u32 = 2700;
#[derive(Deserialize)]
struct FfprobeOutput {
#[serde(default)]
chapters: Vec<FfprobeChapter>,
}
#[derive(Deserialize)]
struct FfprobeChapter {
#[serde(default)]
start_time: String,
#[serde(default)]
end_time: String,
#[serde(default)]
tags: Option<FfprobeChapterTags>,
}
#[derive(Deserialize)]
struct FfprobeChapterTags {
title: Option<String>,
}
pub fn parse_chapters_json(json: &str) -> Vec<Chapter> {
let output: FfprobeOutput = match serde_json::from_str(json) {
Ok(o) => o,
Err(_) => return Vec::new(),
};
output
.chapters
.into_iter()
.map(|c| {
let title = c.tags.and_then(|t| t.title);
let start_secs = c.start_time.parse::<f64>().unwrap_or(0.0);
let end_secs = c.end_time.parse::<f64>().unwrap_or(0.0);
Chapter::new(title, start_secs, end_secs)
})
.collect()
}
pub fn should_probe_chapters(content_type: &ContentType, duration_secs: u32) -> bool {
matches!(content_type, ContentType::Movie) || duration_secs > CHAPTER_PROBE_MIN_DURATION_SECS
}
pub async fn extract_chapters(source_uri: &SourceUri) -> Vec<Chapter> {
let path = match source_uri {
SourceUri::FilePath { path } => path.clone(),
SourceUri::NetworkUrl { url } => url.clone(),
};
let result = tokio::process::Command::new("ffprobe")
.args([
"-v",
"quiet",
"-print_format",
"json",
"-show_chapters",
&path,
])
.output()
.await;
match result {
Ok(output) if output.status.success() => {
let json = String::from_utf8_lossy(&output.stdout);
parse_chapters_json(&json)
}
Ok(output) => {
tracing::warn!(
path = %path,
stderr = %String::from_utf8_lossy(&output.stderr),
"ffprobe exited with non-zero status"
);
Vec::new()
}
Err(e) => {
tracing::warn!(error = %e, "ffprobe not available or failed to execute");
Vec::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_ffprobe_json_with_chapters() {
let json = r#"{
"chapters": [
{
"id": 0,
"time_base": "1/1000",
"start": 0,
"start_time": "0.000000",
"end": 300000,
"end_time": "300.000000",
"tags": { "title": "Opening" }
},
{
"id": 1,
"time_base": "1/1000",
"start": 300000,
"start_time": "300.000000",
"end": 1800000,
"end_time": "1800.000000",
"tags": { "title": "Main Feature" }
},
{
"id": 2,
"time_base": "1/1000",
"start": 1800000,
"start_time": "1800.000000",
"end": 2100000,
"end_time": "2100.000000"
}
]
}"#;
let chapters = parse_chapters_json(json);
assert_eq!(chapters.len(), 3);
assert_eq!(chapters[0].title(), Some("Opening"));
assert!((chapters[0].start_secs() - 0.0).abs() < f64::EPSILON);
assert!((chapters[0].end_secs() - 300.0).abs() < f64::EPSILON);
assert_eq!(chapters[1].title(), Some("Main Feature"));
assert!((chapters[1].start_secs() - 300.0).abs() < f64::EPSILON);
assert!((chapters[1].end_secs() - 1800.0).abs() < f64::EPSILON);
assert_eq!(chapters[2].title(), None);
assert!((chapters[2].start_secs() - 1800.0).abs() < f64::EPSILON);
assert!((chapters[2].end_secs() - 2100.0).abs() < f64::EPSILON);
}
#[test]
fn parse_ffprobe_json_no_chapters() {
let json = r#"{ "chapters": [] }"#;
let chapters = parse_chapters_json(json);
assert!(chapters.is_empty());
}
#[test]
fn parse_ffprobe_json_missing_chapters_key() {
let json = r#"{}"#;
let chapters = parse_chapters_json(json);
assert!(chapters.is_empty());
}
#[test]
fn parse_ffprobe_json_invalid() {
let chapters = parse_chapters_json("not json");
assert!(chapters.is_empty());
}
#[test]
fn short_items_skip_probe() {
assert!(!should_probe_chapters(&ContentType::Episode, 1800));
assert!(!should_probe_chapters(&ContentType::Short, 300));
}
#[test]
fn movie_always_probed() {
assert!(should_probe_chapters(&ContentType::Movie, 600));
assert!(should_probe_chapters(&ContentType::Movie, 7200));
}
#[test]
fn long_episode_probed() {
assert!(should_probe_chapters(&ContentType::Episode, 3600));
}
#[test]
fn episode_under_threshold_skipped() {
assert!(!should_probe_chapters(&ContentType::Episode, 2700));
}
}

View File

@@ -1,206 +0,0 @@
pub mod ffprobe;
pub mod role_detector;
use chrono::{DateTime, Utc};
use domain::{DomainError, RotationPolicy, ScheduleConfig, ScheduleConfigCompat};
use serde::de::DeserializeOwned;
use uuid::Uuid;
pub fn map_sqlx_error(err: sqlx::Error) -> DomainError {
tracing::error!(error = %err, "database error");
DomainError::RepositoryError(err.to_string())
}
pub fn parse_dt(s: &str) -> Result<DateTime<Utc>, DomainError> {
DateTime::parse_from_rfc3339(s)
.map(|dt| dt.with_timezone(&Utc))
.or_else(|_| {
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").map(|dt| dt.and_utc())
})
.map_err(|e| DomainError::RepositoryError(format!("Invalid datetime '{}': {}", s, e)))
}
pub fn parse_uuid(s: &str, context: &str) -> Result<Uuid, DomainError> {
Uuid::parse_str(s)
.map_err(|e| DomainError::RepositoryError(format!("Invalid {} UUID '{}': {}", context, s, e)))
}
pub fn parse_json<T: DeserializeOwned>(json: &str, context: &str) -> Result<T, DomainError> {
serde_json::from_str(json)
.map_err(|e| DomainError::RepositoryError(format!("Invalid {} JSON: {}", context, e)))
}
pub fn parse_schedule_config(json: &str) -> Result<ScheduleConfig, DomainError> {
let compat: ScheduleConfigCompat = parse_json(json, "schedule_config")?;
Ok(ScheduleConfig::from(compat))
}
pub fn parse_rotation_policy(json: &str) -> Result<RotationPolicy, DomainError> {
parse_json(json, "rotation_policy")
}
pub fn parse_enum_or_default<T: DeserializeOwned + Default>(value: String) -> T {
serde_json::from_value(serde_json::Value::String(value)).unwrap_or_default()
}
pub fn serialize_enum_as_string<T: serde::Serialize>(v: &T, fallback: &str) -> String {
serde_json::to_value(v)
.ok()
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_else(|| fallback.to_owned())
}
pub fn content_type_str(ct: &domain::ContentType) -> &'static str {
match ct {
domain::ContentType::Movie => "movie",
domain::ContentType::Episode => "episode",
domain::ContentType::Short => "short",
}
}
pub fn parse_content_type(s: &str) -> domain::ContentType {
match s {
"episode" => domain::ContentType::Episode,
"short" => domain::ContentType::Short,
_ => domain::ContentType::Movie,
}
}
pub fn parse_genres_blob(blob: &str) -> Vec<String> {
use std::collections::HashSet;
blob.split("],[")
.flat_map(|chunk| {
let cleaned = chunk.trim_start_matches('[').trim_end_matches(']');
cleaned
.split(',')
.filter_map(|s| {
let s = s.trim().trim_matches('"');
if s.is_empty() {
None
} else {
Some(s.to_string())
}
})
.collect::<Vec<_>>()
})
.collect::<HashSet<_>>()
.into_iter()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Datelike;
#[test]
fn parse_dt_rfc3339() {
let dt = parse_dt("2026-03-19T12:30:00Z").unwrap();
assert_eq!(dt.year(), 2026);
assert_eq!(dt.month(), 3);
assert_eq!(dt.day(), 19);
}
#[test]
fn parse_dt_sqlite_format() {
let dt = parse_dt("2026-03-19 12:30:00").unwrap();
assert_eq!(dt.year(), 2026);
}
#[test]
fn parse_dt_invalid() {
assert!(parse_dt("not-a-date").is_err());
}
#[test]
fn parse_uuid_valid() {
let u = Uuid::new_v4();
let parsed = parse_uuid(&u.to_string(), "test").unwrap();
assert_eq!(parsed, u);
}
#[test]
fn parse_uuid_invalid() {
let err = parse_uuid("not-a-uuid", "channel id").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("channel id"), "error should contain context: {msg}");
}
#[test]
fn parse_json_valid() {
let val: Vec<i32> = parse_json("[1,2,3]", "test").unwrap();
assert_eq!(val, vec![1, 2, 3]);
}
#[test]
fn parse_json_invalid() {
let err = parse_json::<Vec<i32>>("not json", "test_field").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("test_field"), "error should contain context: {msg}");
}
#[test]
fn parse_schedule_config_v2() {
let json = r#"{"day_blocks":{}}"#;
let cfg = parse_schedule_config(json).unwrap();
assert!(cfg.day_blocks().is_empty());
}
#[test]
fn parse_schedule_config_v1_compat() {
let json = r#"{"blocks":[]}"#;
let cfg = parse_schedule_config(json).unwrap();
assert_eq!(cfg.day_blocks().len(), 7);
}
#[test]
fn parse_rotation_policy_valid() {
let json = r#"{"cooldown_days":7,"cooldown_generations":3,"min_available_ratio":0.3}"#;
let policy = parse_rotation_policy(json).unwrap();
assert_eq!(policy.cooldown_days, Some(7));
}
#[test]
fn parse_enum_or_default_valid() {
use domain::AccessMode;
let mode: AccessMode = parse_enum_or_default("public".to_string());
assert!(matches!(mode, AccessMode::Public));
}
#[test]
fn parse_enum_or_default_fallback() {
use domain::AccessMode;
let mode: AccessMode = parse_enum_or_default("garbage".to_string());
assert!(matches!(mode, AccessMode::Public));
}
#[test]
fn map_sqlx_error_produces_repository_error() {
let sqlx_err = sqlx::Error::RowNotFound;
let domain_err = map_sqlx_error(sqlx_err);
assert!(matches!(domain_err, DomainError::RepositoryError(_)));
}
#[test]
fn serialize_enum_as_string_valid() {
use domain::AccessMode;
let result = serialize_enum_as_string(&AccessMode::Public, "fallback");
assert_eq!(result, "public");
}
#[test]
fn content_type_roundtrip() {
use domain::ContentType;
assert_eq!(parse_content_type(content_type_str(&ContentType::Movie)), ContentType::Movie);
assert_eq!(parse_content_type(content_type_str(&ContentType::Episode)), ContentType::Episode);
assert_eq!(parse_content_type(content_type_str(&ContentType::Short)), ContentType::Short);
}
#[test]
fn parse_genres_blob_basic() {
let genres = parse_genres_blob(r#"["Action","Comedy"],["Drama","Action"]"#);
assert!(genres.contains(&"Action".to_string()));
assert!(genres.contains(&"Comedy".to_string()));
assert!(genres.contains(&"Drama".to_string()));
}
}

View File

@@ -1,158 +0,0 @@
use domain::{MediaItem, MediaRole};
#[derive(Debug, Clone)]
pub struct RoleDetectionConfig {
pub interstitial_collection_patterns: Vec<String>,
pub interstitial_tag_patterns: Vec<String>,
}
impl Default for RoleDetectionConfig {
fn default() -> Self {
Self {
interstitial_collection_patterns: vec![
"bumper".into(),
"bumpers".into(),
"ad".into(),
"ads".into(),
"interstitial".into(),
"interstitials".into(),
"promo".into(),
"promos".into(),
"ident".into(),
"idents".into(),
],
interstitial_tag_patterns: vec![
"bumper".into(),
"interstitial".into(),
"ad".into(),
"promo".into(),
"ident".into(),
],
}
}
}
pub fn detect_role(item: &MediaItem, config: &RoleDetectionConfig) -> MediaRole {
if matches_collection_pattern(item, &config.interstitial_collection_patterns) {
return MediaRole::Interstitial;
}
if matches_tag_pattern(item, &config.interstitial_tag_patterns) {
return MediaRole::Interstitial;
}
MediaRole::Program
}
fn matches_collection_pattern(item: &MediaItem, patterns: &[String]) -> bool {
let collection_name = match item.collection_name() {
Some(name) => name.to_lowercase(),
None => return false,
};
patterns
.iter()
.any(|pattern| collection_name == pattern.to_lowercase())
}
fn matches_tag_pattern(item: &MediaItem, patterns: &[String]) -> bool {
item.tags().iter().any(|tag| {
let lower_tag = tag.to_lowercase();
patterns
.iter()
.any(|pattern| lower_tag == pattern.to_lowercase())
})
}
#[cfg(test)]
mod tests {
use super::*;
use domain::{ContentType, MediaItemId, MediaItemRow};
fn make_item(
collection_name: Option<&str>,
tags: Vec<&str>,
) -> MediaItem {
MediaItem::from_persistence(MediaItemRow {
id: MediaItemId::new("test::1"),
provider_id: "test".into(),
external_id: "1".into(),
title: "Test Item".into(),
content_type: ContentType::Movie,
duration_secs: 3600,
description: None,
series_name: None,
season_number: None,
episode_number: None,
year: None,
genres: vec![],
tags: tags.into_iter().map(String::from).collect(),
collection_id: None,
collection_name: collection_name.map(String::from),
collection_type: None,
thumbnail_url: None,
synced_at: None,
role: MediaRole::default(),
chapters: vec![],
})
}
#[test]
fn item_from_bumpers_collection_gets_interstitial() {
let item = make_item(Some("Bumpers"), vec![]);
let config = RoleDetectionConfig::default();
assert_eq!(detect_role(&item, &config), MediaRole::Interstitial);
}
#[test]
fn item_with_bumper_tag_gets_interstitial() {
let item = make_item(None, vec!["bumper"]);
let config = RoleDetectionConfig::default();
assert_eq!(detect_role(&item, &config), MediaRole::Interstitial);
}
#[test]
fn item_from_ads_collection_gets_interstitial() {
let item = make_item(Some("Ads"), vec![]);
let config = RoleDetectionConfig::default();
assert_eq!(detect_role(&item, &config), MediaRole::Interstitial);
}
#[test]
fn normal_item_from_movies_gets_program() {
let item = make_item(Some("Movies"), vec![]);
let config = RoleDetectionConfig::default();
assert_eq!(detect_role(&item, &config), MediaRole::Program);
}
#[test]
fn item_with_no_collection_or_tags_gets_program() {
let item = make_item(None, vec![]);
let config = RoleDetectionConfig::default();
assert_eq!(detect_role(&item, &config), MediaRole::Program);
}
#[test]
fn case_insensitive_collection_match() {
let item = make_item(Some("INTERSTITIALS"), vec![]);
let config = RoleDetectionConfig::default();
assert_eq!(detect_role(&item, &config), MediaRole::Interstitial);
}
#[test]
fn case_insensitive_tag_match() {
let item = make_item(None, vec!["PROMO"]);
let config = RoleDetectionConfig::default();
assert_eq!(detect_role(&item, &config), MediaRole::Interstitial);
}
#[test]
fn custom_config_patterns() {
let item = make_item(Some("Station IDs"), vec![]);
let config = RoleDetectionConfig {
interstitial_collection_patterns: vec!["station ids".into()],
interstitial_tag_patterns: vec![],
};
assert_eq!(detect_role(&item, &config), MediaRole::Interstitial);
}
}

View File

@@ -1,22 +0,0 @@
[package]
name = "adapter-auth"
version = "0.1.0"
edition = "2024"
[features]
default = ["jwt"]
jwt = ["dep:jsonwebtoken"]
[dependencies]
domain = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
# JWT deps
jsonwebtoken = { workspace = true, optional = true }
# Password hashing
password-auth = "1"

View File

@@ -1,341 +0,0 @@
use domain::User;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
const MIN_SECRET_LENGTH: usize = 32;
const SECS_PER_HOUR: usize = 3600;
const SECS_PER_DAY: usize = 86400;
const TOKEN_TYPE_ACCESS: &str = "access";
const TOKEN_TYPE_REFRESH: &str = "refresh";
#[derive(Debug, Clone)]
pub struct JwtConfig {
pub secret: String,
pub issuer: Option<String>,
pub audience: Option<String>,
pub expiry_hours: u64,
pub refresh_expiry_days: u64,
}
impl JwtConfig {
pub fn new(
secret: String,
issuer: Option<String>,
audience: Option<String>,
expiry_hours: Option<u64>,
refresh_expiry_days: Option<u64>,
is_production: bool,
) -> Result<Self, JwtError> {
if is_production && secret.len() < MIN_SECRET_LENGTH {
return Err(JwtError::WeakSecret {
min_length: MIN_SECRET_LENGTH,
actual_length: secret.len(),
});
}
Ok(Self {
secret,
issuer,
audience,
expiry_hours: expiry_hours.unwrap_or(24),
refresh_expiry_days: refresh_expiry_days.unwrap_or(30),
})
}
pub fn new_unchecked(secret: String) -> Self {
Self {
secret,
issuer: None,
audience: None,
expiry_hours: 24,
refresh_expiry_days: 30,
}
}
}
fn default_token_type() -> String {
TOKEN_TYPE_ACCESS.to_string()
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JwtClaims {
pub sub: String,
pub email: String,
pub exp: usize,
pub iat: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub iss: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aud: Option<String>,
#[serde(default = "default_token_type")]
pub token_type: String,
}
#[derive(Debug, thiserror::Error)]
pub enum JwtError {
#[error("JWT secret too weak: minimum {min_length} bytes, got {actual_length}")]
WeakSecret {
min_length: usize,
actual_length: usize,
},
#[error("Token creation failed: {0}")]
CreationFailed(#[from] jsonwebtoken::errors::Error),
#[error("Token validation failed: {0}")]
ValidationFailed(String),
#[error("Token expired")]
Expired,
#[error("Invalid token format")]
InvalidFormat,
#[error("Missing configuration")]
MissingConfig,
}
#[derive(Clone)]
pub struct JwtValidator {
config: JwtConfig,
encoding_key: EncodingKey,
decoding_key: DecodingKey,
validation: Validation,
}
impl JwtValidator {
pub fn new(config: JwtConfig) -> Self {
let encoding_key = EncodingKey::from_secret(config.secret.as_bytes());
let decoding_key = DecodingKey::from_secret(config.secret.as_bytes());
let mut validation = Validation::new(Algorithm::HS256);
if let Some(ref issuer) = config.issuer {
validation.set_issuer(&[issuer]);
}
if let Some(ref audience) = config.audience {
validation.set_audience(&[audience]);
}
Self {
config,
encoding_key,
decoding_key,
validation,
}
}
pub fn create_token(&self, user: &User) -> Result<String, JwtError> {
let now = now_secs();
let expiry = now + (self.config.expiry_hours as usize * SECS_PER_HOUR);
let claims = JwtClaims {
sub: user.id().to_string(),
email: user.email().as_ref().to_string(),
exp: expiry,
iat: now,
iss: self.config.issuer.clone(),
aud: self.config.audience.clone(),
token_type: TOKEN_TYPE_ACCESS.to_string(),
};
encode(&Header::new(Algorithm::HS256), &claims, &self.encoding_key)
.map_err(JwtError::CreationFailed)
}
pub fn create_refresh_token(&self, user: &User) -> Result<String, JwtError> {
let now = now_secs();
let expiry = now + (self.config.refresh_expiry_days as usize * SECS_PER_DAY);
let claims = JwtClaims {
sub: user.id().to_string(),
email: user.email().as_ref().to_string(),
exp: expiry,
iat: now,
iss: self.config.issuer.clone(),
aud: self.config.audience.clone(),
token_type: TOKEN_TYPE_REFRESH.to_string(),
};
encode(&Header::new(Algorithm::HS256), &claims, &self.encoding_key)
.map_err(JwtError::CreationFailed)
}
pub fn validate_token(&self, token: &str) -> Result<JwtClaims, JwtError> {
let token_data =
decode::<JwtClaims>(token, &self.decoding_key, &self.validation).map_err(|e| {
match e.kind() {
jsonwebtoken::errors::ErrorKind::ExpiredSignature => JwtError::Expired,
jsonwebtoken::errors::ErrorKind::InvalidToken => JwtError::InvalidFormat,
_ => JwtError::ValidationFailed(e.to_string()),
}
})?;
Ok(token_data.claims)
}
pub fn validate_access_token(&self, token: &str) -> Result<JwtClaims, JwtError> {
let claims = self.validate_token(token)?;
if claims.token_type != TOKEN_TYPE_ACCESS {
return Err(JwtError::ValidationFailed(
"Not an access token".to_string(),
));
}
Ok(claims)
}
pub fn validate_refresh_token(&self, token: &str) -> Result<JwtClaims, JwtError> {
let claims = self.validate_token(token)?;
if claims.token_type != TOKEN_TYPE_REFRESH {
return Err(JwtError::ValidationFailed(
"Not a refresh token".to_string(),
));
}
Ok(claims)
}
pub fn decode_unverified(&self, token: &str) -> Result<JwtClaims, JwtError> {
let mut insecure = Validation::new(Algorithm::HS256);
insecure.insecure_disable_signature_validation();
insecure.validate_exp = false;
insecure.validate_aud = false;
let token_data = decode::<JwtClaims>(token, &self.decoding_key, &insecure)
.map_err(|_| JwtError::InvalidFormat)?;
Ok(token_data.claims)
}
pub fn expiry_hours(&self) -> u64 {
self.config.expiry_hours
}
}
pub struct JwtTokenService {
validator: JwtValidator,
}
impl JwtTokenService {
pub fn new(validator: JwtValidator) -> Self {
Self { validator }
}
}
impl domain::ports::TokenService for JwtTokenService {
fn create_access_token(&self, user: &domain::User) -> domain::DomainResult<String> {
self.validator.create_token(user).map_err(|e| {
domain::DomainError::InfrastructureError(format!("Failed to create access token: {e}"))
})
}
fn create_refresh_token(&self, user: &domain::User) -> domain::DomainResult<String> {
self.validator.create_refresh_token(user).map_err(|e| {
domain::DomainError::InfrastructureError(format!(
"Failed to create refresh token: {e}"
))
})
}
fn validate_refresh_token(&self, token: &str) -> domain::DomainResult<domain::UserId> {
let claims = self.validator.validate_refresh_token(token).map_err(|e| {
tracing::debug!("Refresh token validation failed: {:?}", e);
domain::DomainError::Unauthenticated("Invalid refresh token".to_string())
})?;
let user_id: uuid::Uuid = claims.sub.parse().map_err(|_| {
domain::DomainError::Unauthenticated("Invalid user ID in token".to_string())
})?;
Ok(domain::UserId::from(user_id))
}
fn token_expiry_secs(&self) -> u64 {
self.validator.expiry_hours() * 3600
}
}
impl std::fmt::Debug for JwtValidator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JwtValidator")
.field("issuer", &self.config.issuer)
.field("audience", &self.config.audience)
.field("expiry_hours", &self.config.expiry_hours)
.finish_non_exhaustive()
}
}
fn now_secs() -> usize {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs() as usize
}
#[cfg(test)]
mod tests {
use super::*;
use domain::Email;
fn test_user() -> User {
let email = Email::new("test@example.com").unwrap();
User::new("test-subject", email)
}
#[test]
fn create_and_validate_token() {
let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string());
let validator = JwtValidator::new(config);
let user = test_user();
let token = validator.create_token(&user).expect("create token");
let claims = validator.validate_token(&token).expect("validate token");
assert_eq!(claims.sub, user.id().to_string());
assert_eq!(claims.email, "test@example.com");
assert_eq!(claims.token_type, "access");
}
#[test]
fn refresh_token_round_trip() {
let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string());
let validator = JwtValidator::new(config);
let user = test_user();
let token = validator.create_refresh_token(&user).unwrap();
let claims = validator.validate_refresh_token(&token).unwrap();
assert_eq!(claims.token_type, "refresh");
assert!(validator.validate_access_token(&token).is_err());
}
#[test]
fn weak_secret_rejected_in_production() {
let result = JwtConfig::new("short".to_string(), None, None, None, None, true);
assert!(matches!(result, Err(JwtError::WeakSecret { .. })));
}
#[test]
fn weak_secret_allowed_in_development() {
let result = JwtConfig::new("short".to_string(), None, None, None, None, false);
assert!(result.is_ok());
}
#[test]
fn invalid_token_rejected() {
let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string());
let validator = JwtValidator::new(config);
assert!(validator.validate_token("invalid.token.here").is_err());
}
#[test]
fn wrong_secret_rejected() {
let v1 = JwtValidator::new(JwtConfig::new_unchecked(
"secret-one-that-is-long-enough".to_string(),
));
let v2 = JwtValidator::new(JwtConfig::new_unchecked(
"secret-two-that-is-long-enough".to_string(),
));
let user = test_user();
let token = v1.create_token(&user).unwrap();
assert!(v2.validate_token(&token).is_err());
}
}

View File

@@ -1,9 +0,0 @@
pub mod password;
#[cfg(feature = "jwt")]
pub mod jwt;
pub use password::PasswordAuthService;
#[cfg(feature = "jwt")]
pub use jwt::{JwtClaims, JwtConfig, JwtError, JwtTokenService, JwtValidator};

View File

@@ -1,33 +0,0 @@
use domain::errors::DomainResult;
use domain::ports::AuthService;
pub struct PasswordAuthService;
impl AuthService for PasswordAuthService {
fn hash_password(&self, password: &str) -> DomainResult<String> {
Ok(password_auth::generate_hash(password))
}
fn verify_password(&self, password: &str, hash: &str) -> DomainResult<bool> {
Ok(password_auth::verify_password(password, hash).is_ok())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_and_verify_round_trip() {
let svc = PasswordAuthService;
let hash = svc.hash_password("supersecret").unwrap();
assert!(svc.verify_password("supersecret", &hash).unwrap());
}
#[test]
fn wrong_password_rejected() {
let svc = PasswordAuthService;
let hash = svc.hash_password("correct").unwrap();
assert!(!svc.verify_password("wrong", &hash).unwrap());
}
}

View File

@@ -1,14 +0,0 @@
[package]
name = "adapter-event-publisher"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
sqlx = { workspace = true, features = ["sqlite"] }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }

View File

@@ -1,170 +0,0 @@
use async_trait::async_trait;
use domain::errors::{DomainError, DomainResult};
use domain::events::{DomainEvent, EventEnvelope};
use domain::ports::events::{EventConsumer, EventPublisher};
use sqlx::SqlitePool;
fn event_type_label(event: &DomainEvent) -> &'static str {
match event {
DomainEvent::BroadcastTransition { .. } => "broadcast_transition",
DomainEvent::NoSignal { .. } => "no_signal",
DomainEvent::ScheduleGenerated { .. } => "schedule_generated",
DomainEvent::ChannelCreated { .. } => "channel_created",
DomainEvent::ChannelUpdated { .. } => "channel_updated",
DomainEvent::ChannelDeleted { .. } => "channel_deleted",
DomainEvent::UserRegistered { .. } => "user_registered",
_ => "unknown",
}
}
pub struct SqliteEventPublisher {
pool: SqlitePool,
}
impl SqliteEventPublisher {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait]
impl EventPublisher for SqliteEventPublisher {
async fn publish(&self, event: DomainEvent) -> DomainResult<()> {
let event_type = event_type_label(&event);
let payload = serde_json::to_string(&event)
.map_err(|e| DomainError::InfrastructureError(format!("event serialize: {e}")))?;
sqlx::query(
"INSERT INTO event_queue (event_type, payload, status) VALUES (?, ?, 'pending')",
)
.bind(event_type)
.bind(&payload)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
}
pub struct SqliteEventConsumer {
pool: SqlitePool,
}
impl SqliteEventConsumer {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
struct EventRow {
id: i64,
payload: String,
retry_count: i32,
created_at: String,
max_retries: i32,
event_type: String,
}
#[async_trait]
impl EventConsumer for SqliteEventConsumer {
async fn poll_next(&self) -> DomainResult<Option<EventEnvelope>> {
let row: Option<EventRow> = sqlx::query_as(
"SELECT id, event_type, payload, retry_count, created_at, max_retries \
FROM event_queue WHERE status = 'pending' ORDER BY id ASC LIMIT 1",
)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let row = match row {
Some(r) => r,
None => return Ok(None),
};
sqlx::query("UPDATE event_queue SET status = 'processing', updated_at = datetime('now') WHERE id = ?")
.bind(row.id)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
match serde_json::from_str::<DomainEvent>(&row.payload) {
Ok(event) => Ok(Some(EventEnvelope::from_persistence(
row.id,
event,
row.retry_count as u32,
row.created_at,
))),
Err(e) => {
move_to_dlq(&self.pool, &row, &e.to_string()).await?;
Ok(None)
}
}
}
async fn ack(&self, event_id: i64) -> DomainResult<()> {
sqlx::query("DELETE FROM event_queue WHERE id = ?")
.bind(event_id)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
async fn nack(&self, event_id: i64, error: &str) -> DomainResult<()> {
let row: Option<EventRow> = sqlx::query_as(
"SELECT id, event_type, payload, retry_count, created_at, max_retries FROM event_queue WHERE id = ?",
)
.bind(event_id)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let row = match row {
Some(r) => r,
None => return Ok(()),
};
let new_retry = row.retry_count + 1;
if new_retry >= row.max_retries {
move_to_dlq(&self.pool, &row, error).await?;
} else {
sqlx::query(
"UPDATE event_queue SET status = 'pending', retry_count = ?, error_message = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(new_retry)
.bind(error)
.bind(event_id)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
}
Ok(())
}
}
async fn move_to_dlq(pool: &SqlitePool, row: &EventRow, error: &str) -> DomainResult<()> {
sqlx::query(
"INSERT INTO dead_letter_queue (original_event_id, event_type, payload, error_message, retry_count, original_created_at) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(row.id)
.bind(&row.event_type)
.bind(&row.payload)
.bind(error)
.bind(row.retry_count)
.bind(&row.created_at)
.execute(pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
sqlx::query("DELETE FROM event_queue WHERE id = ?")
.bind(row.id)
.execute(pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}

View File

@@ -1,12 +0,0 @@
[package]
name = "adapter-jellyfin"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }

View File

@@ -1,6 +0,0 @@
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct JellyfinConfig {
pub base_url: String,
pub api_key: String,
pub user_id: String,
}

View File

@@ -1,7 +0,0 @@
mod config;
mod mapping;
mod models;
mod provider;
pub use config::JellyfinConfig;
pub use provider::JellyfinMediaProvider;

View File

@@ -1,41 +0,0 @@
use domain::{ContentType, MediaItem, MediaItemId, MediaItemRow, MediaRole};
use crate::models::JellyfinItem;
pub(crate) const TICKS_PER_SEC: i64 = 10_000_000;
pub(crate) fn map_jellyfin_item(item: JellyfinItem) -> Option<MediaItem> {
let content_type = match item.item_type.as_str() {
"Movie" => ContentType::Movie,
"Episode" => ContentType::Episode,
_ => return None,
};
let duration_secs = item
.run_time_ticks
.map(|t| (t / TICKS_PER_SEC) as u32)
.unwrap_or(0);
Some(MediaItem::from_persistence(MediaItemRow {
id: MediaItemId::new(&item.id),
title: item.name,
content_type,
duration_secs,
description: item.overview,
genres: item.genres.unwrap_or_default(),
year: item.production_year,
tags: item.tags.unwrap_or_default(),
series_name: item.series_name,
season_number: item.parent_index_number,
episode_number: item.index_number,
thumbnail_url: None,
collection_id: None,
provider_id: String::new(),
external_id: item.id,
collection_name: None,
collection_type: None,
synced_at: None,
role: MediaRole::default(),
chapters: Vec::new(),
}))
}

View File

@@ -1,46 +0,0 @@
use domain::ContentType;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub(crate) struct JellyfinItemsResponse {
#[serde(rename = "Items")]
pub items: Vec<JellyfinItem>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct JellyfinItem {
#[serde(rename = "Id")]
pub id: String,
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Type")]
pub item_type: String,
#[serde(rename = "RunTimeTicks")]
pub run_time_ticks: Option<i64>,
#[serde(rename = "Overview")]
pub overview: Option<String>,
#[serde(rename = "Genres")]
pub genres: Option<Vec<String>>,
#[serde(rename = "ProductionYear")]
pub production_year: Option<u16>,
#[serde(rename = "Tags")]
pub tags: Option<Vec<String>>,
#[serde(rename = "SeriesName")]
pub series_name: Option<String>,
#[serde(rename = "ParentIndexNumber")]
pub parent_index_number: Option<u32>,
#[serde(rename = "IndexNumber")]
pub index_number: Option<u32>,
#[serde(rename = "CollectionType")]
pub collection_type: Option<String>,
#[serde(rename = "RecursiveItemCount")]
pub recursive_item_count: Option<u32>,
}
pub(crate) fn jellyfin_item_type(ct: &ContentType) -> &'static str {
match ct {
ContentType::Movie => "Movie",
ContentType::Episode => "Episode",
ContentType::Short => "Movie",
}
}

View File

@@ -1,344 +0,0 @@
use async_trait::async_trait;
use domain::ports::{Collection, IMediaProvider, ProviderCapabilities, SeriesSummary};
use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId, SourceUri};
use crate::config::JellyfinConfig;
use crate::mapping::{map_jellyfin_item, TICKS_PER_SEC};
use crate::models::{jellyfin_item_type, JellyfinItemsResponse};
pub struct JellyfinMediaProvider {
client: reqwest::Client,
config: JellyfinConfig,
}
impl JellyfinMediaProvider {
pub fn new(config: JellyfinConfig) -> Self {
Self {
client: reqwest::Client::new(),
config: JellyfinConfig {
base_url: config.base_url.trim_end_matches('/').to_string(),
..config
},
}
}
async fn fetch_items_for_series(
&self,
filter: &MediaFilter,
series_name: Option<&str>,
) -> DomainResult<Vec<MediaItem>> {
let url = format!(
"{}/Users/{}/Items",
self.config.base_url, self.config.user_id
);
let mut params: Vec<(&str, String)> = vec![
("Recursive", "true".into()),
(
"Fields",
"Genres,Tags,RunTimeTicks,ProductionYear,Overview".into(),
),
];
if let Some(ct) = &filter.content_type {
params.push(("IncludeItemTypes", jellyfin_item_type(ct).into()));
}
if !filter.genres.is_empty() {
params.push(("Genres", filter.genres.join("|")));
}
if let Some(decade) = filter.decade {
params.push(("MinYear", decade.to_string()));
params.push(("MaxYear", (decade + 9).to_string()));
}
if !filter.tags.is_empty() {
params.push(("Tags", filter.tags.join("|")));
}
if let Some(min) = filter.min_duration_secs {
params.push(("MinRunTimeTicks", (min as i64 * TICKS_PER_SEC).to_string()));
}
if let Some(max) = filter.max_duration_secs {
params.push(("MaxRunTimeTicks", (max as i64 * TICKS_PER_SEC).to_string()));
}
if let Some(name) = series_name {
params.push(("SeriesName", name.to_string()));
params.push(("SortBy", "ParentIndexNumber,IndexNumber".into()));
params.push(("SortOrder", "Ascending".into()));
if filter.content_type.is_none() {
params.push(("IncludeItemTypes", "Episode".into()));
}
} else {
if let Some(parent_id) = filter.collections.first() {
params.push(("ParentId", parent_id.clone()));
}
}
if let Some(q) = &filter.search_term {
params.push(("SearchTerm", q.clone()));
}
let response = self
.client
.get(&url)
.header("X-Emby-Token", &self.config.api_key)
.query(&params)
.send()
.await
.map_err(|e| {
DomainError::InfrastructureError(format!("Jellyfin request failed: {e}"))
})?;
if !response.status().is_success() {
return Err(DomainError::InfrastructureError(format!(
"Jellyfin returned HTTP {}",
response.status()
)));
}
let body: JellyfinItemsResponse = response.json().await.map_err(|e| {
DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {e}"))
})?;
// WHY: Jellyfin's SeriesName query param is a fuzzy match that can return
// items from other shows; post-filter to guarantee correctness.
let items = body.items.into_iter().filter_map(map_jellyfin_item);
let items: Vec<MediaItem> = if let Some(name) = series_name {
items
.filter(|item| {
item.series_name()
.map(|s| s.eq_ignore_ascii_case(name))
.unwrap_or(false)
})
.collect()
} else {
items.collect()
};
Ok(items)
}
}
#[async_trait]
impl IMediaProvider for JellyfinMediaProvider {
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
collections: true,
series: true,
genres: true,
tags: true,
decade: true,
search: true,
rescan: false,
}
}
async fn fetch_items(&self, filter: &MediaFilter) -> DomainResult<Vec<MediaItem>> {
match filter.series_names.len() {
0 | 1 => {
let series = filter.series_names.first().map(String::as_str);
self.fetch_items_for_series(filter, series).await
}
_ => {
let mut per_series: Vec<Vec<MediaItem>> = Vec::new();
for series_name in &filter.series_names {
let items = self
.fetch_items_for_series(filter, Some(series_name.as_str()))
.await?;
if !items.is_empty() {
per_series.push(items);
}
}
let max_len = per_series.iter().map(|s| s.len()).max().unwrap_or(0);
let mut all = Vec::with_capacity(per_series.iter().map(|s| s.len()).sum());
for i in 0..max_len {
for s in &per_series {
if let Some(item) = s.get(i) {
all.push(item.clone());
}
}
}
Ok(all)
}
}
}
async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult<Option<MediaItem>> {
let url = format!(
"{}/Users/{}/Items",
self.config.base_url, self.config.user_id
);
let response = self
.client
.get(&url)
.header("X-Emby-Token", &self.config.api_key)
.query(&[
("Ids", item_id.as_ref()),
("Fields", "Genres,Tags,RunTimeTicks,ProductionYear"),
])
.send()
.await
.map_err(|e| {
DomainError::InfrastructureError(format!("Jellyfin request failed: {e}"))
})?;
if !response.status().is_success() {
return Ok(None);
}
let body: JellyfinItemsResponse = response.json().await.map_err(|e| {
DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {e}"))
})?;
Ok(body.items.into_iter().next().and_then(map_jellyfin_item))
}
async fn list_collections(&self) -> DomainResult<Vec<Collection>> {
let url = format!(
"{}/Users/{}/Views",
self.config.base_url, self.config.user_id
);
let response = self
.client
.get(&url)
.header("X-Emby-Token", &self.config.api_key)
.send()
.await
.map_err(|e| {
DomainError::InfrastructureError(format!("Jellyfin request failed: {e}"))
})?;
if !response.status().is_success() {
return Err(DomainError::InfrastructureError(format!(
"Jellyfin returned HTTP {}",
response.status()
)));
}
let body: JellyfinItemsResponse = response.json().await.map_err(|e| {
DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {e}"))
})?;
Ok(body
.items
.into_iter()
.map(|item| Collection {
id: item.id,
name: item.name,
collection_type: item.collection_type,
})
.collect())
}
async fn list_series(&self, collection_id: Option<&str>) -> DomainResult<Vec<SeriesSummary>> {
let url = format!(
"{}/Users/{}/Items",
self.config.base_url, self.config.user_id
);
let mut params: Vec<(&str, String)> = vec![
("Recursive", "true".into()),
("IncludeItemTypes", "Series".into()),
(
"Fields",
"Genres,ProductionYear,RecursiveItemCount".into(),
),
("SortBy", "SortName".into()),
("SortOrder", "Ascending".into()),
];
if let Some(id) = collection_id {
params.push(("ParentId", id.to_string()));
}
let response = self
.client
.get(&url)
.header("X-Emby-Token", &self.config.api_key)
.query(&params)
.send()
.await
.map_err(|e| {
DomainError::InfrastructureError(format!("Jellyfin request failed: {e}"))
})?;
if !response.status().is_success() {
return Err(DomainError::InfrastructureError(format!(
"Jellyfin returned HTTP {}",
response.status()
)));
}
let body: JellyfinItemsResponse = response.json().await.map_err(|e| {
DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {e}"))
})?;
Ok(body
.items
.into_iter()
.map(|item| SeriesSummary {
id: item.id,
name: item.name,
episode_count: item.recursive_item_count.unwrap_or(0),
genres: item.genres.unwrap_or_default(),
year: item.production_year,
})
.collect())
}
async fn list_genres(&self, content_type: Option<&ContentType>) -> DomainResult<Vec<String>> {
let url = format!("{}/Genres", self.config.base_url);
let mut params: Vec<(&str, String)> = vec![
("UserId", self.config.user_id.clone()),
("SortBy", "SortName".into()),
("SortOrder", "Ascending".into()),
];
if let Some(ct) = content_type {
params.push(("IncludeItemTypes", jellyfin_item_type(ct).into()));
}
let response = self
.client
.get(&url)
.header("X-Emby-Token", &self.config.api_key)
.query(&params)
.send()
.await
.map_err(|e| {
DomainError::InfrastructureError(format!("Jellyfin request failed: {e}"))
})?;
if !response.status().is_success() {
return Err(DomainError::InfrastructureError(format!(
"Jellyfin returned HTTP {}",
response.status()
)));
}
let body: JellyfinItemsResponse = response.json().await.map_err(|e| {
DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {e}"))
})?;
Ok(body.items.into_iter().map(|item| item.name).collect())
}
async fn get_source_uri(&self, item_id: &MediaItemId) -> DomainResult<SourceUri> {
Ok(SourceUri::NetworkUrl {
url: format!(
"{}/Videos/{}/stream?static=true&api_key={}",
self.config.base_url,
item_id.as_ref(),
self.config.api_key,
),
})
}
}

View File

@@ -1,18 +0,0 @@
[package]
name = "adapter-local-files"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
infra-wiring = { workspace = true, features = ["sqlite"] }
async-trait = { workspace = true }
sqlx = { workspace = true, features = ["sqlite"] }
tokio = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
walkdir = "2"
base64 = "0.22"

View File

@@ -1,8 +0,0 @@
use std::path::PathBuf;
pub struct LocalFilesConfig {
pub root_dir: PathBuf,
pub base_url: String,
pub transcode_dir: Option<PathBuf>,
pub cleanup_ttl_hours: u32,
}

View File

@@ -1,186 +0,0 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use chrono::Utc;
use tokio::sync::RwLock;
use tracing::{error, info};
use domain::MediaItemId;
use crate::config::LocalFilesConfig;
use crate::scanner::{scan_dir, LocalFileItem};
pub fn encode_id(rel_path: &str) -> MediaItemId {
use base64::Engine as _;
MediaItemId::new(
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(rel_path.as_bytes()),
)
}
pub fn decode_id(id: &MediaItemId) -> Option<String> {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(id.as_ref())
.ok()?;
String::from_utf8(bytes).ok()
}
pub struct LocalIndex {
items: Arc<RwLock<HashMap<MediaItemId, LocalFileItem>>>,
pub root_dir: PathBuf,
provider_id: String,
pool: sqlx::SqlitePool,
}
impl LocalIndex {
pub async fn new(
config: &LocalFilesConfig,
pool: sqlx::SqlitePool,
provider_id: String,
) -> Self {
let idx = Self {
items: Arc::new(RwLock::new(HashMap::new())),
root_dir: config.root_dir.clone(),
provider_id,
pool,
};
idx.load_from_db().await;
idx
}
async fn load_from_db(&self) {
#[derive(sqlx::FromRow)]
struct Row {
id: String,
rel_path: String,
title: String,
duration_secs: i64,
year: Option<i64>,
tags: String,
top_dir: String,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT id, rel_path, title, duration_secs, year, tags, top_dir \
FROM local_files_index WHERE provider_id = ?",
)
.bind(&self.provider_id)
.fetch_all(&self.pool)
.await;
match rows {
Ok(rows) => {
let mut map = self.items.write().await;
for row in rows {
let tags: Vec<String> =
serde_json::from_str(&row.tags).unwrap_or_default();
let item = LocalFileItem {
rel_path: row.rel_path,
title: row.title,
duration_secs: row.duration_secs as u32,
year: row.year.map(|y| y as u16),
tags,
top_dir: row.top_dir,
};
map.insert(MediaItemId::new(row.id), item);
}
info!(
"Local files index [{}]: loaded {} items from DB",
self.provider_id,
map.len()
);
}
Err(e) => {
// Table might not exist yet on first run -- that's fine.
tracing::debug!("Could not load local files index from DB: {}", e);
}
}
}
pub async fn rescan(&self) -> u32 {
info!(
"Local files [{}]: scanning {:?}",
self.provider_id, self.root_dir
);
let new_items = scan_dir(&self.root_dir).await;
let count = new_items.len() as u32;
{
let mut map = self.items.write().await;
map.clear();
for item in &new_items {
let id = encode_id(&item.rel_path);
map.insert(id, item.clone());
}
}
if let Err(e) = self.save_to_db(&new_items).await {
error!("Failed to persist local files index: {}", e);
}
info!(
"Local files [{}]: indexed {} items",
self.provider_id, count
);
count
}
async fn save_to_db(&self, items: &[LocalFileItem]) -> Result<(), sqlx::Error> {
let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM local_files_index WHERE provider_id = ?")
.bind(&self.provider_id)
.execute(&mut *tx)
.await?;
let now = Utc::now().to_rfc3339();
for item in items {
let id = encode_id(&item.rel_path).into_inner();
let tags_json =
serde_json::to_string(&item.tags).unwrap_or_else(|_| "[]".into());
sqlx::query(
"INSERT INTO local_files_index \
(id, rel_path, title, duration_secs, year, tags, top_dir, scanned_at, provider_id) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(&item.rel_path)
.bind(&item.title)
.bind(item.duration_secs as i64)
.bind(item.year.map(|y| y as i64))
.bind(&tags_json)
.bind(&item.top_dir)
.bind(&now)
.bind(&self.provider_id)
.execute(&mut *tx)
.await?;
}
tx.commit().await
}
pub async fn get(&self, id: &MediaItemId) -> Option<LocalFileItem> {
self.items.read().await.get(id).cloned()
}
pub async fn get_all(&self) -> Vec<(MediaItemId, LocalFileItem)> {
self.items
.read()
.await
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
pub async fn collections(&self) -> Vec<String> {
let map = self.items.read().await;
let mut seen = std::collections::HashSet::new();
for item in map.values() {
seen.insert(item.top_dir.clone());
}
let mut dirs: Vec<String> = seen.into_iter().collect();
dirs.sort();
dirs
}
}

View File

@@ -1,41 +0,0 @@
pub mod config;
pub mod index;
pub mod provider;
pub mod scanner;
pub mod transcoder;
pub use config::LocalFilesConfig;
pub use index::LocalIndex;
pub use provider::{LocalFilesProvider, decode_stream_id};
pub use transcoder::TranscodeManager;
use std::sync::Arc;
pub struct LocalFilesBundle {
pub provider: LocalFilesProvider,
pub local_index: Arc<LocalIndex>,
pub transcode_manager: Option<Arc<TranscodeManager>>,
}
impl LocalFilesBundle {
pub async fn build(
config: LocalFilesConfig,
pool: sqlx::SqlitePool,
provider_id: String,
) -> Self {
let local_index = Arc::new(LocalIndex::new(&config, pool, provider_id).await);
let transcode_manager = config.transcode_dir.as_ref().map(|dir| {
TranscodeManager::new(dir.clone(), config.cleanup_ttl_hours)
});
let provider =
LocalFilesProvider::new(Arc::clone(&local_index), &config);
Self {
provider,
local_index,
transcode_manager,
}
}
}

View File

@@ -1,165 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::ports::{Collection, IMediaProvider, ProviderCapabilities};
use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId, MediaItemRow, MediaRole, SourceUri};
use crate::config::LocalFilesConfig;
use crate::index::{decode_id, LocalIndex};
use crate::scanner::LocalFileItem;
pub struct LocalFilesProvider {
pub index: Arc<LocalIndex>,
}
const SHORT_DURATION_SECS: u32 = 1200;
const DECADE_SPAN: u16 = 9;
impl LocalFilesProvider {
pub fn new(index: Arc<LocalIndex>, _config: &LocalFilesConfig) -> Self {
Self { index }
}
}
fn to_media_item(id: MediaItemId, item: &LocalFileItem) -> MediaItem {
let content_type = if item.duration_secs < SHORT_DURATION_SECS {
ContentType::Short
} else {
ContentType::Movie
};
MediaItem::from_persistence(MediaItemRow {
id,
title: item.title.clone(),
content_type,
duration_secs: item.duration_secs,
description: None,
genres: vec![],
year: item.year,
tags: item.tags.clone(),
series_name: None,
season_number: None,
episode_number: None,
thumbnail_url: None,
collection_id: None,
provider_id: String::new(),
external_id: String::new(),
collection_name: None,
collection_type: None,
synced_at: None,
role: MediaRole::default(),
chapters: Vec::new(),
})
}
#[async_trait]
impl IMediaProvider for LocalFilesProvider {
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
collections: true,
series: false,
genres: false,
tags: true,
decade: true,
search: true,
rescan: true,
}
}
async fn fetch_items(&self, filter: &MediaFilter) -> DomainResult<Vec<MediaItem>> {
let all = self.index.get_all().await;
let results = all
.into_iter()
.filter_map(|(id, item)| {
let content_type = if item.duration_secs < SHORT_DURATION_SECS {
ContentType::Short
} else {
ContentType::Movie
};
if let Some(ref ct) = filter.content_type
&& &content_type != ct
{
return None;
}
if !filter.collections.is_empty()
&& !filter.collections.contains(&item.top_dir)
{
return None;
}
if !filter.tags.is_empty() {
let has = filter
.tags
.iter()
.any(|tag| item.tags.iter().any(|t| t.eq_ignore_ascii_case(tag)));
if !has {
return None;
}
}
if let Some(decade) = filter.decade {
match item.year {
Some(y) if y >= decade && y <= decade + DECADE_SPAN => {}
_ => return None,
}
}
if let Some(min) = filter.min_duration_secs
&& item.duration_secs < min
{
return None;
}
if let Some(max) = filter.max_duration_secs
&& item.duration_secs > max
{
return None;
}
if let Some(ref q) = filter.search_term
&& !item.title.to_lowercase().contains(&q.to_lowercase())
{
return None;
}
Some(to_media_item(id, &item))
})
.collect();
Ok(results)
}
async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult<Option<MediaItem>> {
Ok(self
.index
.get(item_id)
.await
.map(|item| to_media_item(item_id.clone(), &item)))
}
async fn get_source_uri(&self, item_id: &MediaItemId) -> DomainResult<SourceUri> {
let rel = decode_id(item_id).ok_or_else(|| {
DomainError::InfrastructureError("invalid item id encoding".into())
})?;
let abs_path = self.index.root_dir.join(&rel);
Ok(SourceUri::FilePath {
path: abs_path.to_string_lossy().into_owned(),
})
}
async fn list_collections(&self) -> DomainResult<Vec<Collection>> {
let dirs = self.index.collections().await;
Ok(dirs
.into_iter()
.map(|d| Collection {
id: d.clone(),
name: d,
collection_type: None,
})
.collect())
}
}
pub fn decode_stream_id(encoded: &str) -> Option<String> {
decode_id(&MediaItemId::new(encoded))
}

View File

@@ -1,162 +0,0 @@
use std::path::Path;
use tokio::process::Command;
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mkv", "avi", "mov", "webm", "m4v"];
const ROOT_COLLECTION_NAME: &str = "__root__";
const YEAR_DIGITS: usize = 4;
const MIN_YEAR: u16 = 1900;
const MAX_YEAR: u16 = 2099;
#[derive(Debug, Clone)]
pub struct LocalFileItem {
pub rel_path: String,
pub title: String,
pub duration_secs: u32,
pub year: Option<u16>,
pub tags: Vec<String>,
pub top_dir: String,
}
pub async fn scan_dir(root: &Path) -> Vec<LocalFileItem> {
let mut items = Vec::new();
let walker = walkdir::WalkDir::new(root).follow_links(true);
for entry in walker.into_iter().filter_map(|e| e.ok()) {
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase());
match ext {
Some(ref e) if VIDEO_EXTENSIONS.contains(&e.as_str()) => {}
_ => continue,
};
let rel = match path.strip_prefix(root) {
Ok(r) => r,
Err(_) => continue,
};
let rel_path: String = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/");
let top_dir = rel
.components()
.next()
.filter(|_| rel.components().count() > 1)
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.unwrap_or_else(|| ROOT_COLLECTION_NAME.to_string());
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let title = stem.replace(['_', '-', '.'], " ");
let title = title.trim().to_string();
let search_str = format!(
"{} {}",
stem,
rel.parent()
.and_then(|p| p.to_str())
.unwrap_or("")
);
let year = extract_year(&search_str);
let tags: Vec<String> = rel
.parent()
.into_iter()
.flat_map(|p| p.components())
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.filter(|s| !s.is_empty())
.collect();
let duration_secs = get_duration(path).await.unwrap_or(0);
items.push(LocalFileItem {
rel_path,
title,
duration_secs,
year,
tags,
top_dir,
});
}
items
}
fn extract_year(s: &str) -> Option<u16> {
let chars: Vec<char> = s.chars().collect();
let n = chars.len();
if n < YEAR_DIGITS {
return None;
}
for i in 0..=(n - YEAR_DIGITS) {
if !chars[i..i + YEAR_DIGITS].iter().all(|c| c.is_ascii_digit()) {
continue;
}
let s4: String = chars[i..i + YEAR_DIGITS].iter().collect();
let num: u16 = s4.parse().ok()?;
if !(MIN_YEAR..=MAX_YEAR).contains(&num) {
continue;
}
let before_ok = i == 0 || !chars[i - 1].is_ascii_digit();
let after_ok = i + YEAR_DIGITS >= n || !chars[i + YEAR_DIGITS].is_ascii_digit();
if before_ok && after_ok {
return Some(num);
}
}
None
}
async fn get_duration(path: &Path) -> Option<u32> {
#[derive(serde::Deserialize)]
struct Fmt {
duration: Option<String>,
}
#[derive(serde::Deserialize)]
struct Out {
format: Fmt,
}
let output = Command::new("ffprobe")
.args([
"-v",
"quiet",
"-print_format",
"json",
"-show_format",
path.to_str()?,
])
.output()
.await
.ok()?;
let parsed: Out = serde_json::from_slice(&output.stdout).ok()?;
let dur: f64 = parsed.format.duration?.parse().ok()?;
Some(dur as u32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_year_basic() {
assert_eq!(extract_year("Movie 2024 HD"), Some(2024));
assert_eq!(extract_year("1999_classic"), Some(1999));
assert_eq!(extract_year("no year here"), None);
assert_eq!(extract_year("12345"), None);
assert_eq!(extract_year("2100"), None);
assert_eq!(extract_year("1900"), Some(1900));
assert_eq!(extract_year("2099"), Some(2099));
}
}

View File

@@ -1,241 +0,0 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicU32, Ordering},
};
use std::time::{Duration, Instant};
use tokio::sync::{Mutex, watch};
use tracing::{error, info, warn};
use domain::{DomainError, DomainResult};
const SECS_PER_HOUR: u64 = 3600;
const CLEANUP_INTERVAL: Duration = Duration::from_secs(SECS_PER_HOUR);
const TRANSCODE_TIMEOUT: Duration = Duration::from_secs(60);
const TRANSCODE_POLL_INTERVAL: Duration = Duration::from_millis(100);
const FFMPEG_CRF: &str = "23";
const FFMPEG_AUDIO_BITRATE: &str = "128k";
const HLS_SEGMENT_SECS: &str = "6";
#[derive(Clone, Debug)]
pub enum TranscodeStatus {
Ready,
Failed(String),
}
pub struct TranscodeManager {
pub transcode_dir: PathBuf,
cleanup_ttl_hours: Arc<AtomicU32>,
active: Arc<Mutex<HashMap<String, watch::Sender<Option<TranscodeStatus>>>>>,
}
impl TranscodeManager {
pub fn new(transcode_dir: PathBuf, cleanup_ttl_hours: u32) -> Arc<Self> {
let mgr = Arc::new(Self {
transcode_dir,
cleanup_ttl_hours: Arc::new(AtomicU32::new(cleanup_ttl_hours)),
active: Arc::new(Mutex::new(HashMap::new())),
});
// uses Weak to avoid keeping manager alive
let weak = Arc::downgrade(&mgr);
tokio::spawn(async move {
let mut interval = tokio::time::interval(CLEANUP_INTERVAL);
loop {
interval.tick().await;
match weak.upgrade() {
Some(m) => m.run_cleanup().await,
None => break,
}
}
});
mgr
}
pub fn set_cleanup_ttl(&self, hours: u32) {
self.cleanup_ttl_hours.store(hours, Ordering::Relaxed);
}
pub fn get_cleanup_ttl(&self) -> u32 {
self.cleanup_ttl_hours.load(Ordering::Relaxed)
}
pub async fn ensure_transcoded(&self, item_id: &str, src_path: &Path) -> DomainResult<()> {
let out_dir = self.transcode_dir.join(item_id);
let playlist = out_dir.join("playlist.m3u8");
if playlist.exists() {
return Ok(());
}
let mut rx = {
let mut map = self.active.lock().await;
if let Some(tx) = map.get(item_id) {
tx.subscribe()
} else {
let (tx, rx) = watch::channel::<Option<TranscodeStatus>>(None);
map.insert(item_id.to_string(), tx.clone());
let item_id_owned = item_id.to_string();
let src_owned = src_path.to_path_buf();
let out_dir_owned = out_dir.clone();
let playlist_owned = playlist.clone();
let active_ref = Arc::clone(&self.active);
tokio::spawn(async move {
let _ = tokio::fs::create_dir_all(&out_dir_owned).await;
let status =
do_transcode(&src_owned, &out_dir_owned, &playlist_owned).await;
if matches!(status, TranscodeStatus::Ready) {
info!("transcode ready: {}", item_id_owned);
} else if let TranscodeStatus::Failed(ref e) = status {
error!("transcode failed for {}: {}", item_id_owned, e);
}
let _ = tx.send(Some(status));
active_ref.lock().await.remove(&item_id_owned);
});
rx
}
};
loop {
rx.changed().await.map_err(|_| {
DomainError::InfrastructureError(
"transcode task dropped unexpectedly".into(),
)
})?;
if let Some(status) = &*rx.borrow() {
return match status {
TranscodeStatus::Ready => Ok(()),
TranscodeStatus::Failed(e) => Err(DomainError::InfrastructureError(
format!("transcode failed: {}", e),
)),
};
}
}
}
pub async fn clear_cache(&self) -> std::io::Result<()> {
if self.transcode_dir.exists() {
tokio::fs::remove_dir_all(&self.transcode_dir).await?;
}
tokio::fs::create_dir_all(&self.transcode_dir).await
}
pub async fn cache_stats(&self) -> (u64, usize) {
let mut total_bytes = 0u64;
let mut item_count = 0usize;
let Ok(mut entries) = tokio::fs::read_dir(&self.transcode_dir).await else {
return (0, 0);
};
while let Ok(Some(entry)) = entries.next_entry().await {
if !entry.path().is_dir() {
continue;
}
item_count += 1;
if let Ok(mut sub) = tokio::fs::read_dir(entry.path()).await {
while let Ok(Some(f)) = sub.next_entry().await {
if let Ok(meta) = f.metadata().await {
total_bytes += meta.len();
}
}
}
}
(total_bytes, item_count)
}
async fn run_cleanup(&self) {
let ttl_hours = self.cleanup_ttl_hours.load(Ordering::Relaxed) as u64;
let ttl = Duration::from_secs(ttl_hours * SECS_PER_HOUR);
let now = std::time::SystemTime::now();
let Ok(mut entries) = tokio::fs::read_dir(&self.transcode_dir).await else {
return;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if !path.is_dir() {
continue;
}
let playlist = path.join("playlist.m3u8");
if let Ok(meta) = tokio::fs::metadata(&playlist).await
&& let Ok(modified) = meta.modified()
&& let Ok(age) = now.duration_since(modified)
&& age > ttl
{
warn!("cleanup: removing stale transcode {:?}", path);
let _ = tokio::fs::remove_dir_all(&path).await;
}
}
}
}
async fn do_transcode(src: &Path, out_dir: &Path, playlist: &Path) -> TranscodeStatus {
let segment_pattern = out_dir.join("seg%05d.ts");
let mut child = match tokio::process::Command::new("ffmpeg")
.args([
"-i",
src.to_str().unwrap_or(""),
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
FFMPEG_CRF,
"-c:a",
"aac",
"-b:a",
FFMPEG_AUDIO_BITRATE,
"-hls_time",
HLS_SEGMENT_SECS,
"-hls_list_size",
"0",
"-hls_flags",
"independent_segments",
"-hls_segment_filename",
segment_pattern.to_str().unwrap_or(""),
playlist.to_str().unwrap_or(""),
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(c) => c,
Err(e) => return TranscodeStatus::Failed(format!("ffmpeg spawn error: {}", e)),
};
let start = Instant::now();
let timeout = TRANSCODE_TIMEOUT;
loop {
if playlist.exists() {
return TranscodeStatus::Ready;
}
if start.elapsed() > timeout {
let _ = child.kill().await;
return TranscodeStatus::Failed(
"timeout waiting for transcode to start".into(),
);
}
match child.try_wait() {
Ok(Some(status)) => {
return if playlist.exists() {
TranscodeStatus::Ready
} else if status.success() {
TranscodeStatus::Failed(
"ffmpeg exited but produced no playlist".into(),
)
} else {
TranscodeStatus::Failed(
"ffmpeg exited with non-zero status".into(),
)
};
}
Err(e) => return TranscodeStatus::Failed(e.to_string()),
Ok(None) => {}
}
tokio::time::sleep(TRANSCODE_POLL_INTERVAL).await;
}
}

View File

@@ -1,16 +0,0 @@
[package]
name = "adapter-sqlite"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
adapter-common = { workspace = true }
infra-wiring = { workspace = true, features = ["sqlite"] }
async-trait = { workspace = true }
sqlx = { workspace = true, features = ["sqlite"] }
chrono = { workspace = true }
uuid = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }

View File

@@ -1,83 +0,0 @@
use async_trait::async_trait;
use chrono::Utc;
use sqlx::SqlitePool;
use uuid::Uuid;
use adapter_common::{map_sqlx_error, parse_dt, parse_uuid};
use domain::{
ports::activity::{ActivityLogCommand, ActivityLogQuery},
ActivityEvent, ActivityEventId, ChannelId, DomainResult,
};
pub struct SqliteActivityLog {
pool: SqlitePool,
}
impl SqliteActivityLog {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait]
impl ActivityLogCommand for SqliteActivityLog {
async fn log(
&self,
event_type: &str,
detail: &str,
channel_id: Option<ChannelId>,
) -> DomainResult<()> {
let id = Uuid::new_v4().to_string();
let timestamp = Utc::now().to_rfc3339();
let channel_id_str = channel_id.map(|id| id.value().to_string());
sqlx::query(
"INSERT INTO activity_log (id, timestamp, event_type, detail, channel_id) VALUES (?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(&timestamp)
.bind(event_type)
.bind(detail)
.bind(&channel_id_str)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
}
#[async_trait]
impl ActivityLogQuery for SqliteActivityLog {
async fn recent(&self, limit: u32) -> DomainResult<Vec<ActivityEvent>> {
let rows: Vec<(String, String, String, String, Option<String>)> = sqlx::query_as(
"SELECT id, timestamp, event_type, detail, channel_id FROM activity_log ORDER BY timestamp DESC LIMIT ?",
)
.bind(limit)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
let mut events = Vec::with_capacity(rows.len());
for (id_str, ts_str, event_type, detail, channel_id_str) in rows {
let Ok(id) = parse_uuid(&id_str, "activity id") else {
continue;
};
let Ok(timestamp) = parse_dt(&ts_str) else {
continue;
};
let channel_id = channel_id_str
.and_then(|s| Uuid::parse_str(&s).ok())
.map(ChannelId::from_uuid);
events.push(ActivityEvent::from_persistence(
ActivityEventId::from_uuid(id),
timestamp,
event_type,
detail,
channel_id,
));
}
Ok(events)
}
}

View File

@@ -1,345 +0,0 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::{Row, SqlitePool};
use uuid::Uuid;
use adapter_common::{
map_sqlx_error, parse_dt, parse_enum_or_default, parse_rotation_policy, parse_schedule_config,
parse_uuid, serialize_enum_as_string,
};
use domain::{
ports::channel::{ChannelCommand, ChannelQuery},
AccessMode, Channel, ChannelConfigSnapshot, ChannelId, ChannelRow as DomainChannelRow,
DomainError, DomainResult, LogoPosition, MediaFilter, ScheduleConfig, SnapshotId, UserId,
};
pub struct SqliteChannelRepository {
pool: SqlitePool,
}
impl SqliteChannelRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
const SELECT_COLS: &str = "id, owner_id, name, description, timezone, schedule_config, rotation_policy, auto_schedule, access_mode, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, webhook_headers, gap_filler, created_at, updated_at";
#[derive(Debug, sqlx::FromRow)]
struct ChannelRow {
id: String,
owner_id: String,
name: String,
description: Option<String>,
timezone: String,
schedule_config: String,
rotation_policy: String,
auto_schedule: i64,
access_mode: String,
logo: Option<String>,
logo_position: String,
logo_opacity: f32,
webhook_url: Option<String>,
webhook_poll_interval_secs: i64,
webhook_body_template: Option<String>,
webhook_headers: Option<String>,
gap_filler: Option<String>,
created_at: String,
updated_at: String,
}
impl ChannelRow {
fn into_channel(self) -> DomainResult<Channel> {
let id = ChannelId::from_uuid(parse_uuid(&self.id, "channel id")?);
let owner_id = UserId::from_uuid(parse_uuid(&self.owner_id, "owner id")?);
let schedule_config = parse_schedule_config(&self.schedule_config)?;
let rotation_policy = parse_rotation_policy(&self.rotation_policy)?;
let access_mode: AccessMode = parse_enum_or_default(self.access_mode);
let logo_position: LogoPosition = parse_enum_or_default(self.logo_position);
let gap_filler: Option<MediaFilter> = self
.gap_filler
.as_deref()
.and_then(|s| serde_json::from_str(s).ok());
Ok(Channel::from_persistence(DomainChannelRow {
id,
owner_id,
name: self.name,
description: self.description,
timezone: self.timezone,
schedule_config,
rotation_policy,
auto_schedule: self.auto_schedule != 0,
access_mode,
logo: self.logo,
logo_position,
logo_opacity: self.logo_opacity,
webhook_url: self.webhook_url,
webhook_poll_interval_secs: self.webhook_poll_interval_secs as u32,
webhook_body_template: self.webhook_body_template,
webhook_headers: self.webhook_headers,
gap_filler,
created_at: parse_dt(&self.created_at)?,
updated_at: parse_dt(&self.updated_at)?,
}))
}
}
fn map_snapshot_row(
row: &sqlx::sqlite::SqliteRow,
channel_id: ChannelId,
) -> DomainResult<ChannelConfigSnapshot> {
let id_str: String = row.get("id");
let id = SnapshotId::from_uuid(parse_uuid(&id_str, "snapshot id")?);
let config_json: String = row.get("config_json");
let config = parse_schedule_config(&config_json)?;
let version_num: i64 = row.get("version_num");
let label: Option<String> = row.get("label");
let created_at_str: String = row.get("created_at");
let created_at: DateTime<Utc> = parse_dt(&created_at_str)?;
Ok(ChannelConfigSnapshot::from_persistence(
id,
channel_id,
config,
version_num,
label,
created_at,
))
}
#[async_trait]
impl ChannelCommand for SqliteChannelRepository {
async fn save(&self, channel: &Channel) -> DomainResult<()> {
let schedule_config = serde_json::to_string(channel.schedule_config())
.map_err(|e| DomainError::RepositoryError(format!("serialize schedule_config: {e}")))?;
let rotation_policy = serde_json::to_string(channel.rotation_policy())
.map_err(|e| DomainError::RepositoryError(format!("serialize rotation_policy: {e}")))?;
let access_mode = serialize_enum_as_string(channel.access_mode(), "public");
let logo_position = serialize_enum_as_string(channel.logo_position(), "top_right");
let gap_filler_json = channel
.gap_filler()
.map(|f| serde_json::to_string(f).unwrap_or_default());
sqlx::query(
r#"
INSERT INTO channels
(id, owner_id, name, description, timezone, schedule_config, rotation_policy,
auto_schedule, access_mode, logo, logo_position,
logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template,
webhook_headers, gap_filler, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
timezone = excluded.timezone,
schedule_config = excluded.schedule_config,
rotation_policy = excluded.rotation_policy,
auto_schedule = excluded.auto_schedule,
access_mode = excluded.access_mode,
logo = excluded.logo,
logo_position = excluded.logo_position,
logo_opacity = excluded.logo_opacity,
webhook_url = excluded.webhook_url,
webhook_poll_interval_secs = excluded.webhook_poll_interval_secs,
webhook_body_template = excluded.webhook_body_template,
webhook_headers = excluded.webhook_headers,
gap_filler = excluded.gap_filler,
updated_at = excluded.updated_at
"#,
)
.bind(channel.id().value().to_string())
.bind(channel.owner_id().value().to_string())
.bind(channel.name())
.bind(channel.description())
.bind(channel.timezone())
.bind(&schedule_config)
.bind(&rotation_policy)
.bind(channel.auto_schedule() as i64)
.bind(&access_mode)
.bind(channel.logo())
.bind(&logo_position)
.bind(channel.logo_opacity())
.bind(channel.webhook_url())
.bind(channel.webhook_poll_interval_secs() as i64)
.bind(channel.webhook_body_template())
.bind(channel.webhook_headers())
.bind(&gap_filler_json)
.bind(channel.created_at().to_rfc3339())
.bind(channel.updated_at().to_rfc3339())
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn delete(&self, id: ChannelId) -> DomainResult<()> {
sqlx::query("DELETE FROM channels WHERE id = ?")
.bind(id.value().to_string())
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn save_config_snapshot(
&self,
channel_id: ChannelId,
config: &ScheduleConfig,
label: Option<String>,
) -> DomainResult<ChannelConfigSnapshot> {
let id = Uuid::new_v4();
let now = Utc::now();
let config_json = serde_json::to_string(config)
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
let version_num: i64 = sqlx::query_scalar(
"SELECT COALESCE(MAX(version_num), 0) + 1 FROM channel_config_snapshots WHERE channel_id = ?",
)
.bind(channel_id.value().to_string())
.fetch_one(&mut *tx)
.await
.map_err(map_sqlx_error)?;
sqlx::query(
"INSERT INTO channel_config_snapshots (id, channel_id, config_json, version_num, label, created_at)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(id.to_string())
.bind(channel_id.value().to_string())
.bind(&config_json)
.bind(version_num)
.bind(&label)
.bind(now.to_rfc3339())
.execute(&mut *tx)
.await
.map_err(map_sqlx_error)?;
tx.commit().await.map_err(map_sqlx_error)?;
Ok(ChannelConfigSnapshot::from_persistence(
SnapshotId::from_uuid(id),
channel_id,
config.clone(),
version_num,
label,
now,
))
}
async fn patch_config_snapshot_label(
&self,
channel_id: ChannelId,
snapshot_id: SnapshotId,
label: Option<String>,
) -> DomainResult<Option<ChannelConfigSnapshot>> {
let updated = sqlx::query(
"UPDATE channel_config_snapshots SET label = ? WHERE id = ? AND channel_id = ? RETURNING id",
)
.bind(&label)
.bind(snapshot_id.value().to_string())
.bind(channel_id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
if updated.is_none() {
return Ok(None);
}
self.get_config_snapshot(channel_id, snapshot_id).await
}
}
#[async_trait]
impl ChannelQuery for SqliteChannelRepository {
async fn find_by_id(&self, id: ChannelId) -> DomainResult<Option<Channel>> {
let sql = format!("SELECT {SELECT_COLS} FROM channels WHERE id = ?");
let row: Option<ChannelRow> = sqlx::query_as(&sql)
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
row.map(ChannelRow::into_channel).transpose()
}
async fn find_by_owner(&self, owner_id: UserId) -> DomainResult<Vec<Channel>> {
let sql = format!(
"SELECT {SELECT_COLS} FROM channels WHERE owner_id = ? ORDER BY created_at ASC"
);
let rows: Vec<ChannelRow> = sqlx::query_as(&sql)
.bind(owner_id.value().to_string())
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
rows.into_iter().map(ChannelRow::into_channel).collect()
}
async fn find_all(&self) -> DomainResult<Vec<Channel>> {
let sql = format!("SELECT {SELECT_COLS} FROM channels ORDER BY created_at ASC");
let rows: Vec<ChannelRow> = sqlx::query_as(&sql)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
rows.into_iter().map(ChannelRow::into_channel).collect()
}
async fn find_auto_schedule_enabled(&self) -> DomainResult<Vec<Channel>> {
let sql = format!(
"SELECT {SELECT_COLS} FROM channels WHERE auto_schedule = 1 ORDER BY created_at ASC"
);
let rows: Vec<ChannelRow> = sqlx::query_as(&sql)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
rows.into_iter().map(ChannelRow::into_channel).collect()
}
async fn list_config_snapshots(
&self,
channel_id: ChannelId,
) -> DomainResult<Vec<ChannelConfigSnapshot>> {
let rows = sqlx::query(
"SELECT id, config_json, version_num, label, created_at
FROM channel_config_snapshots WHERE channel_id = ?
ORDER BY version_num DESC",
)
.bind(channel_id.value().to_string())
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
rows.iter()
.map(|row| map_snapshot_row(row, channel_id))
.collect()
}
async fn get_config_snapshot(
&self,
channel_id: ChannelId,
snapshot_id: SnapshotId,
) -> DomainResult<Option<ChannelConfigSnapshot>> {
let row = sqlx::query(
"SELECT id, config_json, version_num, label, created_at
FROM channel_config_snapshots WHERE id = ? AND channel_id = ?",
)
.bind(snapshot_id.value().to_string())
.bind(channel_id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
match row {
None => Ok(None),
Some(row) => Ok(Some(map_snapshot_row(&row, channel_id)?)),
}
}
}

View File

@@ -1,11 +0,0 @@
pub mod activity;
pub mod channel;
pub mod library;
pub mod provider_config;
pub mod schedule;
pub mod settings;
pub mod transcode;
pub mod user;
pub mod wire;
pub use wire::{wire, SqliteWireOutput};

View File

@@ -1,558 +0,0 @@
use async_trait::async_trait;
use sqlx::SqlitePool;
use adapter_common::{content_type_str, parse_content_type, parse_enum_or_default, parse_genres_blob, serialize_enum_as_string};
use domain::{
ports::library::{LibraryCommand, LibraryQuery},
ContentType, DomainError, DomainResult, LibraryCollection,
LibrarySearchFilter, LibrarySyncLogEntry,
LibrarySyncResult, MediaItem, MediaItemRow as DomainMediaItemRow,
MediaRole, SeasonSummary, ShowSummary,
};
pub struct SqliteLibraryRepository {
pool: SqlitePool,
}
impl SqliteLibraryRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
struct LibraryItemRow {
id: String,
provider_id: String,
external_id: String,
title: String,
content_type: String,
duration_secs: i64,
series_name: Option<String>,
season_number: Option<i64>,
episode_number: Option<i64>,
year: Option<i64>,
genres: String,
tags: String,
collection_id: Option<String>,
collection_name: Option<String>,
collection_type: Option<String>,
thumbnail_url: Option<String>,
synced_at: String,
chapters: Option<String>,
role: Option<String>,
}
impl LibraryItemRow {
fn into_media_item(self) -> MediaItem {
let role: MediaRole = self
.role
.map(parse_enum_or_default)
.unwrap_or_default();
MediaItem::from_persistence(DomainMediaItemRow {
id: domain::MediaItemId::new(&self.id),
provider_id: self.provider_id,
external_id: self.external_id,
title: self.title,
content_type: parse_content_type(&self.content_type),
duration_secs: self.duration_secs as u32,
description: None,
series_name: self.series_name,
season_number: self.season_number.map(|n| n as u32),
episode_number: self.episode_number.map(|n| n as u32),
year: self.year.map(|n| n as u16),
genres: serde_json::from_str(&self.genres).unwrap_or_default(),
tags: serde_json::from_str(&self.tags).unwrap_or_default(),
collection_id: self.collection_id,
collection_name: self.collection_name,
collection_type: self.collection_type,
thumbnail_url: self.thumbnail_url,
synced_at: Some(self.synced_at),
role,
chapters: self
.chapters
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default(),
})
}
}
#[derive(sqlx::FromRow)]
struct SyncLogRow {
id: i64,
provider_id: String,
started_at: String,
finished_at: Option<String>,
items_found: i64,
status: String,
error_msg: Option<String>,
}
#[derive(sqlx::FromRow)]
struct ShowSummaryRow {
series_name: String,
episode_count: i64,
season_count: i64,
thumbnail_url: Option<String>,
genres_blob: String,
}
#[derive(sqlx::FromRow)]
struct SeasonSummaryRow {
season_number: i64,
episode_count: i64,
thumbnail_url: Option<String>,
}
#[async_trait]
impl LibraryCommand for SqliteLibraryRepository {
async fn upsert_items(&self, _provider_id: &str, items: Vec<MediaItem>) -> DomainResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
for item in items {
let chapters_json = if item.chapters().is_empty() {
None
} else {
Some(serde_json::to_string(item.chapters()).unwrap_or_default())
};
let role_str = serialize_enum_as_string(item.role(), "program");
sqlx::query(
"INSERT OR REPLACE INTO library_items
(id, provider_id, external_id, title, content_type, duration_secs,
series_name, season_number, episode_number, year, genres, tags,
collection_id, collection_name, collection_type, thumbnail_url, synced_at, chapters, role)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
)
.bind(item.id().value())
.bind(item.provider_id())
.bind(item.external_id())
.bind(item.title())
.bind(content_type_str(item.content_type()))
.bind(item.duration_secs() as i64)
.bind(item.series_name())
.bind(item.season_number().map(|n| n as i64))
.bind(item.episode_number().map(|n| n as i64))
.bind(item.year().map(|n| n as i64))
.bind(serde_json::to_string(item.genres()).unwrap_or_default())
.bind(serde_json::to_string(item.tags()).unwrap_or_default())
.bind(item.collection_id())
.bind(item.collection_name())
.bind(item.collection_type())
.bind(item.thumbnail_url())
.bind(item.synced_at().unwrap_or(""))
.bind(&chapters_json)
.bind(&role_str)
.execute(&mut *tx)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
}
tx.commit()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
async fn update_role(&self, item_id: &str, role: MediaRole) -> DomainResult<()> {
let role_str = serialize_enum_as_string(&role, "program");
let rows = sqlx::query("UPDATE library_items SET role = ? WHERE id = ?")
.bind(&role_str)
.bind(item_id)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
if rows.rows_affected() == 0 {
return Err(DomainError::NotFound(format!(
"Library item {item_id} not found"
)));
}
Ok(())
}
async fn clear_provider(&self, provider_id: &str) -> DomainResult<()> {
sqlx::query("DELETE FROM library_items WHERE provider_id = ?")
.bind(provider_id)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
async fn log_sync_start(&self, provider_id: &str) -> DomainResult<i64> {
let now = chrono::Utc::now().to_rfc3339();
let id = sqlx::query_scalar::<_, i64>(
"INSERT INTO library_sync_log (provider_id, started_at, status)
VALUES (?, ?, 'running') RETURNING id",
)
.bind(provider_id)
.bind(&now)
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(id)
}
async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()> {
let now = chrono::Utc::now().to_rfc3339();
let status = if result.error().is_none() {
"done"
} else {
"error"
};
sqlx::query(
"UPDATE library_sync_log
SET finished_at = ?, items_found = ?, status = ?, error_msg = ?
WHERE id = ?",
)
.bind(&now)
.bind(result.items_found() as i64)
.bind(status)
.bind(result.error())
.bind(log_id)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
}
#[async_trait]
impl LibraryQuery for SqliteLibraryRepository {
async fn search(
&self,
filter: &LibrarySearchFilter,
) -> DomainResult<(Vec<MediaItem>, u32)> {
let mut conditions: Vec<String> = vec![];
if let Some(p) = filter.provider_id() {
conditions.push(format!("provider_id = '{}'", p.replace('\'', "''")));
}
if let Some(ct) = filter.content_type() {
conditions.push(format!("content_type = '{}'", content_type_str(ct)));
}
if let Some(st) = filter.search_term() {
conditions.push(format!("title LIKE '%{}%'", st.replace('\'', "''")));
}
if let Some(cid) = filter.collection_id() {
conditions.push(format!("collection_id = '{}'", cid.replace('\'', "''")));
}
if let Some(decade) = filter.decade() {
let end = decade + 10;
conditions.push(format!("year >= {} AND year < {}", decade, end));
}
if let Some(min) = filter.min_duration_secs() {
conditions.push(format!("duration_secs >= {}", min));
}
if let Some(max) = filter.max_duration_secs() {
conditions.push(format!("duration_secs <= {}", max));
}
if !filter.series_names().is_empty() {
let quoted: Vec<String> = filter
.series_names()
.iter()
.map(|s| format!("'{}'", s.replace('\'', "''")))
.collect();
conditions.push(format!("series_name IN ({})", quoted.join(",")));
}
if !filter.genres().is_empty() {
let genre_conditions: Vec<String> = filter
.genres()
.iter()
.map(|g| {
format!(
"EXISTS (SELECT 1 FROM json_each(library_items.genres) WHERE value = '{}')",
g.replace('\'', "''")
)
})
.collect();
conditions.push(format!("({})", genre_conditions.join(" OR ")));
}
if !filter.tags().is_empty() {
let tag_conditions: Vec<String> = filter
.tags()
.iter()
.map(|t| {
format!(
"EXISTS (SELECT 1 FROM json_each(library_items.tags) WHERE LOWER(value) = LOWER('{}'))",
t.replace('\'', "''")
)
})
.collect();
conditions.push(format!("({})", tag_conditions.join(" OR ")));
}
if let Some(sn) = filter.season_number() {
conditions.push(format!("season_number = {}", sn));
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
let count_sql = format!("SELECT COUNT(*) FROM library_items {}", where_clause);
let total: i64 = sqlx::query_scalar(&count_sql)
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let items_sql = format!(
"SELECT * FROM library_items {} ORDER BY title ASC LIMIT {} OFFSET {}",
where_clause,
filter.limit(),
filter.offset()
);
let rows = sqlx::query_as::<_, LibraryItemRow>(&items_sql)
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok((
rows.into_iter()
.map(LibraryItemRow::into_media_item)
.collect(),
total as u32,
))
}
async fn get_by_id(&self, id: &str) -> DomainResult<Option<MediaItem>> {
let row = sqlx::query_as::<_, LibraryItemRow>("SELECT * FROM library_items WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(row.map(LibraryItemRow::into_media_item))
}
async fn list_collections(
&self,
provider_id: Option<&str>,
) -> DomainResult<Vec<LibraryCollection>> {
let rows: Vec<(String, Option<String>, Option<String>)> = if let Some(p) = provider_id {
sqlx::query_as(
"SELECT DISTINCT collection_id, collection_name, collection_type
FROM library_items WHERE collection_id IS NOT NULL AND provider_id = ?
ORDER BY collection_name ASC",
)
.bind(p)
.fetch_all(&self.pool)
.await
} else {
sqlx::query_as(
"SELECT DISTINCT collection_id, collection_name, collection_type
FROM library_items WHERE collection_id IS NOT NULL
ORDER BY collection_name ASC",
)
.fetch_all(&self.pool)
.await
}
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(rows
.into_iter()
.map(|(id, name, ct)| {
LibraryCollection::from_persistence(id, name.unwrap_or_default(), ct)
})
.collect())
}
async fn list_series(&self, provider_id: Option<&str>) -> DomainResult<Vec<String>> {
let rows: Vec<(String,)> = if let Some(p) = provider_id {
sqlx::query_as(
"SELECT DISTINCT series_name FROM library_items
WHERE series_name IS NOT NULL AND provider_id = ? ORDER BY series_name ASC",
)
.bind(p)
.fetch_all(&self.pool)
.await
} else {
sqlx::query_as(
"SELECT DISTINCT series_name FROM library_items
WHERE series_name IS NOT NULL ORDER BY series_name ASC",
)
.fetch_all(&self.pool)
.await
}
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(rows.into_iter().map(|(s,)| s).collect())
}
async fn list_genres(
&self,
content_type: Option<&ContentType>,
provider_id: Option<&str>,
) -> DomainResult<Vec<String>> {
let sql = match (content_type, provider_id) {
(Some(ct), Some(p)) => format!(
"SELECT DISTINCT je.value FROM library_items li, json_each(li.genres) je
WHERE li.content_type = '{}' AND li.provider_id = '{}' ORDER BY je.value ASC",
content_type_str(ct),
p.replace('\'', "''")
),
(Some(ct), None) => format!(
"SELECT DISTINCT je.value FROM library_items li, json_each(li.genres) je
WHERE li.content_type = '{}' ORDER BY je.value ASC",
content_type_str(ct)
),
(None, Some(p)) => format!(
"SELECT DISTINCT je.value FROM library_items li, json_each(li.genres) je
WHERE li.provider_id = '{}' ORDER BY je.value ASC",
p.replace('\'', "''")
),
(None, None) => {
"SELECT DISTINCT je.value FROM library_items li, json_each(li.genres) je ORDER BY je.value ASC"
.to_string()
}
};
let rows: Vec<(String,)> = sqlx::query_as(&sql)
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(rows.into_iter().map(|(s,)| s).collect())
}
async fn latest_sync_status(&self) -> DomainResult<Vec<LibrarySyncLogEntry>> {
let rows = sqlx::query_as::<_, SyncLogRow>(
"SELECT * FROM library_sync_log
WHERE id IN (
SELECT MAX(id) FROM library_sync_log GROUP BY provider_id
)
ORDER BY started_at DESC",
)
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(rows
.into_iter()
.map(|r| {
LibrarySyncLogEntry::from_persistence(
r.id,
r.provider_id,
r.started_at,
r.finished_at,
r.items_found as u32,
r.status,
r.error_msg,
)
})
.collect())
}
async fn is_sync_running(&self, provider_id: &str) -> DomainResult<bool> {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM library_sync_log WHERE provider_id = ? AND status = 'running'",
)
.bind(provider_id)
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(count > 0)
}
async fn list_shows(
&self,
provider_id: Option<&str>,
search_term: Option<&str>,
genres: &[String],
) -> DomainResult<Vec<ShowSummary>> {
let mut conditions = vec![
"content_type = 'episode'".to_string(),
"series_name IS NOT NULL".to_string(),
];
if let Some(p) = provider_id {
conditions.push(format!("provider_id = '{}'", p.replace('\'', "''")));
}
if let Some(st) = search_term {
let escaped = st.replace('\'', "''");
conditions.push(format!(
"(title LIKE '%{escaped}%' OR series_name LIKE '%{escaped}%')"
));
}
if !genres.is_empty() {
let genre_conditions: Vec<String> = genres
.iter()
.map(|g| {
format!(
"EXISTS (SELECT 1 FROM json_each(library_items.genres) WHERE value = '{}')",
g.replace('\'', "''")
)
})
.collect();
conditions.push(format!("({})", genre_conditions.join(" OR ")));
}
let where_clause = format!("WHERE {}", conditions.join(" AND "));
let sql = format!(
"SELECT series_name, COUNT(*) AS episode_count, \
COUNT(DISTINCT season_number) AS season_count, \
MAX(thumbnail_url) AS thumbnail_url, \
GROUP_CONCAT(genres, ',') AS genres_blob \
FROM library_items {} GROUP BY series_name ORDER BY series_name ASC",
where_clause
);
let rows = sqlx::query_as::<_, ShowSummaryRow>(&sql)
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(rows
.into_iter()
.map(|r| {
ShowSummary::from_persistence(
r.series_name,
r.episode_count as u32,
r.season_count as u32,
r.thumbnail_url,
parse_genres_blob(&r.genres_blob),
)
})
.collect())
}
async fn list_seasons(
&self,
series_name: &str,
provider_id: Option<&str>,
) -> DomainResult<Vec<SeasonSummary>> {
let mut conditions = vec![
format!("series_name = '{}'", series_name.replace('\'', "''")),
"content_type = 'episode'".to_string(),
];
if let Some(p) = provider_id {
conditions.push(format!("provider_id = '{}'", p.replace('\'', "''")));
}
let where_clause = format!("WHERE {}", conditions.join(" AND "));
let sql = format!(
"SELECT season_number, COUNT(*) AS episode_count, \
MAX(thumbnail_url) AS thumbnail_url \
FROM library_items {} GROUP BY season_number ORDER BY season_number ASC",
where_clause
);
let rows = sqlx::query_as::<_, SeasonSummaryRow>(&sql)
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(rows
.into_iter()
.map(|r| {
SeasonSummary::from_persistence(
r.season_number as u32,
r.episode_count as u32,
r.thumbnail_url,
)
})
.collect())
}
}

View File

@@ -1,90 +0,0 @@
use async_trait::async_trait;
use sqlx::SqlitePool;
use adapter_common::map_sqlx_error;
use domain::{
ports::provider_config::{ProviderConfigCommand, ProviderConfigQuery},
DomainResult, ProviderConfigRow,
};
pub struct SqliteProviderConfig {
pool: SqlitePool,
}
impl SqliteProviderConfig {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait]
impl ProviderConfigCommand for SqliteProviderConfig {
async fn upsert(&self, row: &ProviderConfigRow) -> DomainResult<()> {
sqlx::query(
r#"INSERT INTO provider_configs (id, provider_type, config_json, enabled, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
provider_type = excluded.provider_type,
config_json = excluded.config_json,
enabled = excluded.enabled,
updated_at = excluded.updated_at"#,
)
.bind(row.id())
.bind(row.provider_type())
.bind(row.config_json())
.bind(row.enabled() as i64)
.bind(row.updated_at())
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn delete(&self, id: &str) -> DomainResult<()> {
sqlx::query("DELETE FROM provider_configs WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
}
#[async_trait]
impl ProviderConfigQuery for SqliteProviderConfig {
async fn get_all(&self) -> DomainResult<Vec<ProviderConfigRow>> {
let rows: Vec<(String, String, String, i64, String)> = sqlx::query_as(
"SELECT id, provider_type, config_json, enabled, updated_at FROM provider_configs",
)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|(id, provider_type, config_json, enabled, updated_at)| {
ProviderConfigRow::from_persistence(
id,
provider_type,
config_json,
enabled != 0,
updated_at,
)
})
.collect())
}
async fn get_by_id(&self, id: &str) -> DomainResult<Option<ProviderConfigRow>> {
let row: Option<(String, String, String, i64, String)> = sqlx::query_as(
"SELECT id, provider_type, config_json, enabled, updated_at FROM provider_configs WHERE id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|(id, provider_type, config_json, enabled, updated_at)| {
ProviderConfigRow::from_persistence(id, provider_type, config_json, enabled != 0, updated_at)
}))
}
}

View File

@@ -1,348 +0,0 @@
use std::collections::HashMap;
use async_trait::async_trait;
use sqlx::SqlitePool;
use adapter_common::{map_sqlx_error, parse_dt, parse_json, parse_uuid};
use domain::{
ports::schedule::{ScheduleCommand, ScheduleQuery},
BlockId, ChannelId, DomainError, DomainResult, GeneratedSchedule, MediaItem, MediaItemId,
PlaybackRecord, PlaybackRecordId, ScheduleId, ScheduledSlot, SlotId,
};
pub struct SqliteScheduleRepository {
pool: SqlitePool,
}
impl SqliteScheduleRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[derive(Debug, sqlx::FromRow)]
struct ScheduleRow {
id: String,
channel_id: String,
valid_from: String,
valid_until: String,
generation: i64,
}
#[derive(Debug, sqlx::FromRow)]
struct SlotRow {
id: String,
_schedule_id: String,
start_at: String,
end_at: String,
item: String,
source_block_id: String,
}
#[derive(Debug, sqlx::FromRow)]
struct LastSlotRow {
source_block_id: String,
item: String,
}
#[derive(Debug, sqlx::FromRow)]
struct PlaybackRecordRow {
id: String,
channel_id: String,
item_id: String,
played_at: String,
generation: i64,
}
fn map_slot_row(row: SlotRow) -> DomainResult<ScheduledSlot> {
let id = SlotId::from_uuid(parse_uuid(&row.id, "slot id")?);
let source_block_id = BlockId::from_uuid(parse_uuid(&row.source_block_id, "block id")?);
let item: MediaItem = parse_json(&row.item, "slot item")?;
Ok(ScheduledSlot::from_persistence(
id,
parse_dt(&row.start_at)?,
parse_dt(&row.end_at)?,
item,
source_block_id,
))
}
fn map_schedule(row: ScheduleRow, slot_rows: Vec<SlotRow>) -> DomainResult<GeneratedSchedule> {
let id = ScheduleId::from_uuid(parse_uuid(&row.id, "schedule id")?);
let channel_id = ChannelId::from_uuid(parse_uuid(&row.channel_id, "channel id")?);
let slots: Result<Vec<ScheduledSlot>, _> = slot_rows.into_iter().map(map_slot_row).collect();
Ok(GeneratedSchedule::from_persistence(
id,
channel_id,
parse_dt(&row.valid_from)?,
parse_dt(&row.valid_until)?,
row.generation as u32,
slots?,
))
}
fn map_playback_row(row: PlaybackRecordRow) -> DomainResult<PlaybackRecord> {
let id = PlaybackRecordId::from_uuid(parse_uuid(&row.id, "playback record id")?);
let channel_id = ChannelId::from_uuid(parse_uuid(&row.channel_id, "channel id")?);
Ok(PlaybackRecord::from_persistence(
id,
channel_id,
MediaItemId::new(row.item_id),
parse_dt(&row.played_at)?,
row.generation as u32,
))
}
impl SqliteScheduleRepository {
async fn fetch_slots(&self, schedule_id: &str) -> DomainResult<Vec<SlotRow>> {
sqlx::query_as(
"SELECT id, schedule_id, start_at, end_at, item, source_block_id \
FROM scheduled_slots WHERE schedule_id = ? ORDER BY start_at",
)
.bind(schedule_id)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)
}
}
#[async_trait]
impl ScheduleCommand for SqliteScheduleRepository {
async fn save(&self, schedule: &GeneratedSchedule) -> DomainResult<()> {
sqlx::query(
r#"
INSERT INTO generated_schedules (id, channel_id, valid_from, valid_until, generation)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
valid_from = excluded.valid_from,
valid_until = excluded.valid_until,
generation = excluded.generation
"#,
)
.bind(schedule.id().value().to_string())
.bind(schedule.channel_id().value().to_string())
.bind(schedule.valid_from().to_rfc3339())
.bind(schedule.valid_until().to_rfc3339())
.bind(schedule.generation() as i64)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
sqlx::query("DELETE FROM scheduled_slots WHERE schedule_id = ?")
.bind(schedule.id().value().to_string())
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
for slot in schedule.slots() {
let item_json = serde_json::to_string(slot.item())
.map_err(|e| DomainError::RepositoryError(format!("serialize slot item: {e}")))?;
sqlx::query(
"INSERT INTO scheduled_slots (id, schedule_id, start_at, end_at, item, source_block_id)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(slot.id().value().to_string())
.bind(schedule.id().value().to_string())
.bind(slot.start_at().to_rfc3339())
.bind(slot.end_at().to_rfc3339())
.bind(&item_json)
.bind(slot.source_block_id().value().to_string())
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
}
Ok(())
}
async fn save_playback_record(&self, record: &PlaybackRecord) -> DomainResult<()> {
sqlx::query(
r#"
INSERT INTO playback_records (id, channel_id, item_id, played_at, generation)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
"#,
)
.bind(record.id().to_string())
.bind(record.channel_id().value().to_string())
.bind(record.item_id().value())
.bind(record.played_at().to_rfc3339())
.bind(record.generation() as i64)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn delete_schedules_after(
&self,
channel_id: ChannelId,
target_generation: u32,
) -> DomainResult<()> {
let ch = channel_id.value().to_string();
let target_gen = target_generation as i64;
sqlx::query("DELETE FROM playback_records WHERE channel_id = ? AND generation > ?")
.bind(&ch)
.bind(target_gen)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
sqlx::query("DELETE FROM generated_schedules WHERE channel_id = ? AND generation > ?")
.bind(&ch)
.bind(target_gen)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
}
#[async_trait]
impl ScheduleQuery for SqliteScheduleRepository {
async fn find_active(
&self,
channel_id: ChannelId,
at: chrono::DateTime<chrono::Utc>,
) -> DomainResult<Option<GeneratedSchedule>> {
let at_str = at.to_rfc3339();
let row: Option<ScheduleRow> = sqlx::query_as(
"SELECT id, channel_id, valid_from, valid_until, generation \
FROM generated_schedules \
WHERE channel_id = ? AND valid_from <= ? AND valid_until > ? \
LIMIT 1",
)
.bind(channel_id.value().to_string())
.bind(&at_str)
.bind(&at_str)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
match row {
None => Ok(None),
Some(r) => {
let slots = self.fetch_slots(&r.id).await?;
Some(map_schedule(r, slots)).transpose()
}
}
}
async fn find_latest(&self, channel_id: ChannelId) -> DomainResult<Option<GeneratedSchedule>> {
let row: Option<ScheduleRow> = sqlx::query_as(
"SELECT id, channel_id, valid_from, valid_until, generation \
FROM generated_schedules \
WHERE channel_id = ? ORDER BY valid_from DESC LIMIT 1",
)
.bind(channel_id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
match row {
None => Ok(None),
Some(r) => {
let slots = self.fetch_slots(&r.id).await?;
Some(map_schedule(r, slots)).transpose()
}
}
}
async fn find_playback_history(
&self,
channel_id: ChannelId,
) -> DomainResult<Vec<PlaybackRecord>> {
let rows: Vec<PlaybackRecordRow> = sqlx::query_as(
"SELECT id, channel_id, item_id, played_at, generation \
FROM playback_records WHERE channel_id = ? ORDER BY played_at DESC",
)
.bind(channel_id.value().to_string())
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
rows.into_iter().map(map_playback_row).collect()
}
async fn find_last_slot_per_block(
&self,
channel_id: ChannelId,
) -> DomainResult<HashMap<BlockId, MediaItemId>> {
let channel_id_str = channel_id.value().to_string();
let rows: Vec<LastSlotRow> = sqlx::query_as(
"SELECT ss.source_block_id, ss.item \
FROM scheduled_slots ss \
INNER JOIN generated_schedules gs ON gs.id = ss.schedule_id \
WHERE gs.channel_id = ? \
AND ss.start_at = ( \
SELECT MAX(ss2.start_at) \
FROM scheduled_slots ss2 \
INNER JOIN generated_schedules gs2 ON gs2.id = ss2.schedule_id \
WHERE ss2.source_block_id = ss.source_block_id \
AND gs2.channel_id = ? \
)",
)
.bind(&channel_id_str)
.bind(&channel_id_str)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
let mut map = HashMap::new();
for row in rows {
let block_id =
BlockId::from_uuid(parse_uuid(&row.source_block_id, "block id")?);
let item: MediaItem = parse_json(&row.item, "slot item")?;
map.insert(block_id, item.id().clone());
}
Ok(map)
}
async fn list_schedule_history(
&self,
channel_id: ChannelId,
) -> DomainResult<Vec<GeneratedSchedule>> {
let rows: Vec<ScheduleRow> = sqlx::query_as(
"SELECT id, channel_id, valid_from, valid_until, generation \
FROM generated_schedules WHERE channel_id = ? ORDER BY generation DESC",
)
.bind(channel_id.value().to_string())
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
rows.into_iter()
.map(|r| map_schedule(r, vec![]))
.collect()
}
async fn get_schedule_by_id(
&self,
channel_id: ChannelId,
schedule_id: ScheduleId,
) -> DomainResult<Option<GeneratedSchedule>> {
let row: Option<ScheduleRow> = sqlx::query_as(
"SELECT id, channel_id, valid_from, valid_until, generation \
FROM generated_schedules WHERE id = ? AND channel_id = ?",
)
.bind(schedule_id.value().to_string())
.bind(channel_id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
match row {
None => Ok(None),
Some(r) => {
let slots = self.fetch_slots(&r.id).await?;
Some(map_schedule(r, slots)).transpose()
}
}
}
}

View File

@@ -1,45 +0,0 @@
use async_trait::async_trait;
use sqlx::SqlitePool;
use domain::{
ports::settings::AppSettingsRepository,
DomainError, DomainResult,
};
pub struct SqliteAppSettings {
pool: SqlitePool,
}
impl SqliteAppSettings {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait]
impl AppSettingsRepository for SqliteAppSettings {
async fn get(&self, key: &str) -> DomainResult<Option<String>> {
sqlx::query_scalar::<_, String>("SELECT value FROM app_settings WHERE key = ?")
.bind(key)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
async fn set(&self, key: &str, value: &str) -> DomainResult<()> {
sqlx::query("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)")
.bind(key)
.bind(value)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
async fn get_all(&self) -> DomainResult<Vec<(String, String)>> {
sqlx::query_as::<_, (String, String)>("SELECT key, value FROM app_settings ORDER BY key")
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
}

View File

@@ -1,38 +0,0 @@
use async_trait::async_trait;
use sqlx::SqlitePool;
use domain::{
ports::transcode::TranscodeSettingsRepository,
DomainError, DomainResult,
};
pub struct SqliteTranscodeSettings {
pool: SqlitePool,
}
impl SqliteTranscodeSettings {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait]
impl TranscodeSettingsRepository for SqliteTranscodeSettings {
async fn load_cleanup_ttl(&self) -> DomainResult<Option<u32>> {
let row: Option<(i64,)> =
sqlx::query_as("SELECT cleanup_ttl_hours FROM transcode_settings WHERE id = 1")
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(row.map(|(h,)| h as u32))
}
async fn save_cleanup_ttl(&self, hours: u32) -> DomainResult<()> {
sqlx::query("UPDATE transcode_settings SET cleanup_ttl_hours = ? WHERE id = 1")
.bind(hours as i64)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
}

View File

@@ -1,140 +0,0 @@
use async_trait::async_trait;
use sqlx::SqlitePool;
use adapter_common::{map_sqlx_error, parse_dt, parse_uuid};
use domain::{
ports::user::{UserCommand, UserQuery},
DomainError, DomainResult, Email, User, UserId,
};
pub struct SqliteUserRepository {
pool: SqlitePool,
}
impl SqliteUserRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[derive(Debug, sqlx::FromRow)]
struct UserRow {
id: String,
subject: String,
email: String,
password_hash: Option<String>,
is_admin: i64,
created_at: String,
}
impl UserRow {
fn into_user(self) -> DomainResult<User> {
let id = UserId::from_uuid(parse_uuid(&self.id, "user id")?);
let email = Email::new(&self.email)
.map_err(|e| DomainError::RepositoryError(format!("Invalid email: {e}")))?;
let created_at = parse_dt(&self.created_at)?;
Ok(User::from_persistence(
id,
self.subject,
email,
self.password_hash,
self.is_admin != 0,
created_at,
))
}
}
#[async_trait]
impl UserCommand for SqliteUserRepository {
async fn save(&self, user: &User) -> DomainResult<()> {
let id = user.id().value().to_string();
let created_at = user.created_at().to_rfc3339();
sqlx::query(
r#"
INSERT INTO users (id, subject, email, password_hash, is_admin, created_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
subject = excluded.subject,
email = excluded.email,
password_hash = excluded.password_hash,
is_admin = excluded.is_admin
"#,
)
.bind(&id)
.bind(user.subject())
.bind(user.email().as_ref())
.bind(user.password_hash())
.bind(user.is_admin() as i64)
.bind(&created_at)
.execute(&self.pool)
.await
.map_err(|e| {
let msg = e.to_string();
if msg.contains("UNIQUE constraint failed") || msg.contains("unique constraint") {
DomainError::UserAlreadyExists(user.email().as_ref().to_string())
} else {
map_sqlx_error(e)
}
})?;
Ok(())
}
async fn delete(&self, id: UserId) -> DomainResult<()> {
sqlx::query("DELETE FROM users WHERE id = ?")
.bind(id.value().to_string())
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
}
#[async_trait]
impl UserQuery for SqliteUserRepository {
async fn find_by_id(&self, id: UserId) -> DomainResult<Option<User>> {
let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, is_admin, created_at FROM users WHERE id = ?",
)
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
row.map(UserRow::into_user).transpose()
}
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>> {
let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, is_admin, created_at FROM users WHERE subject = ?",
)
.bind(subject)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
row.map(UserRow::into_user).transpose()
}
async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>> {
let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, is_admin, created_at FROM users WHERE email = ?",
)
.bind(email)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
row.map(UserRow::into_user).transpose()
}
async fn count_users(&self) -> DomainResult<u64> {
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(count as u64)
}
}

View File

@@ -1,70 +0,0 @@
use std::sync::Arc;
use sqlx::SqlitePool;
use domain::ports::{
activity::{ActivityLogCommand, ActivityLogQuery},
channel::{ChannelCommand, ChannelQuery},
library::{LibraryCommand, LibraryQuery},
provider_config::{ProviderConfigCommand, ProviderConfigQuery},
schedule::{ScheduleCommand, ScheduleQuery},
settings::AppSettingsRepository,
transcode::TranscodeSettingsRepository,
user::{UserCommand, UserQuery},
};
use crate::{
activity::SqliteActivityLog,
channel::SqliteChannelRepository,
library::SqliteLibraryRepository,
provider_config::SqliteProviderConfig,
schedule::SqliteScheduleRepository,
settings::SqliteAppSettings,
transcode::SqliteTranscodeSettings,
user::SqliteUserRepository,
};
pub struct SqliteWireOutput {
pub user_command: Arc<dyn UserCommand>,
pub user_query: Arc<dyn UserQuery>,
pub channel_command: Arc<dyn ChannelCommand>,
pub channel_query: Arc<dyn ChannelQuery>,
pub schedule_command: Arc<dyn ScheduleCommand>,
pub schedule_query: Arc<dyn ScheduleQuery>,
pub library_command: Arc<dyn LibraryCommand>,
pub library_query: Arc<dyn LibraryQuery>,
pub activity_command: Arc<dyn ActivityLogCommand>,
pub activity_query: Arc<dyn ActivityLogQuery>,
pub settings: Arc<dyn AppSettingsRepository>,
pub provider_config_command: Arc<dyn ProviderConfigCommand>,
pub provider_config_query: Arc<dyn ProviderConfigQuery>,
pub transcode_settings: Arc<dyn TranscodeSettingsRepository>,
}
pub fn wire(pool: SqlitePool) -> SqliteWireOutput {
let user = Arc::new(SqliteUserRepository::new(pool.clone()));
let channel = Arc::new(SqliteChannelRepository::new(pool.clone()));
let schedule = Arc::new(SqliteScheduleRepository::new(pool.clone()));
let library = Arc::new(SqliteLibraryRepository::new(pool.clone()));
let activity = Arc::new(SqliteActivityLog::new(pool.clone()));
let settings = Arc::new(SqliteAppSettings::new(pool.clone()));
let provider_config = Arc::new(SqliteProviderConfig::new(pool.clone()));
let transcode_settings = Arc::new(SqliteTranscodeSettings::new(pool));
SqliteWireOutput {
user_command: user.clone(),
user_query: user,
channel_command: channel.clone(),
channel_query: channel,
schedule_command: schedule.clone(),
schedule_query: schedule,
library_command: library.clone(),
library_query: library,
activity_command: activity.clone(),
activity_query: activity,
settings,
provider_config_command: provider_config.clone(),
provider_config_query: provider_config,
transcode_settings,
}
}

View File

@@ -1,12 +0,0 @@
[package]
name = "api-types"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
utoipa = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }

View File

@@ -1,35 +0,0 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SettingsResponse {
pub settings: std::collections::HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ActivityEventResponse {
pub id: Uuid,
pub timestamp: DateTime<Utc>,
pub event_type: String,
pub detail: String,
pub channel_id: Option<Uuid>,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct ActivityLogParams {
pub limit: Option<u32>,
}
impl From<domain::ActivityEvent> for ActivityEventResponse {
fn from(e: domain::ActivityEvent) -> Self {
Self {
id: e.id().value(),
timestamp: e.timestamp(),
event_type: e.event_type().to_string(),
detail: e.detail().to_string(),
channel_id: e.channel_id().map(|id| id.value()),
}
}
}

View File

@@ -1,51 +0,0 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct LoginRequest {
pub email: String,
pub password: String,
#[serde(default)]
pub remember_me: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct RegisterRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct RefreshRequest {
pub refresh_token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UserResponse {
pub id: Uuid,
pub email: String,
pub is_admin: bool,
pub created_at: DateTime<Utc>,
}
impl From<domain::User> for UserResponse {
fn from(user: domain::User) -> Self {
Self {
id: user.id().value(),
email: user.email().to_string(),
is_admin: user.is_admin(),
created_at: user.created_at(),
}
}
}

View File

@@ -1,114 +0,0 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::common::enum_to_string;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateChannelRequest {
pub name: String,
pub description: Option<String>,
pub timezone: String,
pub access_mode: Option<String>,
pub webhook_url: Option<String>,
pub webhook_poll_interval_secs: Option<u32>,
pub webhook_body_template: Option<String>,
pub webhook_headers: Option<String>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpdateChannelRequest {
pub name: Option<String>,
pub description: Option<String>,
pub timezone: Option<String>,
#[schema(value_type = Option<Object>)]
pub schedule_config: Option<domain::models::ScheduleConfigCompat>,
#[schema(value_type = Option<Object>)]
pub rotation_policy: Option<domain::RotationPolicy>,
pub auto_schedule: Option<bool>,
pub access_mode: Option<String>,
pub logo: Option<Option<String>>,
pub logo_position: Option<String>,
pub logo_opacity: Option<f32>,
pub webhook_url: Option<Option<String>>,
pub webhook_poll_interval_secs: Option<u32>,
pub webhook_body_template: Option<Option<String>>,
pub webhook_headers: Option<Option<String>>,
#[schema(value_type = Option<Option<Object>>)]
pub gap_filler: Option<Option<domain::MediaFilter>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ChannelResponse {
pub id: Uuid,
pub owner_id: Uuid,
pub name: String,
pub description: Option<String>,
pub timezone: String,
pub schedule_config: serde_json::Value,
pub rotation_policy: serde_json::Value,
pub auto_schedule: bool,
pub access_mode: String,
pub logo: Option<String>,
pub logo_position: String,
pub logo_opacity: f32,
pub webhook_url: Option<String>,
pub webhook_poll_interval_secs: u32,
pub webhook_body_template: Option<String>,
pub webhook_headers: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gap_filler: Option<serde_json::Value>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl From<domain::Channel> for ChannelResponse {
fn from(c: domain::Channel) -> Self {
Self {
id: c.id().value(),
owner_id: c.owner_id().value(),
name: c.name().to_string(),
description: c.description().map(|s| s.to_string()),
timezone: c.timezone().to_string(),
schedule_config: serde_json::to_value(c.schedule_config()).unwrap_or_default(),
rotation_policy: serde_json::to_value(c.rotation_policy()).unwrap_or_default(),
auto_schedule: c.auto_schedule(),
access_mode: enum_to_string(c.access_mode()),
logo: c.logo().map(|s| s.to_string()),
logo_position: enum_to_string(c.logo_position()),
logo_opacity: c.logo_opacity(),
webhook_url: c.webhook_url().map(|s| s.to_string()),
webhook_poll_interval_secs: c.webhook_poll_interval_secs(),
webhook_body_template: c.webhook_body_template().map(|s| s.to_string()),
webhook_headers: c.webhook_headers().map(|s| s.to_string()),
gap_filler: c.gap_filler().map(|f| serde_json::to_value(f).unwrap_or_default()),
created_at: c.created_at(),
updated_at: c.updated_at(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ConfigSnapshotResponse {
pub id: Uuid,
pub version_num: i64,
pub label: Option<String>,
pub created_at: DateTime<Utc>,
}
impl From<domain::ChannelConfigSnapshot> for ConfigSnapshotResponse {
fn from(s: domain::ChannelConfigSnapshot) -> Self {
Self {
id: s.id().value(),
version_num: s.version_num(),
label: s.label().map(|s| s.to_string()),
created_at: s.created_at(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PatchSnapshotRequest {
pub label: Option<String>,
}

View File

@@ -1,47 +0,0 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PaginatedResponse<T: ToSchema> {
pub items: Vec<T>,
pub total: u64,
}
impl<T: ToSchema> PaginatedResponse<T> {
pub fn new(items: Vec<T>, total: u64) -> Self {
Self { items, total }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ErrorResponse {
pub error: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}
impl ErrorResponse {
pub fn new(error: impl Into<String>) -> Self {
Self {
error: error.into(),
details: None,
}
}
pub fn with_details(error: impl Into<String>, details: impl Into<String>) -> Self {
Self {
error: error.into(),
details: Some(details.into()),
}
}
}
pub(crate) fn enum_to_string<T: Serialize>(val: &T) -> String {
serde_json::to_value(val)
.ok()
.and_then(|v| match v {
serde_json::Value::String(s) => Some(s),
_ => None,
})
.unwrap_or_default()
}

View File

@@ -1,41 +0,0 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ProviderCapabilitiesResponse {
pub collections: bool,
pub series: bool,
pub genres: bool,
pub tags: bool,
pub decade: bool,
pub search: bool,
pub rescan: bool,
}
impl From<domain::ports::ProviderCapabilities> for ProviderCapabilitiesResponse {
fn from(c: domain::ports::ProviderCapabilities) -> Self {
Self {
collections: c.collections,
series: c.series,
genres: c.genres,
tags: c.tags,
decade: c.decade,
search: c.search,
rescan: c.rescan,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ProviderInfo {
pub id: String,
pub capabilities: ProviderCapabilitiesResponse,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ConfigResponse {
pub allow_registration: bool,
pub providers: Vec<ProviderInfo>,
pub provider_capabilities: ProviderCapabilitiesResponse,
pub available_provider_types: Vec<String>,
}

View File

@@ -1,7 +0,0 @@
use serde::Deserialize;
use utoipa::{IntoParams, ToSchema};
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct IptvParams {
pub token: Option<String>,
}

View File

@@ -1,32 +0,0 @@
pub mod admin;
pub mod auth;
pub mod channels;
pub mod common;
pub mod config;
pub mod iptv;
pub mod library;
pub mod providers;
pub mod schedule;
pub mod transcode;
pub use admin::{ActivityEventResponse, ActivityLogParams, SettingsResponse};
pub use auth::{LoginRequest, RefreshRequest, RegisterRequest, TokenResponse, UserResponse};
pub use channels::{
ChannelResponse, ConfigSnapshotResponse, CreateChannelRequest, PatchSnapshotRequest,
UpdateChannelRequest,
};
pub use common::{ErrorResponse, PaginatedResponse};
pub use config::{ConfigResponse, ProviderCapabilitiesResponse, ProviderInfo};
pub use iptv::IptvParams;
pub use library::{
CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, ProviderParam,
SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry, UpdateRoleRequest,
};
pub use providers::{ProviderConfigRequest, ProviderConfigResponse};
pub use schedule::{
CurrentBroadcastResponse, MediaItemResponse, ScheduleHistoryEntry, ScheduleResponse,
SlotResponse,
};
pub use transcode::{
TranscodeSettingsResponse, TranscodeStatsResponse, UpdateTranscodeSettingsRequest,
};

View File

@@ -1,175 +0,0 @@
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::common::enum_to_string;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct LibraryItemResponse {
pub id: String,
pub provider_id: String,
pub external_id: String,
pub title: String,
pub content_type: String,
pub duration_secs: u32,
pub series_name: Option<String>,
pub season_number: Option<u32>,
pub episode_number: Option<u32>,
pub year: Option<u16>,
pub genres: Vec<String>,
pub tags: Vec<String>,
pub collection_id: Option<String>,
pub collection_name: Option<String>,
pub collection_type: Option<String>,
pub thumbnail_url: Option<String>,
pub synced_at: Option<String>,
pub role: String,
}
impl From<domain::MediaItem> for LibraryItemResponse {
fn from(i: domain::MediaItem) -> Self {
Self {
id: i.id().value().to_string(),
provider_id: i.provider_id().to_string(),
external_id: i.external_id().to_string(),
title: i.title().to_string(),
content_type: enum_to_string(i.content_type()),
duration_secs: i.duration_secs(),
series_name: i.series_name().map(|s| s.to_string()),
season_number: i.season_number(),
episode_number: i.episode_number(),
year: i.year(),
genres: i.genres().to_vec(),
tags: i.tags().to_vec(),
collection_id: i.collection_id().map(|s| s.to_string()),
collection_name: i.collection_name().map(|s| s.to_string()),
collection_type: i.collection_type().map(|s| s.to_string()),
thumbnail_url: i.thumbnail_url().map(|s| s.to_string()),
synced_at: i.synced_at().map(|s| s.to_string()),
role: enum_to_string(i.role()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CollectionResponse {
pub id: String,
pub name: String,
pub collection_type: Option<String>,
}
impl From<domain::LibraryCollection> for CollectionResponse {
fn from(c: domain::LibraryCollection) -> Self {
Self {
id: c.id().to_string(),
name: c.name().to_string(),
collection_type: c.collection_type().map(|s| s.to_string()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ShowResponse {
pub series_name: String,
pub episode_count: u32,
pub season_count: u32,
pub thumbnail_url: Option<String>,
pub genres: Vec<String>,
}
impl From<domain::ShowSummary> for ShowResponse {
fn from(s: domain::ShowSummary) -> Self {
Self {
series_name: s.series_name().to_string(),
episode_count: s.episode_count(),
season_count: s.season_count(),
thumbnail_url: s.thumbnail_url().map(|s| s.to_string()),
genres: s.genres().to_vec(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SeasonResponse {
pub season_number: u32,
pub episode_count: u32,
pub thumbnail_url: Option<String>,
}
impl From<domain::SeasonSummary> for SeasonResponse {
fn from(s: domain::SeasonSummary) -> Self {
Self {
season_number: s.season_number(),
episode_count: s.episode_count(),
thumbnail_url: s.thumbnail_url().map(|s| s.to_string()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SyncStatusEntry {
pub provider_id: String,
pub started_at: String,
pub finished_at: String,
pub items_found: u32,
pub status: String,
pub error_msg: String,
}
impl From<domain::LibrarySyncLogEntry> for SyncStatusEntry {
fn from(e: domain::LibrarySyncLogEntry) -> Self {
Self {
provider_id: e.provider_id().to_string(),
started_at: e.started_at().to_string(),
finished_at: e.finished_at().unwrap_or("").to_string(),
items_found: e.items_found(),
status: e.status().to_string(),
error_msg: e.error_msg().unwrap_or("").to_string(),
}
}
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct LibrarySearchParams {
pub provider: Option<String>,
pub content_type: Option<String>,
#[serde(default, rename = "genres[]")]
pub genres: Vec<String>,
pub search_term: Option<String>,
pub collection_id: Option<String>,
#[serde(default, rename = "series_names[]")]
pub series_names: Vec<String>,
pub season_number: Option<u32>,
pub decade: Option<u16>,
pub offset: Option<u32>,
pub limit: Option<u32>,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct ProviderParam {
pub provider: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct ShowsParams {
pub provider: Option<String>,
pub search_term: Option<String>,
#[serde(default, rename = "genres[]")]
pub genres: Vec<String>,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct SeasonsParams {
pub series_name: String,
pub provider: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct GenresParams {
pub content_type: Option<String>,
pub provider: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateRoleRequest {
pub role: String,
}

View File

@@ -1,37 +0,0 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ProviderConfigRequest {
pub provider_type: String,
pub config: serde_json::Value,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ProviderConfigResponse {
pub id: String,
pub provider_type: String,
pub config: serde_json::Value,
pub enabled: bool,
pub updated_at: String,
}
impl From<domain::ProviderConfigRow> for ProviderConfigResponse {
fn from(r: domain::ProviderConfigRow) -> Self {
let config = serde_json::from_str(r.config_json())
.unwrap_or(serde_json::Value::Object(Default::default()));
Self {
id: r.id().to_string(),
provider_type: r.provider_type().to_string(),
config,
enabled: r.enabled(),
updated_at: r.updated_at().to_string(),
}
}
}

View File

@@ -1,116 +0,0 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::common::enum_to_string;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct MediaItemResponse {
pub id: String,
pub title: String,
pub content_type: String,
pub duration_secs: u32,
pub description: Option<String>,
pub genres: Vec<String>,
pub year: Option<u16>,
pub tags: Vec<String>,
pub series_name: Option<String>,
pub season_number: Option<u32>,
pub episode_number: Option<u32>,
}
impl From<domain::MediaItem> for MediaItemResponse {
fn from(i: domain::MediaItem) -> Self {
Self {
id: i.id().value().to_string(),
title: i.title().to_string(),
content_type: enum_to_string(i.content_type()),
duration_secs: i.duration_secs(),
description: i.description().map(|s| s.to_string()),
genres: i.genres().to_vec(),
year: i.year(),
tags: i.tags().to_vec(),
series_name: i.series_name().map(|s| s.to_string()),
season_number: i.season_number(),
episode_number: i.episode_number(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SlotResponse {
pub id: Uuid,
pub start_at: DateTime<Utc>,
pub end_at: DateTime<Utc>,
pub item: MediaItemResponse,
pub source_block_id: Uuid,
}
impl From<domain::ScheduledSlot> for SlotResponse {
fn from(s: domain::ScheduledSlot) -> Self {
Self {
id: s.id().value(),
start_at: s.start_at(),
end_at: s.end_at(),
item: s.item().clone().into(),
source_block_id: s.source_block_id().value(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CurrentBroadcastResponse {
pub slot: SlotResponse,
pub offset_secs: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ScheduleResponse {
pub id: Uuid,
pub channel_id: Uuid,
pub valid_from: DateTime<Utc>,
pub valid_until: DateTime<Utc>,
pub generation: u32,
pub slots: Vec<SlotResponse>,
}
impl From<domain::GeneratedSchedule> for ScheduleResponse {
fn from(s: domain::GeneratedSchedule) -> Self {
let id = s.id().value();
let channel_id = s.channel_id().value();
let valid_from = s.valid_from();
let valid_until = s.valid_until();
let generation = s.generation();
let slots = s.into_slots().into_iter().map(Into::into).collect();
Self {
id,
channel_id,
valid_from,
valid_until,
generation,
slots,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ScheduleHistoryEntry {
pub id: Uuid,
pub generation: u32,
pub valid_from: DateTime<Utc>,
pub valid_until: DateTime<Utc>,
pub slot_count: usize,
}
impl From<domain::GeneratedSchedule> for ScheduleHistoryEntry {
fn from(s: domain::GeneratedSchedule) -> Self {
Self {
id: s.id().value(),
generation: s.generation(),
valid_from: s.valid_from(),
valid_until: s.valid_until(),
slot_count: s.slots().len(),
}
}
}

View File

@@ -1,18 +0,0 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct TranscodeSettingsResponse {
pub cleanup_ttl_hours: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateTranscodeSettingsRequest {
pub cleanup_ttl_hours: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct TranscodeStatsResponse {
pub cache_size_bytes: u64,
pub item_count: usize,
}

View File

@@ -1,15 +0,0 @@
[package]
name = "application"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
chrono = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
domain = { workspace = true, features = ["test-helpers"] }
tokio = { workspace = true }

View File

@@ -1,3 +0,0 @@
pub struct UpdateSettingsCommand {
pub settings: Vec<(String, String)>,
}

View File

@@ -1,8 +0,0 @@
use std::sync::Arc;
use domain::ports::{ActivityLogQuery, AppSettingsRepository};
pub struct AdminDeps {
pub settings_repo: Arc<dyn AppSettingsRepository>,
pub activity_query: Arc<dyn ActivityLogQuery>,
}

View File

@@ -1,6 +0,0 @@
pub mod commands;
pub mod deps;
pub mod update_settings;
pub use commands::UpdateSettingsCommand;
pub use deps::AdminDeps;

View File

@@ -1,63 +0,0 @@
use std::sync::Arc;
use domain::testing::{InMemoryActivityLog, InMemoryAppSettings};
use crate::admin::commands::UpdateSettingsCommand;
use crate::admin::deps::AdminDeps;
use crate::admin::update_settings;
fn make_deps() -> AdminDeps {
AdminDeps {
settings_repo: Arc::new(InMemoryAppSettings::new()),
activity_query: Arc::new(InMemoryActivityLog::new()),
}
}
#[tokio::test]
async fn update_stores_settings() {
let deps = make_deps();
update_settings::execute(
&deps,
UpdateSettingsCommand {
settings: vec![
("library_sync_interval_hours".into(), "12".into()),
("theme".into(), "dark".into()),
],
},
)
.await
.unwrap();
let val = deps.settings_repo.get("library_sync_interval_hours").await.unwrap();
assert_eq!(val, Some("12".into()));
let val2 = deps.settings_repo.get("theme").await.unwrap();
assert_eq!(val2, Some("dark".into()));
}
#[tokio::test]
async fn update_overwrites_existing() {
let deps = make_deps();
update_settings::execute(
&deps,
UpdateSettingsCommand {
settings: vec![("key".into(), "old".into())],
},
)
.await
.unwrap();
update_settings::execute(
&deps,
UpdateSettingsCommand {
settings: vec![("key".into(), "new".into())],
},
)
.await
.unwrap();
let val = deps.settings_repo.get("key").await.unwrap();
assert_eq!(val, Some("new".into()));
}

View File

@@ -1,15 +0,0 @@
use domain::DomainResult;
use super::commands::UpdateSettingsCommand;
use super::deps::AdminDeps;
pub async fn execute(deps: &AdminDeps, cmd: UpdateSettingsCommand) -> DomainResult<Vec<(String, String)>> {
for (key, value) in &cmd.settings {
deps.settings_repo.set(key, value).await?;
}
deps.settings_repo.get_all().await
}
#[cfg(test)]
#[path = "tests/update_settings.rs"]
mod tests;

View File

@@ -1,10 +0,0 @@
pub struct RegisterCommand {
pub email: String,
pub password: String,
}
pub struct LoginCommand {
pub email: String,
pub password: String,
pub remember_me: bool,
}

View File

@@ -1,11 +0,0 @@
use std::sync::Arc;
use domain::ports::{AuthService, EventPublisher, TokenService, UserCommand, UserQuery};
pub struct AuthDeps {
pub user_command: Arc<dyn UserCommand>,
pub user_query: Arc<dyn UserQuery>,
pub auth_service: Arc<dyn AuthService>,
pub token_service: Arc<dyn TokenService>,
pub event_publisher: Arc<dyn EventPublisher>,
}

View File

@@ -1,44 +0,0 @@
use domain::{DomainError, DomainResult, Email};
use super::commands::LoginCommand;
use super::deps::AuthDeps;
use super::results::LoginResult;
const INVALID_CREDENTIALS: &str = "Invalid credentials";
pub async fn execute(deps: &AuthDeps, cmd: LoginCommand) -> DomainResult<LoginResult> {
let email = Email::new(&cmd.email)?;
let user = deps
.user_query
.find_by_email(email.as_ref())
.await?
.ok_or_else(|| DomainError::unauthenticated(INVALID_CREDENTIALS))?;
let hash = user
.password_hash()
.ok_or_else(|| DomainError::unauthenticated(INVALID_CREDENTIALS))?;
let valid = deps.auth_service.verify_password(&cmd.password, hash)?;
if !valid {
return Err(DomainError::unauthenticated(INVALID_CREDENTIALS));
}
let access_token = deps.token_service.create_access_token(&user)?;
let refresh_token = if cmd.remember_me {
Some(deps.token_service.create_refresh_token(&user)?)
} else {
None
};
let expires_in = deps.token_service.token_expiry_secs();
Ok(LoginResult {
access_token,
refresh_token,
expires_in,
})
}
#[cfg(test)]
#[path = "tests/login.rs"]
mod tests;

View File

@@ -1,11 +0,0 @@
pub mod commands;
pub mod deps;
pub mod login;
pub mod queries;
pub mod refresh;
pub mod register;
pub mod results;
pub use commands::{LoginCommand, RegisterCommand};
pub use deps::AuthDeps;
pub use results::LoginResult;

View File

@@ -1,24 +0,0 @@
use domain::{DomainError, DomainResult};
use super::deps::AuthDeps;
use super::results::LoginResult;
pub async fn execute(deps: &AuthDeps, refresh_token: String) -> DomainResult<LoginResult> {
let user_id = deps.token_service.validate_refresh_token(&refresh_token)?;
let user = deps
.user_query
.find_by_id(user_id)
.await?
.ok_or_else(|| DomainError::Unauthenticated("User not found".to_string()))?;
let access_token = deps.token_service.create_access_token(&user)?;
let new_refresh = Some(deps.token_service.create_refresh_token(&user)?);
let expires_in = deps.token_service.token_expiry_secs();
Ok(LoginResult {
access_token,
refresh_token: new_refresh,
expires_in,
})
}

View File

@@ -1,36 +0,0 @@
use domain::events::DomainEvent;
use domain::models::User;
use domain::{DomainResult, Email, Password};
use super::commands::RegisterCommand;
use super::deps::AuthDeps;
pub async fn execute(deps: &AuthDeps, cmd: RegisterCommand) -> DomainResult<User> {
let email = Email::new(&cmd.email)?;
let password = Password::new(&cmd.password)?;
if deps.user_query.find_by_email(email.as_ref()).await?.is_some() {
return Err(domain::DomainError::UserAlreadyExists(cmd.email));
}
let hash = deps.auth_service.hash_password(password.as_ref())?;
let mut user = User::new_local(email, hash);
if deps.user_query.count_users().await? == 0 {
user.promote_to_admin();
}
deps.user_command.save(&user).await?;
deps.event_publisher
.publish(DomainEvent::UserRegistered {
user_id: user.id(),
})
.await?;
Ok(user)
}
#[cfg(test)]
#[path = "tests/register.rs"]
mod tests;

View File

@@ -1,6 +0,0 @@
#[derive(Debug)]
pub struct LoginResult {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_in: u64,
}

View File

@@ -1,181 +0,0 @@
use std::sync::Arc;
use domain::errors::DomainResult;
use domain::ports::AuthService;
use domain::testing::{InMemoryUserRepository, NoopEventPublisher, NoopTokenService};
use domain::{DomainError, Email};
use crate::auth::commands::LoginCommand;
use crate::auth::deps::AuthDeps;
use crate::auth::login;
struct FakeAuthService;
impl AuthService for FakeAuthService {
fn hash_password(&self, password: &str) -> DomainResult<String> {
Ok(format!("hashed:{}", password))
}
fn verify_password(&self, password: &str, hash: &str) -> DomainResult<bool> {
Ok(hash == format!("hashed:{}", password))
}
}
fn make_deps_with_user(
email: &str,
password_hash: &str,
) -> (AuthDeps, Arc<InMemoryUserRepository>) {
let repo = Arc::new(InMemoryUserRepository::new());
let e = Email::new(email).unwrap();
let user = domain::models::User::new_local(e, password_hash);
repo.store.lock().unwrap().insert(user.id(), user);
let deps = AuthDeps {
user_command: repo.clone(),
user_query: repo.clone(),
auth_service: Arc::new(FakeAuthService),
token_service: Arc::new(NoopTokenService::new()),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
(deps, repo)
}
#[tokio::test]
async fn login_succeeds_with_correct_credentials() {
let (deps, _) = make_deps_with_user("alice@example.com", "hashed:password123");
let result = login::execute(
&deps,
LoginCommand {
email: "alice@example.com".into(),
password: "password123".into(),
remember_me: false,
},
)
.await
.unwrap();
assert!(!result.access_token.is_empty());
assert!(result.refresh_token.is_none());
}
#[tokio::test]
async fn login_with_remember_me_returns_refresh_token() {
let (deps, _) = make_deps_with_user("alice@example.com", "hashed:password123");
let result = login::execute(
&deps,
LoginCommand {
email: "alice@example.com".into(),
password: "password123".into(),
remember_me: true,
},
)
.await
.unwrap();
assert!(!result.access_token.is_empty());
assert!(result.refresh_token.is_some());
}
#[tokio::test]
async fn login_fails_with_wrong_password() {
let (deps, _) = make_deps_with_user("alice@example.com", "hashed:correct");
let result = login::execute(
&deps,
LoginCommand {
email: "alice@example.com".into(),
password: "wrong".into(),
remember_me: false,
},
)
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
DomainError::Unauthenticated(_)
));
}
#[tokio::test]
async fn login_fails_for_unknown_email() {
let (deps, _) = make_deps_with_user("alice@example.com", "hashed:pw");
let result = login::execute(
&deps,
LoginCommand {
email: "nobody@example.com".into(),
password: "password123".into(),
remember_me: false,
},
)
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
DomainError::Unauthenticated(_)
));
}
#[tokio::test]
async fn login_fails_for_user_without_password() {
let repo = Arc::new(InMemoryUserRepository::new());
let email = Email::new("external@example.com").unwrap();
let user = domain::models::User::new("external|subject", email);
repo.store.lock().unwrap().insert(user.id(), user);
let deps = AuthDeps {
user_command: repo.clone(),
user_query: repo.clone(),
auth_service: Arc::new(FakeAuthService),
token_service: Arc::new(NoopTokenService::new()),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
let result = login::execute(
&deps,
LoginCommand {
email: "external@example.com".into(),
password: "password123".into(),
remember_me: false,
},
)
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
DomainError::Unauthenticated(_)
));
}
#[tokio::test]
async fn login_rejects_invalid_email() {
let repo = Arc::new(InMemoryUserRepository::new());
let deps = AuthDeps {
user_command: repo.clone(),
user_query: repo.clone(),
auth_service: Arc::new(FakeAuthService),
token_service: Arc::new(NoopTokenService::new()),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
let result = login::execute(
&deps,
LoginCommand {
email: "not-an-email".into(),
password: "password123".into(),
remember_me: false,
},
)
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
DomainError::ValidationError(_)
));
}

View File

@@ -1,165 +0,0 @@
use std::sync::Arc;
use domain::errors::DomainResult;
use domain::ports::AuthService;
use domain::testing::{InMemoryUserRepository, NoopEventPublisher, NoopTokenService};
use domain::{DomainError, Email};
use crate::auth::commands::RegisterCommand;
use crate::auth::deps::AuthDeps;
use crate::auth::register;
/// Fake auth service: prefixes "hashed:" for hashing, verifies by checking prefix.
struct FakeAuthService;
impl AuthService for FakeAuthService {
fn hash_password(&self, password: &str) -> DomainResult<String> {
Ok(format!("hashed:{}", password))
}
fn verify_password(&self, password: &str, hash: &str) -> DomainResult<bool> {
Ok(hash == format!("hashed:{}", password))
}
}
fn make_deps() -> (AuthDeps, Arc<InMemoryUserRepository>) {
let repo = Arc::new(InMemoryUserRepository::new());
let deps = AuthDeps {
user_command: repo.clone(),
user_query: repo.clone(),
auth_service: Arc::new(FakeAuthService),
token_service: Arc::new(NoopTokenService::new()),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
(deps, repo)
}
#[tokio::test]
async fn registers_new_user() {
let (deps, repo) = make_deps();
let user = register::execute(
&deps,
RegisterCommand {
email: "alice@example.com".into(),
password: "password123".into(),
},
)
.await
.unwrap();
assert_eq!(user.email().as_ref(), "alice@example.com");
assert!(user.password_hash().unwrap().starts_with("hashed:"));
// First user gets admin
assert!(user.is_admin());
// Verify persisted
let stored = repo
.store
.lock()
.unwrap()
.values()
.next()
.cloned()
.unwrap();
assert_eq!(stored.id(), user.id());
}
#[tokio::test]
async fn second_user_is_not_admin() {
let (deps, _) = make_deps();
// First user
register::execute(
&deps,
RegisterCommand {
email: "first@example.com".into(),
password: "password123".into(),
},
)
.await
.unwrap();
// Second user
let user = register::execute(
&deps,
RegisterCommand {
email: "second@example.com".into(),
password: "password123".into(),
},
)
.await
.unwrap();
assert!(!user.is_admin());
}
#[tokio::test]
async fn register_fails_for_duplicate_email() {
let (deps, repo) = make_deps();
// Pre-populate with existing user
let email = Email::new("taken@example.com").unwrap();
let existing = domain::models::User::new_local(email, "existing_hash");
repo.store
.lock()
.unwrap()
.insert(existing.id(), existing);
let result = register::execute(
&deps,
RegisterCommand {
email: "taken@example.com".into(),
password: "password123".into(),
},
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
DomainError::UserAlreadyExists(email) => {
assert_eq!(email, "taken@example.com");
}
other => panic!("expected UserAlreadyExists, got: {:?}", other),
}
}
#[tokio::test]
async fn register_rejects_invalid_email() {
let (deps, _) = make_deps();
let result = register::execute(
&deps,
RegisterCommand {
email: "not-an-email".into(),
password: "password123".into(),
},
)
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
DomainError::ValidationError(_)
));
}
#[tokio::test]
async fn register_rejects_short_password() {
let (deps, _) = make_deps();
let result = register::execute(
&deps,
RegisterCommand {
email: "valid@example.com".into(),
password: "short".into(),
},
)
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
DomainError::ValidationError(_)
));
}

View File

@@ -1,25 +0,0 @@
use domain::models::ScheduleConfig;
use domain::value_objects::{ChannelId, MediaFilter, RotationPolicy, UserId};
pub struct CreateChannelCommand {
pub owner_id: UserId,
pub name: String,
pub timezone: String,
}
pub struct UpdateChannelCommand {
pub channel_id: ChannelId,
pub owner_id: UserId,
pub name: Option<String>,
pub description: Option<Option<String>>,
pub timezone: Option<String>,
pub schedule_config: Option<ScheduleConfig>,
pub rotation_policy: Option<RotationPolicy>,
pub auto_schedule: Option<bool>,
pub gap_filler: Option<Option<MediaFilter>>,
}
pub struct DeleteChannelCommand {
pub channel_id: ChannelId,
pub owner_id: UserId,
}

View File

@@ -1,24 +0,0 @@
use domain::events::DomainEvent;
use domain::models::Channel;
use domain::DomainResult;
use super::commands::CreateChannelCommand;
use super::deps::ChannelCommandDeps;
pub async fn execute(deps: &ChannelCommandDeps, cmd: CreateChannelCommand) -> DomainResult<Channel> {
let channel = Channel::new(cmd.owner_id, cmd.name, cmd.timezone);
deps.channel_command.save(&channel).await?;
deps.event_publisher
.publish(DomainEvent::ChannelCreated {
channel_id: channel.id(),
})
.await?;
Ok(channel)
}
#[cfg(test)]
#[path = "tests/create.rs"]
mod tests;

View File

@@ -1,22 +0,0 @@
use domain::events::DomainEvent;
use domain::DomainResult;
use super::commands::DeleteChannelCommand;
use super::deps::ChannelCommandDeps;
use super::find_owned_channel;
pub async fn execute(deps: &ChannelCommandDeps, cmd: DeleteChannelCommand) -> DomainResult<()> {
find_owned_channel(deps.channel_query.as_ref(), cmd.channel_id, cmd.owner_id).await?;
deps.channel_command.delete(cmd.channel_id).await?;
deps.event_publisher
.publish(DomainEvent::ChannelDeleted { channel_id: cmd.channel_id })
.await?;
Ok(())
}
#[cfg(test)]
#[path = "tests/delete.rs"]
mod tests;

View File

@@ -1,9 +0,0 @@
use std::sync::Arc;
use domain::ports::{ChannelCommand, ChannelQuery, EventPublisher};
pub struct ChannelCommandDeps {
pub channel_command: Arc<dyn ChannelCommand>,
pub channel_query: Arc<dyn ChannelQuery>,
pub event_publisher: Arc<dyn EventPublisher>,
}

View File

@@ -1,31 +0,0 @@
pub mod commands;
pub mod create;
pub mod delete;
pub mod deps;
pub mod update;
pub use commands::{CreateChannelCommand, DeleteChannelCommand, UpdateChannelCommand};
pub use deps::ChannelCommandDeps;
use domain::models::Channel;
use domain::value_objects::{ChannelId, UserId};
use domain::{DomainError, DomainResult};
const OWNERSHIP_DENIED: &str = "You don't own this channel";
pub(crate) async fn find_owned_channel(
query: &dyn domain::ports::ChannelQuery,
channel_id: ChannelId,
owner_id: UserId,
) -> DomainResult<Channel> {
let channel = query
.find_by_id(channel_id)
.await?
.ok_or(DomainError::ChannelNotFound(channel_id))?;
if channel.owner_id() != owner_id {
return Err(DomainError::forbidden(OWNERSHIP_DENIED));
}
Ok(channel)
}

View File

@@ -1,65 +0,0 @@
use std::sync::Arc;
use domain::testing::{InMemoryChannelRepository, NoopEventPublisher};
use domain::value_objects::UserId;
use crate::channels::commands::CreateChannelCommand;
use crate::channels::create;
use crate::channels::deps::ChannelCommandDeps;
fn make_deps() -> (ChannelCommandDeps, Arc<InMemoryChannelRepository>) {
let repo = Arc::new(InMemoryChannelRepository::new());
let deps = ChannelCommandDeps {
channel_command: repo.clone(),
channel_query: repo.clone(),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
(deps, repo)
}
#[tokio::test]
async fn creates_channel_successfully() {
let (deps, repo) = make_deps();
let owner = UserId::generate();
let channel = create::execute(
&deps,
CreateChannelCommand {
owner_id: owner,
name: "Movie Night".into(),
timezone: "America/New_York".into(),
},
)
.await
.unwrap();
assert_eq!(channel.name(), "Movie Night");
assert_eq!(channel.timezone(), "America/New_York");
assert_eq!(channel.owner_id(), owner);
// Verify persisted
let stored = repo.channels.lock().unwrap();
assert_eq!(stored.len(), 1);
let persisted = stored.values().next().unwrap();
assert_eq!(persisted.id(), channel.id());
}
#[tokio::test]
async fn create_returns_default_config() {
let (deps, _) = make_deps();
let channel = create::execute(
&deps,
CreateChannelCommand {
owner_id: UserId::generate(),
name: "Defaults".into(),
timezone: "UTC".into(),
},
)
.await
.unwrap();
assert!(channel.description().is_none());
assert!(!channel.auto_schedule());
assert!(channel.schedule_config().day_blocks().is_empty());
}

View File

@@ -1,101 +0,0 @@
use std::sync::Arc;
use domain::testing::{InMemoryChannelRepository, NoopEventPublisher};
use domain::value_objects::{ChannelId, UserId};
use domain::DomainError;
use crate::channels::commands::{CreateChannelCommand, DeleteChannelCommand};
use crate::channels::deps::ChannelCommandDeps;
use crate::channels::{create, delete};
fn make_deps() -> (ChannelCommandDeps, Arc<InMemoryChannelRepository>) {
let repo = Arc::new(InMemoryChannelRepository::new());
let deps = ChannelCommandDeps {
channel_command: repo.clone(),
channel_query: repo.clone(),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
(deps, repo)
}
#[tokio::test]
async fn deletes_channel_by_owner() {
let (deps, repo) = make_deps();
let owner = UserId::generate();
let channel = create::execute(
&deps,
CreateChannelCommand {
owner_id: owner,
name: "Doomed".into(),
timezone: "UTC".into(),
},
)
.await
.unwrap();
delete::execute(
&deps,
DeleteChannelCommand {
channel_id: channel.id(),
owner_id: owner,
},
)
.await
.unwrap();
assert!(repo.channels.lock().unwrap().is_empty());
}
#[tokio::test]
async fn delete_fails_if_not_owner() {
let (deps, _) = make_deps();
let owner = UserId::generate();
let stranger = UserId::generate();
let channel = create::execute(
&deps,
CreateChannelCommand {
owner_id: owner,
name: "Protected".into(),
timezone: "UTC".into(),
},
)
.await
.unwrap();
let result = delete::execute(
&deps,
DeleteChannelCommand {
channel_id: channel.id(),
owner_id: stranger,
},
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
DomainError::Forbidden(_) => {}
other => panic!("expected Forbidden, got: {:?}", other),
}
}
#[tokio::test]
async fn delete_nonexistent_channel_returns_not_found() {
let (deps, _) = make_deps();
let result = delete::execute(
&deps,
DeleteChannelCommand {
channel_id: ChannelId::generate(),
owner_id: UserId::generate(),
},
)
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
DomainError::ChannelNotFound(_)
));
}

View File

@@ -1,258 +0,0 @@
use std::sync::Arc;
use domain::testing::{InMemoryChannelRepository, NoopEventPublisher};
use domain::value_objects::{ChannelId, UserId};
use domain::DomainError;
use crate::channels::commands::{CreateChannelCommand, UpdateChannelCommand};
use crate::channels::deps::ChannelCommandDeps;
use crate::channels::{create, update};
fn make_deps() -> (ChannelCommandDeps, Arc<InMemoryChannelRepository>) {
let repo = Arc::new(InMemoryChannelRepository::new());
let deps = ChannelCommandDeps {
channel_command: repo.clone(),
channel_query: repo.clone(),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
(deps, repo)
}
#[tokio::test]
async fn updates_channel_name() {
let (deps, _) = make_deps();
let owner = UserId::generate();
let channel = create::execute(
&deps,
CreateChannelCommand {
owner_id: owner,
name: "Original".into(),
timezone: "UTC".into(),
},
)
.await
.unwrap();
let updated = update::execute(
&deps,
UpdateChannelCommand {
channel_id: channel.id(),
owner_id: owner,
name: Some("Renamed".into()),
description: None,
timezone: None,
schedule_config: None,
rotation_policy: None,
auto_schedule: None,
gap_filler: None,
},
)
.await
.unwrap();
assert_eq!(updated.name(), "Renamed");
assert_eq!(updated.timezone(), "UTC"); // unchanged
}
#[tokio::test]
async fn update_fails_if_not_owner() {
let (deps, _) = make_deps();
let owner = UserId::generate();
let stranger = UserId::generate();
let channel = create::execute(
&deps,
CreateChannelCommand {
owner_id: owner,
name: "Protected".into(),
timezone: "UTC".into(),
},
)
.await
.unwrap();
let result = update::execute(
&deps,
UpdateChannelCommand {
channel_id: channel.id(),
owner_id: stranger,
name: Some("Hacked".into()),
description: None,
timezone: None,
schedule_config: None,
rotation_policy: None,
auto_schedule: None,
gap_filler: None,
},
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
DomainError::Forbidden(_) => {}
other => panic!("expected Forbidden, got: {:?}", other),
}
}
#[tokio::test]
async fn update_nonexistent_channel_returns_not_found() {
let (deps, _) = make_deps();
let result = update::execute(
&deps,
UpdateChannelCommand {
channel_id: ChannelId::generate(),
owner_id: UserId::generate(),
name: Some("Ghost".into()),
description: None,
timezone: None,
schedule_config: None,
rotation_policy: None,
auto_schedule: None,
gap_filler: None,
},
)
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
DomainError::ChannelNotFound(_)
));
}
#[tokio::test]
async fn update_config_creates_snapshot() {
let (deps, repo) = make_deps();
let owner = UserId::generate();
let channel = create::execute(
&deps,
CreateChannelCommand {
owner_id: owner,
name: "Snapshotted".into(),
timezone: "UTC".into(),
},
)
.await
.unwrap();
// Update with new schedule_config
let new_config = domain::models::ScheduleConfig::default();
update::execute(
&deps,
UpdateChannelCommand {
channel_id: channel.id(),
owner_id: owner,
name: None,
description: None,
timezone: None,
schedule_config: Some(new_config),
rotation_policy: None,
auto_schedule: None,
gap_filler: None,
},
)
.await
.unwrap();
// Verify a config snapshot was created
let snapshots = repo.snapshots.lock().unwrap();
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].channel_id(), channel.id());
}
#[tokio::test]
async fn update_without_config_skips_snapshot() {
let (deps, repo) = make_deps();
let owner = UserId::generate();
let channel = create::execute(
&deps,
CreateChannelCommand {
owner_id: owner,
name: "NoSnapshot".into(),
timezone: "UTC".into(),
},
)
.await
.unwrap();
// Update name only — no config change
update::execute(
&deps,
UpdateChannelCommand {
channel_id: channel.id(),
owner_id: owner,
name: Some("Renamed".into()),
description: None,
timezone: None,
schedule_config: None,
rotation_policy: None,
auto_schedule: None,
gap_filler: None,
},
)
.await
.unwrap();
// No snapshot should exist
let snapshots = repo.snapshots.lock().unwrap();
assert!(snapshots.is_empty());
}
#[tokio::test]
async fn update_description_clear() {
let (deps, _) = make_deps();
let owner = UserId::generate();
let channel = create::execute(
&deps,
CreateChannelCommand {
owner_id: owner,
name: "Desc Test".into(),
timezone: "UTC".into(),
},
)
.await
.unwrap();
// Set description
let updated = update::execute(
&deps,
UpdateChannelCommand {
channel_id: channel.id(),
owner_id: owner,
name: None,
description: Some(Some("A description".into())),
timezone: None,
schedule_config: None,
rotation_policy: None,
auto_schedule: None,
gap_filler: None,
},
)
.await
.unwrap();
assert_eq!(updated.description(), Some("A description"));
// Clear description with Some(None)
let cleared = update::execute(
&deps,
UpdateChannelCommand {
channel_id: channel.id(),
owner_id: owner,
name: None,
description: Some(None),
timezone: None,
schedule_config: None,
rotation_policy: None,
auto_schedule: None,
gap_filler: None,
},
)
.await
.unwrap();
assert!(cleared.description().is_none());
}

View File

@@ -1,55 +0,0 @@
use domain::events::DomainEvent;
use domain::models::Channel;
use domain::DomainResult;
use super::commands::UpdateChannelCommand;
use super::deps::ChannelCommandDeps;
use super::find_owned_channel;
pub async fn execute(deps: &ChannelCommandDeps, cmd: UpdateChannelCommand) -> DomainResult<Channel> {
let mut channel =
find_owned_channel(deps.channel_query.as_ref(), cmd.channel_id, cmd.owner_id)
.await?;
if cmd.schedule_config.is_some() {
deps.channel_command
.save_config_snapshot(cmd.channel_id, channel.schedule_config(), None)
.await?;
}
if let Some(name) = cmd.name {
channel.set_name(name);
}
if let Some(description) = cmd.description {
channel.set_description(description);
}
if let Some(timezone) = cmd.timezone {
channel.set_timezone(timezone);
}
if let Some(config) = cmd.schedule_config {
channel.set_schedule_config(config);
}
if let Some(policy) = cmd.rotation_policy {
channel.set_rotation_policy(policy);
}
if let Some(auto) = cmd.auto_schedule {
channel.set_auto_schedule(auto);
}
if let Some(gap_filler) = cmd.gap_filler {
channel.set_gap_filler(gap_filler);
}
deps.channel_command.save(&channel).await?;
deps.event_publisher
.publish(DomainEvent::ChannelUpdated {
channel_id: channel.id(),
})
.await?;
Ok(channel)
}
#[cfg(test)]
#[path = "tests/update.rs"]
mod tests;

View File

@@ -1,9 +0,0 @@
use std::sync::Arc;
use domain::ports::IProviderRegistry;
pub struct ConfigDeps {
pub provider_registry: Arc<dyn IProviderRegistry>,
pub allow_registration: bool,
pub available_provider_types: Vec<String>,
}

View File

@@ -1,53 +0,0 @@
use domain::ports::ProviderCapabilities;
use super::deps::ConfigDeps;
use super::queries::GetConfigQuery;
pub struct ProviderInfo {
pub id: String,
pub capabilities: ProviderCapabilities,
}
pub struct SystemConfig {
pub allow_registration: bool,
pub providers: Vec<ProviderInfo>,
pub primary_capabilities: ProviderCapabilities,
pub available_provider_types: Vec<String>,
}
pub fn execute(deps: &ConfigDeps, _query: GetConfigQuery) -> SystemConfig {
let provider_ids = deps.provider_registry.provider_ids();
let primary_id = deps.provider_registry.primary_id().to_string();
let providers: Vec<ProviderInfo> = provider_ids
.iter()
.filter_map(|id| {
deps.provider_registry
.capabilities(id)
.map(|caps| ProviderInfo {
id: id.clone(),
capabilities: caps,
})
})
.collect();
let primary_capabilities = deps
.provider_registry
.capabilities(&primary_id)
.unwrap_or(ProviderCapabilities {
collections: false,
series: false,
genres: false,
tags: false,
decade: false,
search: false,
rescan: false,
});
SystemConfig {
allow_registration: deps.allow_registration,
providers,
primary_capabilities,
available_provider_types: deps.available_provider_types.clone(),
}
}

View File

@@ -1,6 +0,0 @@
pub mod deps;
pub mod get_config;
pub mod queries;
pub use deps::ConfigDeps;
pub use queries::GetConfigQuery;

View File

@@ -1 +0,0 @@
pub struct GetConfigQuery;

View File

@@ -1,11 +0,0 @@
use domain::value_objects::{ChannelId, SnapshotId};
pub struct SaveSnapshotCommand {
pub channel_id: ChannelId,
pub label: Option<String>,
}
pub struct RestoreSnapshotCommand {
pub channel_id: ChannelId,
pub snapshot_id: SnapshotId,
}

View File

@@ -1,8 +0,0 @@
use std::sync::Arc;
use domain::ports::{ChannelCommand, ChannelQuery};
pub struct ConfigSnapshotDeps {
pub channel_command: Arc<dyn ChannelCommand>,
pub channel_query: Arc<dyn ChannelQuery>,
}

View File

@@ -1,7 +0,0 @@
pub mod commands;
pub mod deps;
pub mod restore;
pub mod save;
pub use commands::{RestoreSnapshotCommand, SaveSnapshotCommand};
pub use deps::ConfigSnapshotDeps;

View File

@@ -1,34 +0,0 @@
use domain::models::Channel;
use domain::{DomainError, DomainResult};
use super::commands::RestoreSnapshotCommand;
use super::deps::ConfigSnapshotDeps;
pub async fn execute(
deps: &ConfigSnapshotDeps,
cmd: RestoreSnapshotCommand,
) -> DomainResult<Channel> {
let snapshot = deps
.channel_query
.get_config_snapshot(cmd.channel_id, cmd.snapshot_id)
.await?
.ok_or(DomainError::ValidationError(format!(
"Snapshot {} not found",
cmd.snapshot_id
)))?;
let mut channel = deps
.channel_query
.find_by_id(cmd.channel_id)
.await?
.ok_or(DomainError::ChannelNotFound(cmd.channel_id))?;
deps.channel_command
.save_config_snapshot(cmd.channel_id, channel.schedule_config(), None)
.await?;
channel.set_schedule_config(snapshot.config().clone());
deps.channel_command.save(&channel).await?;
Ok(channel)
}

View File

@@ -1,24 +0,0 @@
use domain::models::ChannelConfigSnapshot;
use domain::{DomainError, DomainResult};
use super::commands::SaveSnapshotCommand;
use super::deps::ConfigSnapshotDeps;
pub async fn execute(
deps: &ConfigSnapshotDeps,
cmd: SaveSnapshotCommand,
) -> DomainResult<ChannelConfigSnapshot> {
let channel = deps
.channel_query
.find_by_id(cmd.channel_id)
.await?
.ok_or(DomainError::ChannelNotFound(cmd.channel_id))?;
deps.channel_command
.save_config_snapshot(cmd.channel_id, channel.schedule_config(), cmd.label)
.await
}
#[cfg(test)]
#[path = "tests/save.rs"]
mod tests;

View File

@@ -1,75 +0,0 @@
use std::sync::Arc;
use domain::models::Channel;
use domain::testing::InMemoryChannelRepository;
use domain::value_objects::UserId;
use crate::config_snapshots::commands::SaveSnapshotCommand;
use crate::config_snapshots::deps::ConfigSnapshotDeps;
use crate::config_snapshots::save;
fn make_deps() -> (ConfigSnapshotDeps, Arc<InMemoryChannelRepository>) {
let repo = Arc::new(InMemoryChannelRepository::new());
let deps = ConfigSnapshotDeps {
channel_command: repo.clone(),
channel_query: repo.clone(),
};
(deps, repo)
}
async fn seed_channel(repo: &InMemoryChannelRepository) -> Channel {
let channel = Channel::new(UserId::generate(), "Test Channel", "UTC");
repo.channels
.lock()
.unwrap()
.insert(channel.id(), channel.clone());
channel
}
#[tokio::test]
async fn save_creates_snapshot() {
let (deps, repo) = make_deps();
let channel = seed_channel(&repo).await;
let snap = save::execute(
&deps,
SaveSnapshotCommand {
channel_id: channel.id(),
label: Some("v1".into()),
},
)
.await
.unwrap();
assert_eq!(snap.channel_id(), channel.id());
assert_eq!(snap.label(), Some("v1"));
assert_eq!(snap.version_num(), 1);
}
#[tokio::test]
async fn save_increments_version() {
let (deps, repo) = make_deps();
let channel = seed_channel(&repo).await;
save::execute(
&deps,
SaveSnapshotCommand {
channel_id: channel.id(),
label: None,
},
)
.await
.unwrap();
let snap2 = save::execute(
&deps,
SaveSnapshotCommand {
channel_id: channel.id(),
label: None,
},
)
.await
.unwrap();
assert_eq!(snap2.version_num(), 2);
}

View File

@@ -1,8 +0,0 @@
use std::sync::Arc;
use domain::ports::{ChannelQuery, ScheduleQuery};
pub struct IptvDeps {
pub channel_query: Arc<dyn ChannelQuery>,
pub schedule_query: Arc<dyn ScheduleQuery>,
}

View File

@@ -1,15 +0,0 @@
use domain::services::iptv::generate_m3u;
use domain::DomainResult;
use super::deps::IptvDeps;
use super::queries::GetM3uQuery;
pub async fn execute(deps: &IptvDeps, query: GetM3uQuery) -> DomainResult<String> {
let channels = deps.channel_query.find_all().await?;
let token = query.token.as_deref().unwrap_or("");
Ok(generate_m3u(&channels, &query.base_url, token))
}
#[cfg(test)]
#[path = "tests/m3u.rs"]
mod tests;

View File

@@ -1,7 +0,0 @@
pub mod deps;
pub mod m3u;
pub mod queries;
pub mod xmltv;
pub use deps::IptvDeps;
pub use queries::{GetM3uQuery, GetXmltvQuery};

View File

@@ -1,6 +0,0 @@
pub struct GetM3uQuery {
pub base_url: String,
pub token: Option<String>,
}
pub struct GetXmltvQuery;

View File

@@ -1,84 +0,0 @@
use std::sync::Arc;
use domain::models::Channel;
use domain::testing::{InMemoryChannelRepository, InMemoryScheduleRepository};
use domain::value_objects::UserId;
use crate::iptv::deps::IptvDeps;
use crate::iptv::m3u;
use crate::iptv::queries::GetM3uQuery;
fn make_deps() -> (IptvDeps, Arc<InMemoryChannelRepository>) {
let channel_repo = Arc::new(InMemoryChannelRepository::new());
let schedule_repo = Arc::new(InMemoryScheduleRepository::new());
let deps = IptvDeps {
channel_query: channel_repo.clone(),
schedule_query: schedule_repo,
};
(deps, channel_repo)
}
#[tokio::test]
async fn m3u_empty_channels() {
let (deps, _) = make_deps();
let result = m3u::execute(
&deps,
GetM3uQuery {
base_url: "http://localhost:3000".into(),
token: Some("tok123".into()),
},
)
.await
.unwrap();
assert_eq!(result, "#EXTM3U\n");
}
#[tokio::test]
async fn m3u_includes_channels() {
let (deps, repo) = make_deps();
let ch = Channel::new(UserId::generate(), "Test TV", "UTC");
repo.channels
.lock()
.unwrap()
.insert(ch.id(), ch.clone());
let result = m3u::execute(
&deps,
GetM3uQuery {
base_url: "http://localhost:3000".into(),
token: Some("mytoken".into()),
},
)
.await
.unwrap();
assert!(result.starts_with("#EXTM3U\n"));
assert!(result.contains("Test TV"));
assert!(result.contains("token=mytoken"));
}
#[tokio::test]
async fn m3u_no_token() {
let (deps, repo) = make_deps();
let ch = Channel::new(UserId::generate(), "Ch1", "UTC");
repo.channels
.lock()
.unwrap()
.insert(ch.id(), ch);
let result = m3u::execute(
&deps,
GetM3uQuery {
base_url: "http://localhost:3000".into(),
token: None,
},
)
.await
.unwrap();
assert!(result.contains("token="));
}

View File

@@ -1,23 +0,0 @@
use std::collections::HashMap;
use chrono::Utc;
use domain::services::iptv::generate_xmltv;
use domain::DomainResult;
use super::deps::IptvDeps;
use super::queries::GetXmltvQuery;
pub async fn execute(deps: &IptvDeps, _query: GetXmltvQuery) -> DomainResult<String> {
let channels = deps.channel_query.find_all().await?;
let now = Utc::now();
let mut slots_by_channel = HashMap::new();
for ch in &channels {
if let Some(schedule) = deps.schedule_query.find_active(ch.id(), now).await? {
slots_by_channel.insert(ch.id(), schedule.slots().to_vec());
}
}
Ok(generate_xmltv(&channels, &slots_by_channel))
}

View File

@@ -1,9 +0,0 @@
pub mod admin;
pub mod auth;
pub mod channels;
pub mod config;
pub mod config_snapshots;
pub mod iptv;
pub mod library;
pub mod providers;
pub mod schedule;

View File

@@ -1,3 +0,0 @@
pub struct TriggerSyncCommand {
pub provider_id: Option<String>,
}

View File

@@ -1,11 +0,0 @@
use std::sync::Arc;
use domain::ports::{EventPublisher, IProviderRegistry, LibraryCommand, LibraryQuery, LibrarySyncAdapter};
pub struct LibraryCommandDeps {
pub library_command: Arc<dyn LibraryCommand>,
pub library_query: Arc<dyn LibraryQuery>,
pub library_sync: Arc<dyn LibrarySyncAdapter>,
pub provider_registry: Arc<dyn IProviderRegistry>,
pub event_publisher: Arc<dyn EventPublisher>,
}

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