Compare commits
6 Commits
master
...
5d8b8f7a5f
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d8b8f7a5f | |||
| dfebcc11ef | |||
| 425a6898e4 | |||
| 5c978389b2 | |||
| 1d0e640946 | |||
| 6307850b82 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,2 @@
|
||||
transcode/
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
|
||||
/target
|
||||
13
CLAUDE.md
13
CLAUDE.md
@@ -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`.
|
||||
@@ -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
3548
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
40
Cargo.toml
40
Cargo.toml
@@ -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" }
|
||||
44
Makefile
44
Makefile
@@ -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
|
||||
@@ -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 |
|
||||
| `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 |
|
||||
| `JWT_SECRET` | backend | JWT signing key — change in production (min 32 chars) |
|
||||
| `COOKIE_SECRET` | backend | OIDC state cookie encryption key — change in production (min 64 chars) |
|
||||
| `SESSION_SECRET` | backend | Change in production |
|
||||
|
||||
@@ -11,38 +11,24 @@
|
||||
# TRAEFIK_CERT_RESOLVER cert resolver name for TLS (default: letsencrypt)
|
||||
# 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
|
||||
# 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,
|
||||
# e.g. https://tv-api.example.com/api/v1, and you must rebuild after changing it.
|
||||
|
||||
services:
|
||||
|
||||
presentation:
|
||||
ports: []
|
||||
backend:
|
||||
ports: [] # Traefik handles ingress; no direct port exposure needed
|
||||
networks:
|
||||
- default
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=${TRAEFIK_NETWORK:-traefik_proxy}"
|
||||
- "traefik.http.routers.ktv-presentation.rule=Host(`${BACKEND_HOST}`)"
|
||||
- "traefik.http.routers.ktv-presentation.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
|
||||
- "traefik.http.routers.ktv-presentation.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}"
|
||||
- "traefik.http.services.ktv-presentation.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"
|
||||
- "traefik.http.routers.ktv-backend.rule=Host(`${BACKEND_HOST}`)"
|
||||
- "traefik.http.routers.ktv-backend.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
|
||||
- "traefik.http.routers.ktv-backend.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}"
|
||||
- "traefik.http.services.ktv-backend.loadbalancer.server.port=3000"
|
||||
|
||||
frontend:
|
||||
ports: []
|
||||
|
||||
77
compose.yml
77
compose.yml
@@ -1,19 +1,19 @@
|
||||
services:
|
||||
|
||||
# ── Presentation (Rust / Axum — HTTP API) ────────────────────────────────
|
||||
presentation:
|
||||
build:
|
||||
context: ./k-tv-backend
|
||||
target: presentation
|
||||
image: registry.gabrielkaszewski.dev/k-tv-presentation:latest
|
||||
# ── Backend (Rust / Axum) ──────────────────────────────────────────────────
|
||||
backend:
|
||||
build: ./k-tv-backend
|
||||
ports:
|
||||
- "${BACKEND_PORT:-3000}:3000"
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3000
|
||||
- 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}
|
||||
# Auth — generate with: openssl rand -hex 32
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
# Cookie secret — generate with: openssl rand -base64 64
|
||||
- COOKIE_SECRET=${COOKIE_SECRET}
|
||||
- JWT_EXPIRY_HOURS=${JWT_EXPIRY_HOURS:-24}
|
||||
- SECURE_COOKIE=${SECURE_COOKIE:-false}
|
||||
@@ -21,6 +21,7 @@ services:
|
||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true}
|
||||
- DB_MAX_CONNECTIONS=${DB_MAX_CONNECTIONS:-5}
|
||||
- DB_MIN_CONNECTIONS=${DB_MIN_CONNECTIONS:-1}
|
||||
# Jellyfin — all three required for schedule generation
|
||||
- JELLYFIN_BASE_URL=${JELLYFIN_BASE_URL}
|
||||
- JELLYFIN_API_KEY=${JELLYFIN_API_KEY}
|
||||
- JELLYFIN_USER_ID=${JELLYFIN_USER_ID}
|
||||
@@ -33,60 +34,40 @@ services:
|
||||
timeout: 5s
|
||||
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:
|
||||
build:
|
||||
context: ./k-tv-frontend
|
||||
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_PLAYOUT_URL: ${NEXT_PUBLIC_PLAYOUT_URL:-http://localhost:9090}
|
||||
image: registry.gabrielkaszewski.dev/k-tv-frontend:latest
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3001}:3001"
|
||||
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:
|
||||
presentation:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
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:
|
||||
|
||||
@@ -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:00–08: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.
|
||||
@@ -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 }
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
mod config;
|
||||
mod mapping;
|
||||
mod models;
|
||||
mod provider;
|
||||
|
||||
pub use config::JellyfinConfig;
|
||||
pub use provider::JellyfinMediaProvider;
|
||||
@@ -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(),
|
||||
}))
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms)
|
||||
.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(¶ms)
|
||||
.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(¶ms)
|
||||
.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,
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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(×tamp)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -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)?)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
use serde::Deserialize;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||
pub struct IptvParams {
|
||||
pub token: Option<String>,
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -1,3 +0,0 @@
|
||||
pub struct UpdateSettingsCommand {
|
||||
pub settings: Vec<(String, String)>,
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
pub mod commands;
|
||||
pub mod deps;
|
||||
pub mod update_settings;
|
||||
|
||||
pub use commands::UpdateSettingsCommand;
|
||||
pub use deps::AdminDeps;
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,6 +0,0 @@
|
||||
#[derive(Debug)]
|
||||
pub struct LoginResult {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_in: u64,
|
||||
}
|
||||
@@ -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(_)
|
||||
));
|
||||
}
|
||||
@@ -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(_)
|
||||
));
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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(_)
|
||||
));
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
pub mod deps;
|
||||
pub mod get_config;
|
||||
pub mod queries;
|
||||
|
||||
pub use deps::ConfigDeps;
|
||||
pub use queries::GetConfigQuery;
|
||||
@@ -1 +0,0 @@
|
||||
pub struct GetConfigQuery;
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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};
|
||||
@@ -1,6 +0,0 @@
|
||||
pub struct GetM3uQuery {
|
||||
pub base_url: String,
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
pub struct GetXmltvQuery;
|
||||
@@ -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="));
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,3 +0,0 @@
|
||||
pub struct TriggerSyncCommand {
|
||||
pub provider_id: Option<String>,
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user