Compare commits
36 Commits
efd15c4f53
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2698cb3ad4 | |||
| abd6bf4bc4 | |||
| 25ff9d2779 | |||
| 2995aea606 | |||
| f52e024e98 | |||
| 5561b70e1b | |||
| 8de7d33007 | |||
| 8dabbdf280 | |||
| 9558f04f73 | |||
| a823a79f6b | |||
| 5bc1e5e44b | |||
| 711f0e4411 | |||
| a7fa2ec4aa | |||
| 6b0d060945 | |||
| 6dbf7ab98c | |||
| e27e2ab6d1 | |||
| b00272aceb | |||
| affd7f0223 | |||
| 8db70df0a0 | |||
| c14e01e0f2 | |||
| 874de68fb5 | |||
| 79e975f057 | |||
| 8edd7a9c0b | |||
| 8e4f724562 | |||
| f21d70c559 | |||
| 607d311375 | |||
| 84dd05a8a6 | |||
| 1b3ecc10e1 | |||
| de7f3092d2 | |||
| 60524c56f7 | |||
| 39f5f99bfd | |||
| abcf69ce7e | |||
| 826e824b58 | |||
| e2393be635 | |||
| a6558e15b2 | |||
| 773e228e21 |
71
Cargo.lock
generated
71
Cargo.lock
generated
@@ -25,6 +25,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
@@ -34,7 +35,11 @@ name = "adapter-event-publisher"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
@@ -1762,6 +1767,29 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "playout"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
|
"axum",
|
||||||
|
"bytes",
|
||||||
|
"chrono",
|
||||||
|
"domain",
|
||||||
|
"dotenvy",
|
||||||
|
"futures",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1791,6 +1819,7 @@ name = "presentation"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"adapter-auth",
|
"adapter-auth",
|
||||||
|
"adapter-common",
|
||||||
"adapter-event-publisher",
|
"adapter-event-publisher",
|
||||||
"adapter-jellyfin",
|
"adapter-jellyfin",
|
||||||
"adapter-local-files",
|
"adapter-local-files",
|
||||||
@@ -1804,9 +1833,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"handlebars",
|
|
||||||
"infra-wiring",
|
"infra-wiring",
|
||||||
"reqwest",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
@@ -1816,6 +1843,8 @@ dependencies = [
|
|||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"utoipa",
|
||||||
|
"utoipa-scalar",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3002,6 +3031,18 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utoipa-scalar"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"utoipa",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.4"
|
version = "1.23.4"
|
||||||
@@ -3365,6 +3406,32 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "worker"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"adapter-auth",
|
||||||
|
"adapter-common",
|
||||||
|
"adapter-event-publisher",
|
||||||
|
"adapter-jellyfin",
|
||||||
|
"adapter-local-files",
|
||||||
|
"adapter-sqlite",
|
||||||
|
"anyhow",
|
||||||
|
"application",
|
||||||
|
"async-trait",
|
||||||
|
"chrono",
|
||||||
|
"domain",
|
||||||
|
"dotenvy",
|
||||||
|
"handlebars",
|
||||||
|
"infra-wiring",
|
||||||
|
"reqwest",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.3"
|
version = "0.6.3"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[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/mcp"]
|
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"]
|
exclude = ["k-tv-backend", "k-tv-frontend"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ tracing = "0.1"
|
|||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
utoipa = { version = "5", features = ["chrono", "uuid"] }
|
utoipa = { version = "5", features = ["chrono", "uuid"] }
|
||||||
|
utoipa-scalar = { version = "0.3", features = ["axum"] }
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
|
|
||||||
# Internal crates
|
# Internal crates
|
||||||
|
|||||||
@@ -11,24 +11,38 @@
|
|||||||
# TRAEFIK_CERT_RESOLVER cert resolver name for TLS (default: letsencrypt)
|
# TRAEFIK_CERT_RESOLVER cert resolver name for TLS (default: letsencrypt)
|
||||||
# FRONTEND_HOST public hostname for the frontend e.g. tv.example.com
|
# FRONTEND_HOST public hostname for the frontend e.g. tv.example.com
|
||||||
# BACKEND_HOST public hostname for the backend API e.g. tv-api.example.com
|
# BACKEND_HOST public hostname for the backend API e.g. tv-api.example.com
|
||||||
|
# PLAYOUT_HOST public hostname for playout streams e.g. tv-playout.example.com
|
||||||
#
|
#
|
||||||
# Remember: NEXT_PUBLIC_API_URL in .env must be the *public* backend URL,
|
# Remember: NEXT_PUBLIC_API_URL in .env must be the *public* backend URL,
|
||||||
# e.g. https://tv-api.example.com/api/v1, and you must rebuild after changing it.
|
# e.g. https://tv-api.example.com/api/v1, and you must rebuild after changing it.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
backend:
|
presentation:
|
||||||
ports: [] # Traefik handles ingress; no direct port exposure needed
|
ports: []
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- traefik
|
- traefik
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.docker.network=${TRAEFIK_NETWORK:-traefik_proxy}"
|
- "traefik.docker.network=${TRAEFIK_NETWORK:-traefik_proxy}"
|
||||||
- "traefik.http.routers.ktv-backend.rule=Host(`${BACKEND_HOST}`)"
|
- "traefik.http.routers.ktv-presentation.rule=Host(`${BACKEND_HOST}`)"
|
||||||
- "traefik.http.routers.ktv-backend.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
|
- "traefik.http.routers.ktv-presentation.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
|
||||||
- "traefik.http.routers.ktv-backend.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}"
|
- "traefik.http.routers.ktv-presentation.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}"
|
||||||
- "traefik.http.services.ktv-backend.loadbalancer.server.port=3000"
|
- "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"
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
ports: []
|
ports: []
|
||||||
|
|||||||
77
compose.yml
77
compose.yml
@@ -1,19 +1,19 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
# ── Backend (Rust / Axum) ──────────────────────────────────────────────────
|
# ── Presentation (Rust / Axum — HTTP API) ────────────────────────────────
|
||||||
backend:
|
presentation:
|
||||||
build: ./k-tv-backend
|
build:
|
||||||
|
context: ./k-tv-backend
|
||||||
|
target: presentation
|
||||||
|
image: registry.gabrielkaszewski.dev/k-tv-presentation:latest
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-3000}:3000"
|
- "${BACKEND_PORT:-3000}:3000"
|
||||||
environment:
|
environment:
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
- DATABASE_URL=sqlite:///app/data/k-tv.db?mode=rwc
|
- DATABASE_URL=sqlite:///app/data/k-tv.db?mode=rwc
|
||||||
# Allow requests from the browser (the user-facing frontend URL)
|
|
||||||
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS}
|
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS}
|
||||||
# Auth — generate with: openssl rand -hex 32
|
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
# Cookie secret — generate with: openssl rand -base64 64
|
|
||||||
- COOKIE_SECRET=${COOKIE_SECRET}
|
- COOKIE_SECRET=${COOKIE_SECRET}
|
||||||
- JWT_EXPIRY_HOURS=${JWT_EXPIRY_HOURS:-24}
|
- JWT_EXPIRY_HOURS=${JWT_EXPIRY_HOURS:-24}
|
||||||
- SECURE_COOKIE=${SECURE_COOKIE:-false}
|
- SECURE_COOKIE=${SECURE_COOKIE:-false}
|
||||||
@@ -21,7 +21,6 @@ services:
|
|||||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true}
|
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true}
|
||||||
- DB_MAX_CONNECTIONS=${DB_MAX_CONNECTIONS:-5}
|
- DB_MAX_CONNECTIONS=${DB_MAX_CONNECTIONS:-5}
|
||||||
- DB_MIN_CONNECTIONS=${DB_MIN_CONNECTIONS:-1}
|
- DB_MIN_CONNECTIONS=${DB_MIN_CONNECTIONS:-1}
|
||||||
# Jellyfin — all three required for schedule generation
|
|
||||||
- JELLYFIN_BASE_URL=${JELLYFIN_BASE_URL}
|
- JELLYFIN_BASE_URL=${JELLYFIN_BASE_URL}
|
||||||
- JELLYFIN_API_KEY=${JELLYFIN_API_KEY}
|
- JELLYFIN_API_KEY=${JELLYFIN_API_KEY}
|
||||||
- JELLYFIN_USER_ID=${JELLYFIN_USER_ID}
|
- JELLYFIN_USER_ID=${JELLYFIN_USER_ID}
|
||||||
@@ -34,40 +33,60 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
# ── Worker (background jobs) ─────────────────────────────────────────────
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: ./k-tv-backend
|
||||||
|
target: worker
|
||||||
|
image: registry.gabrielkaszewski.dev/k-tv-worker:latest
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=sqlite:///app/data/k-tv.db?mode=rwc
|
||||||
|
- JELLYFIN_BASE_URL=${JELLYFIN_BASE_URL}
|
||||||
|
- JELLYFIN_API_KEY=${JELLYFIN_API_KEY}
|
||||||
|
- JELLYFIN_USER_ID=${JELLYFIN_USER_ID}
|
||||||
|
volumes:
|
||||||
|
- backend_data:/app/data
|
||||||
|
depends_on:
|
||||||
|
presentation:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ── Playout (HLS streaming) ──────────────────────────────────────────────
|
||||||
|
playout:
|
||||||
|
build:
|
||||||
|
context: ./k-tv-backend
|
||||||
|
target: playout
|
||||||
|
image: registry.gabrielkaszewski.dev/k-tv-playout:latest
|
||||||
|
ports:
|
||||||
|
- "${PLAYOUT_PORT:-9090}:9090"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=sqlite:///app/data/k-tv.db?mode=rwc
|
||||||
|
- PLAYOUT_LISTEN_ADDR=0.0.0.0:9090
|
||||||
|
- PLAYOUT_STORAGE_PATH=/tmp/k-tv-playout
|
||||||
|
- PLAYOUT_SEGMENT_DURATION=${PLAYOUT_SEGMENT_DURATION:-6}
|
||||||
|
volumes:
|
||||||
|
- backend_data:/app/data
|
||||||
|
depends_on:
|
||||||
|
presentation:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
# ── Frontend (Next.js) ────────────────────────────────────────────────────
|
# ── Frontend (Next.js) ────────────────────────────────────────────────────
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./k-tv-frontend
|
context: ./k-tv-frontend
|
||||||
args:
|
args:
|
||||||
# Browser-visible backend URL — baked into the client bundle at build time.
|
|
||||||
# Rebuild the image after changing this.
|
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:4000/api/v1}
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:4000/api/v1}
|
||||||
|
NEXT_PUBLIC_PLAYOUT_URL: ${NEXT_PUBLIC_PLAYOUT_URL:-http://localhost:9090}
|
||||||
|
image: registry.gabrielkaszewski.dev/k-tv-frontend:latest
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-3001}:3001"
|
- "${FRONTEND_PORT:-3001}:3001"
|
||||||
environment:
|
environment:
|
||||||
# Server-side API URL — uses Docker's internal network, never exposed.
|
API_URL: http://presentation:3000/api/v1
|
||||||
# Next.js API routes (e.g. /api/stream/[channelId]) use this.
|
|
||||||
API_URL: http://backend:3000/api/v1
|
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
presentation:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
backend_data:
|
backend_data:
|
||||||
|
|
||||||
# ── Optional: PostgreSQL ───────────────────────────────────────────────────
|
|
||||||
# Uncomment the db service and set DATABASE_URL in backend's environment:
|
|
||||||
# DATABASE_URL: postgres://ktv:${POSTGRES_PASSWORD}@db:5432/ktv
|
|
||||||
#
|
|
||||||
# db:
|
|
||||||
# image: postgres:16-alpine
|
|
||||||
# environment:
|
|
||||||
# POSTGRES_USER: ktv
|
|
||||||
# POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
||||||
# POSTGRES_DB: ktv
|
|
||||||
# volumes:
|
|
||||||
# - db_data:/var/lib/postgresql/data
|
|
||||||
# restart: unless-stopped
|
|
||||||
#
|
|
||||||
# db_data:
|
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ uuid = { workspace = true }
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|||||||
180
crates/adapters/adapter-common/src/ffprobe.rs
Normal file
180
crates/adapters/adapter-common/src/ffprobe.rs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
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,5 +1,8 @@
|
|||||||
|
pub mod ffprobe;
|
||||||
|
pub mod role_detector;
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use domain::{DomainError, RecyclePolicy, ScheduleConfig, ScheduleConfigCompat};
|
use domain::{DomainError, RotationPolicy, ScheduleConfig, ScheduleConfigCompat};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -32,8 +35,8 @@ pub fn parse_schedule_config(json: &str) -> Result<ScheduleConfig, DomainError>
|
|||||||
Ok(ScheduleConfig::from(compat))
|
Ok(ScheduleConfig::from(compat))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_recycle_policy(json: &str) -> Result<RecyclePolicy, DomainError> {
|
pub fn parse_rotation_policy(json: &str) -> Result<RotationPolicy, DomainError> {
|
||||||
parse_json(json, "recycle_policy")
|
parse_json(json, "rotation_policy")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_enum_or_default<T: DeserializeOwned + Default>(value: String) -> T {
|
pub fn parse_enum_or_default<T: DeserializeOwned + Default>(value: String) -> T {
|
||||||
@@ -151,9 +154,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_recycle_policy_valid() {
|
fn parse_rotation_policy_valid() {
|
||||||
let json = r#"{"cooldown_days":7,"cooldown_generations":3,"min_available_ratio":0.3}"#;
|
let json = r#"{"cooldown_days":7,"cooldown_generations":3,"min_available_ratio":0.3}"#;
|
||||||
let policy = parse_recycle_policy(json).unwrap();
|
let policy = parse_rotation_policy(json).unwrap();
|
||||||
assert_eq!(policy.cooldown_days, Some(7));
|
assert_eq!(policy.cooldown_days, Some(7));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
158
crates/adapters/adapter-common/src/role_detector.rs
Normal file
158
crates/adapters/adapter-common/src/role_detector.rs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,3 +8,7 @@ domain = { workspace = true }
|
|||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
sqlx = { workspace = true, features = ["sqlite"] }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
|||||||
@@ -1,42 +1,170 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::errors::{DomainError, DomainResult};
|
use domain::errors::{DomainError, DomainResult};
|
||||||
use domain::events::DomainEvent;
|
use domain::events::{DomainEvent, EventEnvelope};
|
||||||
use domain::ports::events::{EventConsumer, EventPublisher};
|
use domain::ports::events::{EventConsumer, EventPublisher};
|
||||||
use tokio::sync::broadcast;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
pub struct ChannelEventBus {
|
fn event_type_label(event: &DomainEvent) -> &'static str {
|
||||||
tx: broadcast::Sender<DomainEvent>,
|
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",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChannelEventBus {
|
pub struct SqliteEventPublisher {
|
||||||
pub fn new(capacity: usize) -> Self {
|
pool: SqlitePool,
|
||||||
let (tx, _) = broadcast::channel(capacity);
|
}
|
||||||
Self { tx }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subscriber(&self) -> broadcast::Receiver<DomainEvent> {
|
impl SqliteEventPublisher {
|
||||||
self.tx.subscribe()
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
}
|
Self { pool }
|
||||||
|
|
||||||
pub fn sender(&self) -> broadcast::Sender<DomainEvent> {
|
|
||||||
self.tx.clone()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl EventPublisher for ChannelEventBus {
|
impl EventPublisher for SqliteEventPublisher {
|
||||||
async fn publish(&self, event: DomainEvent) -> DomainResult<()> {
|
async fn publish(&self, event: DomainEvent) -> DomainResult<()> {
|
||||||
let _ = self.tx.send(event);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
pub struct SqliteEventConsumer {
|
||||||
impl EventConsumer for ChannelEventBus {
|
pool: SqlitePool,
|
||||||
async fn recv(&self) -> DomainResult<DomainEvent> {
|
}
|
||||||
let mut rx = self.tx.subscribe();
|
|
||||||
rx.recv()
|
impl SqliteEventConsumer {
|
||||||
.await
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
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,4 +1,4 @@
|
|||||||
use domain::{ContentType, MediaItem, MediaItemId, MediaItemRow};
|
use domain::{ContentType, MediaItem, MediaItemId, MediaItemRow, MediaRole};
|
||||||
|
|
||||||
use crate::models::JellyfinItem;
|
use crate::models::JellyfinItem;
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ pub(crate) fn map_jellyfin_item(item: JellyfinItem) -> Option<MediaItem> {
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
Some(MediaItem::from_persistence(MediaItemRow {
|
Some(MediaItem::from_persistence(MediaItemRow {
|
||||||
id: MediaItemId::new(item.id),
|
id: MediaItemId::new(&item.id),
|
||||||
title: item.name,
|
title: item.name,
|
||||||
content_type,
|
content_type,
|
||||||
duration_secs,
|
duration_secs,
|
||||||
@@ -30,5 +30,12 @@ pub(crate) fn map_jellyfin_item(item: JellyfinItem) -> Option<MediaItem> {
|
|||||||
episode_number: item.index_number,
|
episode_number: item.index_number,
|
||||||
thumbnail_url: None,
|
thumbnail_url: None,
|
||||||
collection_id: 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(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,20 +37,6 @@ pub(crate) struct JellyfinItem {
|
|||||||
pub recursive_item_count: Option<u32>,
|
pub recursive_item_count: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub(crate) struct JellyfinPlaybackInfoResponse {
|
|
||||||
#[serde(rename = "MediaSources")]
|
|
||||||
pub media_sources: Vec<JellyfinMediaSource>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub(crate) struct JellyfinMediaSource {
|
|
||||||
#[serde(rename = "SupportsDirectStream")]
|
|
||||||
pub supports_direct_stream: bool,
|
|
||||||
#[serde(rename = "DirectStreamUrl")]
|
|
||||||
pub direct_stream_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn jellyfin_item_type(ct: &ContentType) -> &'static str {
|
pub(crate) fn jellyfin_item_type(ct: &ContentType) -> &'static str {
|
||||||
match ct {
|
match ct {
|
||||||
ContentType::Movie => "Movie",
|
ContentType::Movie => "Movie",
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
use domain::ports::{
|
use domain::ports::{Collection, IMediaProvider, ProviderCapabilities, SeriesSummary};
|
||||||
Collection, IMediaProvider, ProviderCapabilities, SeriesSummary, StreamQuality,
|
use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId, SourceUri};
|
||||||
StreamingProtocol,
|
|
||||||
};
|
|
||||||
use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId};
|
|
||||||
|
|
||||||
use crate::config::JellyfinConfig;
|
use crate::config::JellyfinConfig;
|
||||||
use crate::mapping::{map_jellyfin_item, TICKS_PER_SEC};
|
use crate::mapping::{map_jellyfin_item, TICKS_PER_SEC};
|
||||||
use crate::models::{
|
use crate::models::{jellyfin_item_type, JellyfinItemsResponse};
|
||||||
jellyfin_item_type, JellyfinItemsResponse, JellyfinPlaybackInfoResponse,
|
|
||||||
};
|
|
||||||
|
|
||||||
const FALLBACK_HLS_BITRATE: u32 = 8_000_000;
|
|
||||||
|
|
||||||
pub struct JellyfinMediaProvider {
|
pub struct JellyfinMediaProvider {
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
@@ -129,16 +122,6 @@ impl JellyfinMediaProvider {
|
|||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hls_url(&self, item_id: &MediaItemId, bitrate: u32) -> String {
|
|
||||||
format!(
|
|
||||||
"{}/Videos/{}/master.m3u8?videoCodec=h264&audioCodec=aac&VideoBitRate={}&mediaSourceId={}&SubtitleMethod=Hls&subtitleCodec=vtt&api_key={}",
|
|
||||||
self.config.base_url,
|
|
||||||
item_id.as_ref(),
|
|
||||||
bitrate,
|
|
||||||
item_id.as_ref(),
|
|
||||||
self.config.api_key,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -151,9 +134,7 @@ impl IMediaProvider for JellyfinMediaProvider {
|
|||||||
tags: true,
|
tags: true,
|
||||||
decade: true,
|
decade: true,
|
||||||
search: true,
|
search: true,
|
||||||
streaming_protocol: StreamingProtocol::Hls,
|
|
||||||
rescan: false,
|
rescan: false,
|
||||||
transcode: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,52 +331,14 @@ impl IMediaProvider for JellyfinMediaProvider {
|
|||||||
Ok(body.items.into_iter().map(|item| item.name).collect())
|
Ok(body.items.into_iter().map(|item| item.name).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_stream_url(
|
async fn get_source_uri(&self, item_id: &MediaItemId) -> DomainResult<SourceUri> {
|
||||||
&self,
|
Ok(SourceUri::NetworkUrl {
|
||||||
item_id: &MediaItemId,
|
url: format!(
|
||||||
quality: &StreamQuality,
|
"{}/Videos/{}/stream?static=true&api_key={}",
|
||||||
) -> DomainResult<String> {
|
self.config.base_url,
|
||||||
match quality {
|
item_id.as_ref(),
|
||||||
StreamQuality::Direct => {
|
self.config.api_key,
|
||||||
let url = format!(
|
),
|
||||||
"{}/Items/{}/PlaybackInfo",
|
})
|
||||||
self.config.base_url,
|
|
||||||
item_id.as_ref()
|
|
||||||
);
|
|
||||||
let resp = self
|
|
||||||
.client
|
|
||||||
.post(&url)
|
|
||||||
.header("X-Emby-Token", &self.config.api_key)
|
|
||||||
.query(&[
|
|
||||||
("userId", &self.config.user_id),
|
|
||||||
("mediaSourceId", &item_id.as_ref().to_string()),
|
|
||||||
])
|
|
||||||
.json(&serde_json::json!({}))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
DomainError::InfrastructureError(format!("PlaybackInfo failed: {e}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if resp.status().is_success() {
|
|
||||||
let info: JellyfinPlaybackInfoResponse = resp.json().await.map_err(|e| {
|
|
||||||
DomainError::InfrastructureError(format!(
|
|
||||||
"PlaybackInfo parse failed: {e}"
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
if let Some(src) = info.media_sources.first()
|
|
||||||
&& src.supports_direct_stream
|
|
||||||
&& let Some(rel_url) = &src.direct_stream_url
|
|
||||||
{
|
|
||||||
return Ok(format!(
|
|
||||||
"{}{}&api_key={}",
|
|
||||||
self.config.base_url, rel_url, self.config.api_key
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(self.hls_url(item_id, FALLBACK_HLS_BITRATE))
|
|
||||||
}
|
|
||||||
StreamQuality::Transcode(bps) => Ok(self.hls_url(item_id, *bps)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ impl LocalFilesBundle {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let provider =
|
let provider =
|
||||||
LocalFilesProvider::new(Arc::clone(&local_index), &config, transcode_manager.clone());
|
LocalFilesProvider::new(Arc::clone(&local_index), &config);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
provider,
|
provider,
|
||||||
|
|||||||
@@ -1,36 +1,23 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{Collection, IMediaProvider, ProviderCapabilities};
|
||||||
Collection, IMediaProvider, ProviderCapabilities, StreamQuality, StreamingProtocol,
|
use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId, MediaItemRow, MediaRole, SourceUri};
|
||||||
};
|
|
||||||
use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId, MediaItemRow};
|
|
||||||
|
|
||||||
use crate::config::LocalFilesConfig;
|
use crate::config::LocalFilesConfig;
|
||||||
use crate::index::{decode_id, LocalIndex};
|
use crate::index::{decode_id, LocalIndex};
|
||||||
use crate::scanner::LocalFileItem;
|
use crate::scanner::LocalFileItem;
|
||||||
use crate::transcoder::TranscodeManager;
|
|
||||||
|
|
||||||
pub struct LocalFilesProvider {
|
pub struct LocalFilesProvider {
|
||||||
pub index: Arc<LocalIndex>,
|
pub index: Arc<LocalIndex>,
|
||||||
base_url: String,
|
|
||||||
transcode_manager: Option<Arc<TranscodeManager>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SHORT_DURATION_SECS: u32 = 1200;
|
const SHORT_DURATION_SECS: u32 = 1200;
|
||||||
const DECADE_SPAN: u16 = 9;
|
const DECADE_SPAN: u16 = 9;
|
||||||
|
|
||||||
impl LocalFilesProvider {
|
impl LocalFilesProvider {
|
||||||
pub fn new(
|
pub fn new(index: Arc<LocalIndex>, _config: &LocalFilesConfig) -> Self {
|
||||||
index: Arc<LocalIndex>,
|
Self { index }
|
||||||
config: &LocalFilesConfig,
|
|
||||||
transcode_manager: Option<Arc<TranscodeManager>>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
index,
|
|
||||||
base_url: config.base_url.trim_end_matches('/').to_string(),
|
|
||||||
transcode_manager,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +41,13 @@ fn to_media_item(id: MediaItemId, item: &LocalFileItem) -> MediaItem {
|
|||||||
episode_number: None,
|
episode_number: None,
|
||||||
thumbnail_url: None,
|
thumbnail_url: None,
|
||||||
collection_id: 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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,13 +61,7 @@ impl IMediaProvider for LocalFilesProvider {
|
|||||||
tags: true,
|
tags: true,
|
||||||
decade: true,
|
decade: true,
|
||||||
search: true,
|
search: true,
|
||||||
streaming_protocol: if self.transcode_manager.is_some() {
|
|
||||||
StreamingProtocol::Hls
|
|
||||||
} else {
|
|
||||||
StreamingProtocol::DirectFile
|
|
||||||
},
|
|
||||||
rescan: true,
|
rescan: true,
|
||||||
transcode: self.transcode_manager.is_some(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,31 +137,14 @@ impl IMediaProvider for LocalFilesProvider {
|
|||||||
.map(|item| to_media_item(item_id.clone(), &item)))
|
.map(|item| to_media_item(item_id.clone(), &item)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_stream_url(
|
async fn get_source_uri(&self, item_id: &MediaItemId) -> DomainResult<SourceUri> {
|
||||||
&self,
|
let rel = decode_id(item_id).ok_or_else(|| {
|
||||||
item_id: &MediaItemId,
|
DomainError::InfrastructureError("invalid item id encoding".into())
|
||||||
quality: &StreamQuality,
|
})?;
|
||||||
) -> DomainResult<String> {
|
let abs_path = self.index.root_dir.join(&rel);
|
||||||
match quality {
|
Ok(SourceUri::FilePath {
|
||||||
StreamQuality::Transcode(_) if self.transcode_manager.is_some() => {
|
path: abs_path.to_string_lossy().into_owned(),
|
||||||
let tm = self.transcode_manager.as_ref().unwrap();
|
})
|
||||||
let rel = decode_id(item_id).ok_or_else(|| {
|
|
||||||
DomainError::InfrastructureError("invalid item id encoding".into())
|
|
||||||
})?;
|
|
||||||
let src = self.index.root_dir.join(&rel);
|
|
||||||
tm.ensure_transcoded(item_id.as_ref(), &src).await?;
|
|
||||||
Ok(format!(
|
|
||||||
"{}/api/v1/files/transcode/{}/playlist.m3u8",
|
|
||||||
self.base_url,
|
|
||||||
item_id.as_ref()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
_ => Ok(format!(
|
|
||||||
"{}/api/v1/files/stream/{}",
|
|
||||||
self.base_url,
|
|
||||||
item_id.as_ref()
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_collections(&self) -> DomainResult<Vec<Collection>> {
|
async fn list_collections(&self) -> DomainResult<Vec<Collection>> {
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ use sqlx::{Row, SqlitePool};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use adapter_common::{
|
use adapter_common::{
|
||||||
map_sqlx_error, parse_dt, parse_enum_or_default, parse_recycle_policy, parse_schedule_config,
|
map_sqlx_error, parse_dt, parse_enum_or_default, parse_rotation_policy, parse_schedule_config,
|
||||||
parse_uuid, serialize_enum_as_string,
|
parse_uuid, serialize_enum_as_string,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
ports::channel::{ChannelCommand, ChannelQuery},
|
ports::channel::{ChannelCommand, ChannelQuery},
|
||||||
AccessMode, Channel, ChannelConfigSnapshot, ChannelId, ChannelRow as DomainChannelRow,
|
AccessMode, Channel, ChannelConfigSnapshot, ChannelId, ChannelRow as DomainChannelRow,
|
||||||
DomainError, DomainResult, LogoPosition, ScheduleConfig, SnapshotId, UserId,
|
DomainError, DomainResult, LogoPosition, MediaFilter, ScheduleConfig, SnapshotId, UserId,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct SqliteChannelRepository {
|
pub struct SqliteChannelRepository {
|
||||||
@@ -23,7 +23,7 @@ impl SqliteChannelRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SELECT_COLS: &str = "id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, webhook_headers, created_at, updated_at";
|
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)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
struct ChannelRow {
|
struct ChannelRow {
|
||||||
@@ -33,10 +33,9 @@ struct ChannelRow {
|
|||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
timezone: String,
|
timezone: String,
|
||||||
schedule_config: String,
|
schedule_config: String,
|
||||||
recycle_policy: String,
|
rotation_policy: String,
|
||||||
auto_schedule: i64,
|
auto_schedule: i64,
|
||||||
access_mode: String,
|
access_mode: String,
|
||||||
access_password_hash: Option<String>,
|
|
||||||
logo: Option<String>,
|
logo: Option<String>,
|
||||||
logo_position: String,
|
logo_position: String,
|
||||||
logo_opacity: f32,
|
logo_opacity: f32,
|
||||||
@@ -44,6 +43,7 @@ struct ChannelRow {
|
|||||||
webhook_poll_interval_secs: i64,
|
webhook_poll_interval_secs: i64,
|
||||||
webhook_body_template: Option<String>,
|
webhook_body_template: Option<String>,
|
||||||
webhook_headers: Option<String>,
|
webhook_headers: Option<String>,
|
||||||
|
gap_filler: Option<String>,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -53,10 +53,15 @@ impl ChannelRow {
|
|||||||
let id = ChannelId::from_uuid(parse_uuid(&self.id, "channel id")?);
|
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 owner_id = UserId::from_uuid(parse_uuid(&self.owner_id, "owner id")?);
|
||||||
let schedule_config = parse_schedule_config(&self.schedule_config)?;
|
let schedule_config = parse_schedule_config(&self.schedule_config)?;
|
||||||
let recycle_policy = parse_recycle_policy(&self.recycle_policy)?;
|
let rotation_policy = parse_rotation_policy(&self.rotation_policy)?;
|
||||||
let access_mode: AccessMode = parse_enum_or_default(self.access_mode);
|
let access_mode: AccessMode = parse_enum_or_default(self.access_mode);
|
||||||
let logo_position: LogoPosition = parse_enum_or_default(self.logo_position);
|
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 {
|
Ok(Channel::from_persistence(DomainChannelRow {
|
||||||
id,
|
id,
|
||||||
owner_id,
|
owner_id,
|
||||||
@@ -64,10 +69,9 @@ impl ChannelRow {
|
|||||||
description: self.description,
|
description: self.description,
|
||||||
timezone: self.timezone,
|
timezone: self.timezone,
|
||||||
schedule_config,
|
schedule_config,
|
||||||
recycle_policy,
|
rotation_policy,
|
||||||
auto_schedule: self.auto_schedule != 0,
|
auto_schedule: self.auto_schedule != 0,
|
||||||
access_mode,
|
access_mode,
|
||||||
access_password_hash: self.access_password_hash,
|
|
||||||
logo: self.logo,
|
logo: self.logo,
|
||||||
logo_position,
|
logo_position,
|
||||||
logo_opacity: self.logo_opacity,
|
logo_opacity: self.logo_opacity,
|
||||||
@@ -75,6 +79,7 @@ impl ChannelRow {
|
|||||||
webhook_poll_interval_secs: self.webhook_poll_interval_secs as u32,
|
webhook_poll_interval_secs: self.webhook_poll_interval_secs as u32,
|
||||||
webhook_body_template: self.webhook_body_template,
|
webhook_body_template: self.webhook_body_template,
|
||||||
webhook_headers: self.webhook_headers,
|
webhook_headers: self.webhook_headers,
|
||||||
|
gap_filler,
|
||||||
created_at: parse_dt(&self.created_at)?,
|
created_at: parse_dt(&self.created_at)?,
|
||||||
updated_at: parse_dt(&self.updated_at)?,
|
updated_at: parse_dt(&self.updated_at)?,
|
||||||
}))
|
}))
|
||||||
@@ -109,28 +114,31 @@ impl ChannelCommand for SqliteChannelRepository {
|
|||||||
async fn save(&self, channel: &Channel) -> DomainResult<()> {
|
async fn save(&self, channel: &Channel) -> DomainResult<()> {
|
||||||
let schedule_config = serde_json::to_string(channel.schedule_config())
|
let schedule_config = serde_json::to_string(channel.schedule_config())
|
||||||
.map_err(|e| DomainError::RepositoryError(format!("serialize schedule_config: {e}")))?;
|
.map_err(|e| DomainError::RepositoryError(format!("serialize schedule_config: {e}")))?;
|
||||||
let recycle_policy = serde_json::to_string(channel.recycle_policy())
|
let rotation_policy = serde_json::to_string(channel.rotation_policy())
|
||||||
.map_err(|e| DomainError::RepositoryError(format!("serialize recycle_policy: {e}")))?;
|
.map_err(|e| DomainError::RepositoryError(format!("serialize rotation_policy: {e}")))?;
|
||||||
let access_mode = serialize_enum_as_string(channel.access_mode(), "public");
|
let access_mode = serialize_enum_as_string(channel.access_mode(), "public");
|
||||||
let logo_position = serialize_enum_as_string(channel.logo_position(), "top_right");
|
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(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO channels
|
INSERT INTO channels
|
||||||
(id, owner_id, name, description, timezone, schedule_config, recycle_policy,
|
(id, owner_id, name, description, timezone, schedule_config, rotation_policy,
|
||||||
auto_schedule, access_mode, access_password_hash, logo, logo_position,
|
auto_schedule, access_mode, logo, logo_position,
|
||||||
logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template,
|
logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template,
|
||||||
webhook_headers, created_at, updated_at)
|
webhook_headers, gap_filler, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
description = excluded.description,
|
description = excluded.description,
|
||||||
timezone = excluded.timezone,
|
timezone = excluded.timezone,
|
||||||
schedule_config = excluded.schedule_config,
|
schedule_config = excluded.schedule_config,
|
||||||
recycle_policy = excluded.recycle_policy,
|
rotation_policy = excluded.rotation_policy,
|
||||||
auto_schedule = excluded.auto_schedule,
|
auto_schedule = excluded.auto_schedule,
|
||||||
access_mode = excluded.access_mode,
|
access_mode = excluded.access_mode,
|
||||||
access_password_hash = excluded.access_password_hash,
|
|
||||||
logo = excluded.logo,
|
logo = excluded.logo,
|
||||||
logo_position = excluded.logo_position,
|
logo_position = excluded.logo_position,
|
||||||
logo_opacity = excluded.logo_opacity,
|
logo_opacity = excluded.logo_opacity,
|
||||||
@@ -138,6 +146,7 @@ impl ChannelCommand for SqliteChannelRepository {
|
|||||||
webhook_poll_interval_secs = excluded.webhook_poll_interval_secs,
|
webhook_poll_interval_secs = excluded.webhook_poll_interval_secs,
|
||||||
webhook_body_template = excluded.webhook_body_template,
|
webhook_body_template = excluded.webhook_body_template,
|
||||||
webhook_headers = excluded.webhook_headers,
|
webhook_headers = excluded.webhook_headers,
|
||||||
|
gap_filler = excluded.gap_filler,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -147,10 +156,9 @@ impl ChannelCommand for SqliteChannelRepository {
|
|||||||
.bind(channel.description())
|
.bind(channel.description())
|
||||||
.bind(channel.timezone())
|
.bind(channel.timezone())
|
||||||
.bind(&schedule_config)
|
.bind(&schedule_config)
|
||||||
.bind(&recycle_policy)
|
.bind(&rotation_policy)
|
||||||
.bind(channel.auto_schedule() as i64)
|
.bind(channel.auto_schedule() as i64)
|
||||||
.bind(&access_mode)
|
.bind(&access_mode)
|
||||||
.bind(channel.access_password_hash())
|
|
||||||
.bind(channel.logo())
|
.bind(channel.logo())
|
||||||
.bind(&logo_position)
|
.bind(&logo_position)
|
||||||
.bind(channel.logo_opacity())
|
.bind(channel.logo_opacity())
|
||||||
@@ -158,6 +166,7 @@ impl ChannelCommand for SqliteChannelRepository {
|
|||||||
.bind(channel.webhook_poll_interval_secs() as i64)
|
.bind(channel.webhook_poll_interval_secs() as i64)
|
||||||
.bind(channel.webhook_body_template())
|
.bind(channel.webhook_body_template())
|
||||||
.bind(channel.webhook_headers())
|
.bind(channel.webhook_headers())
|
||||||
|
.bind(&gap_filler_json)
|
||||||
.bind(channel.created_at().to_rfc3339())
|
.bind(channel.created_at().to_rfc3339())
|
||||||
.bind(channel.updated_at().to_rfc3339())
|
.bind(channel.updated_at().to_rfc3339())
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use adapter_common::{content_type_str, parse_content_type, parse_genres_blob};
|
use adapter_common::{content_type_str, parse_content_type, parse_enum_or_default, parse_genres_blob, serialize_enum_as_string};
|
||||||
use domain::{
|
use domain::{
|
||||||
ports::library::{LibraryCommand, LibraryQuery},
|
ports::library::{LibraryCommand, LibraryQuery},
|
||||||
ContentType, DomainError, DomainResult, LibraryCollection, LibraryItem,
|
ContentType, DomainError, DomainResult, LibraryCollection,
|
||||||
LibraryItemRow as DomainLibraryItemRow, LibrarySearchFilter, LibrarySyncLogEntry,
|
LibrarySearchFilter, LibrarySyncLogEntry,
|
||||||
LibrarySyncResult, SeasonSummary, ShowSummary,
|
LibrarySyncResult, MediaItem, MediaItemRow as DomainMediaItemRow,
|
||||||
|
MediaRole, SeasonSummary, ShowSummary,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct SqliteLibraryRepository {
|
pub struct SqliteLibraryRepository {
|
||||||
@@ -38,17 +39,24 @@ struct LibraryItemRow {
|
|||||||
collection_type: Option<String>,
|
collection_type: Option<String>,
|
||||||
thumbnail_url: Option<String>,
|
thumbnail_url: Option<String>,
|
||||||
synced_at: String,
|
synced_at: String,
|
||||||
|
chapters: Option<String>,
|
||||||
|
role: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LibraryItemRow {
|
impl LibraryItemRow {
|
||||||
fn into_library_item(self) -> LibraryItem {
|
fn into_media_item(self) -> MediaItem {
|
||||||
LibraryItem::from_persistence(DomainLibraryItemRow {
|
let role: MediaRole = self
|
||||||
id: self.id,
|
.role
|
||||||
|
.map(parse_enum_or_default)
|
||||||
|
.unwrap_or_default();
|
||||||
|
MediaItem::from_persistence(DomainMediaItemRow {
|
||||||
|
id: domain::MediaItemId::new(&self.id),
|
||||||
provider_id: self.provider_id,
|
provider_id: self.provider_id,
|
||||||
external_id: self.external_id,
|
external_id: self.external_id,
|
||||||
title: self.title,
|
title: self.title,
|
||||||
content_type: parse_content_type(&self.content_type),
|
content_type: parse_content_type(&self.content_type),
|
||||||
duration_secs: self.duration_secs as u32,
|
duration_secs: self.duration_secs as u32,
|
||||||
|
description: None,
|
||||||
series_name: self.series_name,
|
series_name: self.series_name,
|
||||||
season_number: self.season_number.map(|n| n as u32),
|
season_number: self.season_number.map(|n| n as u32),
|
||||||
episode_number: self.episode_number.map(|n| n as u32),
|
episode_number: self.episode_number.map(|n| n as u32),
|
||||||
@@ -59,7 +67,13 @@ impl LibraryItemRow {
|
|||||||
collection_name: self.collection_name,
|
collection_name: self.collection_name,
|
||||||
collection_type: self.collection_type,
|
collection_type: self.collection_type,
|
||||||
thumbnail_url: self.thumbnail_url,
|
thumbnail_url: self.thumbnail_url,
|
||||||
synced_at: self.synced_at,
|
synced_at: Some(self.synced_at),
|
||||||
|
role,
|
||||||
|
chapters: self
|
||||||
|
.chapters
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str(s).ok())
|
||||||
|
.unwrap_or_default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +107,7 @@ struct SeasonSummaryRow {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LibraryCommand for SqliteLibraryRepository {
|
impl LibraryCommand for SqliteLibraryRepository {
|
||||||
async fn upsert_items(&self, _provider_id: &str, items: Vec<LibraryItem>) -> DomainResult<()> {
|
async fn upsert_items(&self, _provider_id: &str, items: Vec<MediaItem>) -> DomainResult<()> {
|
||||||
let mut tx = self
|
let mut tx = self
|
||||||
.pool
|
.pool
|
||||||
.begin()
|
.begin()
|
||||||
@@ -101,14 +115,22 @@ impl LibraryCommand for SqliteLibraryRepository {
|
|||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
for item in items {
|
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(
|
sqlx::query(
|
||||||
"INSERT OR REPLACE INTO library_items
|
"INSERT OR REPLACE INTO library_items
|
||||||
(id, provider_id, external_id, title, content_type, duration_secs,
|
(id, provider_id, external_id, title, content_type, duration_secs,
|
||||||
series_name, season_number, episode_number, year, genres, tags,
|
series_name, season_number, episode_number, year, genres, tags,
|
||||||
collection_id, collection_name, collection_type, thumbnail_url, synced_at)
|
collection_id, collection_name, collection_type, thumbnail_url, synced_at, chapters, role)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
)
|
)
|
||||||
.bind(item.id())
|
.bind(item.id().value())
|
||||||
.bind(item.provider_id())
|
.bind(item.provider_id())
|
||||||
.bind(item.external_id())
|
.bind(item.external_id())
|
||||||
.bind(item.title())
|
.bind(item.title())
|
||||||
@@ -124,7 +146,9 @@ impl LibraryCommand for SqliteLibraryRepository {
|
|||||||
.bind(item.collection_name())
|
.bind(item.collection_name())
|
||||||
.bind(item.collection_type())
|
.bind(item.collection_type())
|
||||||
.bind(item.thumbnail_url())
|
.bind(item.thumbnail_url())
|
||||||
.bind(item.synced_at())
|
.bind(item.synced_at().unwrap_or(""))
|
||||||
|
.bind(&chapters_json)
|
||||||
|
.bind(&role_str)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
@@ -135,6 +159,22 @@ impl LibraryCommand for SqliteLibraryRepository {
|
|||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
.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<()> {
|
async fn clear_provider(&self, provider_id: &str) -> DomainResult<()> {
|
||||||
sqlx::query("DELETE FROM library_items WHERE provider_id = ?")
|
sqlx::query("DELETE FROM library_items WHERE provider_id = ?")
|
||||||
.bind(provider_id)
|
.bind(provider_id)
|
||||||
@@ -187,7 +227,7 @@ impl LibraryQuery for SqliteLibraryRepository {
|
|||||||
async fn search(
|
async fn search(
|
||||||
&self,
|
&self,
|
||||||
filter: &LibrarySearchFilter,
|
filter: &LibrarySearchFilter,
|
||||||
) -> DomainResult<(Vec<LibraryItem>, u32)> {
|
) -> DomainResult<(Vec<MediaItem>, u32)> {
|
||||||
let mut conditions: Vec<String> = vec![];
|
let mut conditions: Vec<String> = vec![];
|
||||||
|
|
||||||
if let Some(p) = filter.provider_id() {
|
if let Some(p) = filter.provider_id() {
|
||||||
@@ -233,6 +273,19 @@ impl LibraryQuery for SqliteLibraryRepository {
|
|||||||
.collect();
|
.collect();
|
||||||
conditions.push(format!("({})", genre_conditions.join(" OR ")));
|
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() {
|
if let Some(sn) = filter.season_number() {
|
||||||
conditions.push(format!("season_number = {}", sn));
|
conditions.push(format!("season_number = {}", sn));
|
||||||
}
|
}
|
||||||
@@ -263,19 +316,19 @@ impl LibraryQuery for SqliteLibraryRepository {
|
|||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(LibraryItemRow::into_library_item)
|
.map(LibraryItemRow::into_media_item)
|
||||||
.collect(),
|
.collect(),
|
||||||
total as u32,
|
total as u32,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_by_id(&self, id: &str) -> DomainResult<Option<LibraryItem>> {
|
async fn get_by_id(&self, id: &str) -> DomainResult<Option<MediaItem>> {
|
||||||
let row = sqlx::query_as::<_, LibraryItemRow>("SELECT * FROM library_items WHERE id = ?")
|
let row = sqlx::query_as::<_, LibraryItemRow>("SELECT * FROM library_items WHERE id = ?")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
Ok(row.map(LibraryItemRow::into_library_item))
|
Ok(row.map(LibraryItemRow::into_media_item))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_collections(
|
async fn list_collections(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::{IntoParams, ToSchema};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
@@ -17,7 +17,7 @@ pub struct ActivityEventResponse {
|
|||||||
pub channel_id: Option<Uuid>,
|
pub channel_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, ToSchema)]
|
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||||
pub struct ActivityLogParams {
|
pub struct ActivityLogParams {
|
||||||
pub limit: Option<u32>,
|
pub limit: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ pub struct CreateChannelRequest {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub timezone: String,
|
pub timezone: String,
|
||||||
pub access_mode: Option<String>,
|
pub access_mode: Option<String>,
|
||||||
pub access_password: Option<String>,
|
|
||||||
pub webhook_url: Option<String>,
|
pub webhook_url: Option<String>,
|
||||||
pub webhook_poll_interval_secs: Option<u32>,
|
pub webhook_poll_interval_secs: Option<u32>,
|
||||||
pub webhook_body_template: Option<String>,
|
pub webhook_body_template: Option<String>,
|
||||||
@@ -26,10 +25,9 @@ pub struct UpdateChannelRequest {
|
|||||||
#[schema(value_type = Option<Object>)]
|
#[schema(value_type = Option<Object>)]
|
||||||
pub schedule_config: Option<domain::models::ScheduleConfigCompat>,
|
pub schedule_config: Option<domain::models::ScheduleConfigCompat>,
|
||||||
#[schema(value_type = Option<Object>)]
|
#[schema(value_type = Option<Object>)]
|
||||||
pub recycle_policy: Option<domain::RecyclePolicy>,
|
pub rotation_policy: Option<domain::RotationPolicy>,
|
||||||
pub auto_schedule: Option<bool>,
|
pub auto_schedule: Option<bool>,
|
||||||
pub access_mode: Option<String>,
|
pub access_mode: Option<String>,
|
||||||
pub access_password: Option<String>,
|
|
||||||
pub logo: Option<Option<String>>,
|
pub logo: Option<Option<String>>,
|
||||||
pub logo_position: Option<String>,
|
pub logo_position: Option<String>,
|
||||||
pub logo_opacity: Option<f32>,
|
pub logo_opacity: Option<f32>,
|
||||||
@@ -37,6 +35,8 @@ pub struct UpdateChannelRequest {
|
|||||||
pub webhook_poll_interval_secs: Option<u32>,
|
pub webhook_poll_interval_secs: Option<u32>,
|
||||||
pub webhook_body_template: Option<Option<String>>,
|
pub webhook_body_template: Option<Option<String>>,
|
||||||
pub webhook_headers: 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
@@ -47,7 +47,7 @@ pub struct ChannelResponse {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub timezone: String,
|
pub timezone: String,
|
||||||
pub schedule_config: serde_json::Value,
|
pub schedule_config: serde_json::Value,
|
||||||
pub recycle_policy: serde_json::Value,
|
pub rotation_policy: serde_json::Value,
|
||||||
pub auto_schedule: bool,
|
pub auto_schedule: bool,
|
||||||
pub access_mode: String,
|
pub access_mode: String,
|
||||||
pub logo: Option<String>,
|
pub logo: Option<String>,
|
||||||
@@ -57,6 +57,8 @@ pub struct ChannelResponse {
|
|||||||
pub webhook_poll_interval_secs: u32,
|
pub webhook_poll_interval_secs: u32,
|
||||||
pub webhook_body_template: Option<String>,
|
pub webhook_body_template: Option<String>,
|
||||||
pub webhook_headers: 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 created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
@@ -70,7 +72,7 @@ impl From<domain::Channel> for ChannelResponse {
|
|||||||
description: c.description().map(|s| s.to_string()),
|
description: c.description().map(|s| s.to_string()),
|
||||||
timezone: c.timezone().to_string(),
|
timezone: c.timezone().to_string(),
|
||||||
schedule_config: serde_json::to_value(c.schedule_config()).unwrap_or_default(),
|
schedule_config: serde_json::to_value(c.schedule_config()).unwrap_or_default(),
|
||||||
recycle_policy: serde_json::to_value(c.recycle_policy()).unwrap_or_default(),
|
rotation_policy: serde_json::to_value(c.rotation_policy()).unwrap_or_default(),
|
||||||
auto_schedule: c.auto_schedule(),
|
auto_schedule: c.auto_schedule(),
|
||||||
access_mode: enum_to_string(c.access_mode()),
|
access_mode: enum_to_string(c.access_mode()),
|
||||||
logo: c.logo().map(|s| s.to_string()),
|
logo: c.logo().map(|s| s.to_string()),
|
||||||
@@ -80,6 +82,7 @@ impl From<domain::Channel> for ChannelResponse {
|
|||||||
webhook_poll_interval_secs: c.webhook_poll_interval_secs(),
|
webhook_poll_interval_secs: c.webhook_poll_interval_secs(),
|
||||||
webhook_body_template: c.webhook_body_template().map(|s| s.to_string()),
|
webhook_body_template: c.webhook_body_template().map(|s| s.to_string()),
|
||||||
webhook_headers: c.webhook_headers().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(),
|
created_at: c.created_at(),
|
||||||
updated_at: c.updated_at(),
|
updated_at: c.updated_at(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::common::enum_to_string;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct ProviderCapabilitiesResponse {
|
pub struct ProviderCapabilitiesResponse {
|
||||||
pub collections: bool,
|
pub collections: bool,
|
||||||
@@ -11,9 +9,7 @@ pub struct ProviderCapabilitiesResponse {
|
|||||||
pub tags: bool,
|
pub tags: bool,
|
||||||
pub decade: bool,
|
pub decade: bool,
|
||||||
pub search: bool,
|
pub search: bool,
|
||||||
pub streaming_protocol: String,
|
|
||||||
pub rescan: bool,
|
pub rescan: bool,
|
||||||
pub transcode: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<domain::ports::ProviderCapabilities> for ProviderCapabilitiesResponse {
|
impl From<domain::ports::ProviderCapabilities> for ProviderCapabilitiesResponse {
|
||||||
@@ -25,9 +21,7 @@ impl From<domain::ports::ProviderCapabilities> for ProviderCapabilitiesResponse
|
|||||||
tags: c.tags,
|
tags: c.tags,
|
||||||
decade: c.decade,
|
decade: c.decade,
|
||||||
search: c.search,
|
search: c.search,
|
||||||
streaming_protocol: enum_to_string(&c.streaming_protocol),
|
|
||||||
rescan: c.rescan,
|
rescan: c.rescan,
|
||||||
transcode: c.transcode,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use utoipa::ToSchema;
|
use utoipa::{IntoParams, ToSchema};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, ToSchema)]
|
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||||
pub struct IptvParams {
|
pub struct IptvParams {
|
||||||
pub token: Option<String>,
|
pub token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ pub use config::{ConfigResponse, ProviderCapabilitiesResponse, ProviderInfo};
|
|||||||
pub use iptv::IptvParams;
|
pub use iptv::IptvParams;
|
||||||
pub use library::{
|
pub use library::{
|
||||||
CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, ProviderParam,
|
CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, ProviderParam,
|
||||||
SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry,
|
SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry, UpdateRoleRequest,
|
||||||
};
|
};
|
||||||
pub use providers::{ProviderConfigRequest, ProviderConfigResponse};
|
pub use providers::{ProviderConfigRequest, ProviderConfigResponse};
|
||||||
pub use schedule::{
|
pub use schedule::{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::{IntoParams, ToSchema};
|
||||||
|
|
||||||
use crate::common::enum_to_string;
|
use crate::common::enum_to_string;
|
||||||
|
|
||||||
@@ -21,13 +21,14 @@ pub struct LibraryItemResponse {
|
|||||||
pub collection_name: Option<String>,
|
pub collection_name: Option<String>,
|
||||||
pub collection_type: Option<String>,
|
pub collection_type: Option<String>,
|
||||||
pub thumbnail_url: Option<String>,
|
pub thumbnail_url: Option<String>,
|
||||||
pub synced_at: String,
|
pub synced_at: Option<String>,
|
||||||
|
pub role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<domain::LibraryItem> for LibraryItemResponse {
|
impl From<domain::MediaItem> for LibraryItemResponse {
|
||||||
fn from(i: domain::LibraryItem) -> Self {
|
fn from(i: domain::MediaItem) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: i.id().to_string(),
|
id: i.id().value().to_string(),
|
||||||
provider_id: i.provider_id().to_string(),
|
provider_id: i.provider_id().to_string(),
|
||||||
external_id: i.external_id().to_string(),
|
external_id: i.external_id().to_string(),
|
||||||
title: i.title().to_string(),
|
title: i.title().to_string(),
|
||||||
@@ -43,7 +44,8 @@ impl From<domain::LibraryItem> for LibraryItemResponse {
|
|||||||
collection_name: i.collection_name().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()),
|
collection_type: i.collection_type().map(|s| s.to_string()),
|
||||||
thumbnail_url: i.thumbnail_url().map(|s| s.to_string()),
|
thumbnail_url: i.thumbnail_url().map(|s| s.to_string()),
|
||||||
synced_at: i.synced_at().to_string(),
|
synced_at: i.synced_at().map(|s| s.to_string()),
|
||||||
|
role: enum_to_string(i.role()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,7 +128,7 @@ impl From<domain::LibrarySyncLogEntry> for SyncStatusEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, ToSchema)]
|
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||||
pub struct LibrarySearchParams {
|
pub struct LibrarySearchParams {
|
||||||
pub provider: Option<String>,
|
pub provider: Option<String>,
|
||||||
pub content_type: Option<String>,
|
pub content_type: Option<String>,
|
||||||
@@ -142,12 +144,12 @@ pub struct LibrarySearchParams {
|
|||||||
pub limit: Option<u32>,
|
pub limit: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, ToSchema)]
|
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||||
pub struct ProviderParam {
|
pub struct ProviderParam {
|
||||||
pub provider: Option<String>,
|
pub provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, ToSchema)]
|
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||||
pub struct ShowsParams {
|
pub struct ShowsParams {
|
||||||
pub provider: Option<String>,
|
pub provider: Option<String>,
|
||||||
pub search_term: Option<String>,
|
pub search_term: Option<String>,
|
||||||
@@ -155,14 +157,19 @@ pub struct ShowsParams {
|
|||||||
pub genres: Vec<String>,
|
pub genres: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, ToSchema)]
|
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||||
pub struct SeasonsParams {
|
pub struct SeasonsParams {
|
||||||
pub series_name: String,
|
pub series_name: String,
|
||||||
pub provider: Option<String>,
|
pub provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, ToSchema)]
|
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||||
pub struct GenresParams {
|
pub struct GenresParams {
|
||||||
pub content_type: Option<String>,
|
pub content_type: Option<String>,
|
||||||
pub provider: Option<String>,
|
pub provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateRoleRequest {
|
||||||
|
pub role: String,
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::common::enum_to_string;
|
use crate::common::enum_to_string;
|
||||||
|
|
||||||
const DEFAULT_ACCESS_MODE: &str = "public";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct MediaItemResponse {
|
pub struct MediaItemResponse {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -47,8 +45,6 @@ pub struct SlotResponse {
|
|||||||
pub end_at: DateTime<Utc>,
|
pub end_at: DateTime<Utc>,
|
||||||
pub item: MediaItemResponse,
|
pub item: MediaItemResponse,
|
||||||
pub source_block_id: Uuid,
|
pub source_block_id: Uuid,
|
||||||
#[serde(default)]
|
|
||||||
pub block_access_mode: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<domain::ScheduledSlot> for SlotResponse {
|
impl From<domain::ScheduledSlot> for SlotResponse {
|
||||||
@@ -59,26 +55,6 @@ impl From<domain::ScheduledSlot> for SlotResponse {
|
|||||||
end_at: s.end_at(),
|
end_at: s.end_at(),
|
||||||
item: s.item().clone().into(),
|
item: s.item().clone().into(),
|
||||||
source_block_id: s.source_block_id().value(),
|
source_block_id: s.source_block_id().value(),
|
||||||
block_access_mode: String::from(DEFAULT_ACCESS_MODE),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SlotResponse {
|
|
||||||
pub fn with_block_access(slot: domain::ScheduledSlot, channel: &domain::Channel) -> Self {
|
|
||||||
let block_access_mode = channel
|
|
||||||
.schedule_config()
|
|
||||||
.all_blocks()
|
|
||||||
.find(|b| b.id() == slot.source_block_id())
|
|
||||||
.map(|b| enum_to_string(b.access_mode()))
|
|
||||||
.unwrap_or_else(|| String::from(DEFAULT_ACCESS_MODE));
|
|
||||||
Self {
|
|
||||||
id: slot.id().value(),
|
|
||||||
start_at: slot.start_at(),
|
|
||||||
end_at: slot.end_at(),
|
|
||||||
item: slot.item().clone().into(),
|
|
||||||
source_block_id: slot.source_block_id().value(),
|
|
||||||
block_access_mode,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,7 +63,6 @@ impl SlotResponse {
|
|||||||
pub struct CurrentBroadcastResponse {
|
pub struct CurrentBroadcastResponse {
|
||||||
pub slot: SlotResponse,
|
pub slot: SlotResponse,
|
||||||
pub offset_secs: u32,
|
pub offset_secs: u32,
|
||||||
pub block_access_mode: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
use domain::models::ActivityEvent;
|
|
||||||
use domain::DomainResult;
|
|
||||||
|
|
||||||
use super::deps::AdminDeps;
|
|
||||||
use super::queries::GetActivityLogQuery;
|
|
||||||
|
|
||||||
pub async fn execute(
|
|
||||||
deps: &AdminDeps,
|
|
||||||
query: GetActivityLogQuery,
|
|
||||||
) -> DomainResult<Vec<ActivityEvent>> {
|
|
||||||
deps.activity_query.recent(query.limit).await
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
use domain::DomainResult;
|
|
||||||
|
|
||||||
use super::deps::AdminDeps;
|
|
||||||
use super::queries::GetSettingsQuery;
|
|
||||||
|
|
||||||
pub async fn execute(
|
|
||||||
deps: &AdminDeps,
|
|
||||||
_query: GetSettingsQuery,
|
|
||||||
) -> DomainResult<Vec<(String, String)>> {
|
|
||||||
deps.settings_repo.get_all().await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/get_settings.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
pub mod activity_log;
|
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod deps;
|
pub mod deps;
|
||||||
pub mod get_settings;
|
|
||||||
pub mod queries;
|
|
||||||
pub mod update_settings;
|
pub mod update_settings;
|
||||||
|
|
||||||
pub use commands::UpdateSettingsCommand;
|
pub use commands::UpdateSettingsCommand;
|
||||||
pub use deps::AdminDeps;
|
pub use deps::AdminDeps;
|
||||||
pub use queries::{GetActivityLogQuery, GetSettingsQuery};
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
pub struct GetSettingsQuery;
|
|
||||||
|
|
||||||
pub struct GetActivityLogQuery {
|
|
||||||
pub limit: u32,
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use domain::testing::{InMemoryActivityLog, InMemoryAppSettings};
|
|
||||||
|
|
||||||
use crate::admin::commands::UpdateSettingsCommand;
|
|
||||||
use crate::admin::deps::AdminDeps;
|
|
||||||
use crate::admin::queries::GetSettingsQuery;
|
|
||||||
use crate::admin::{get_settings, update_settings};
|
|
||||||
|
|
||||||
fn make_deps() -> AdminDeps {
|
|
||||||
AdminDeps {
|
|
||||||
settings_repo: Arc::new(InMemoryAppSettings::new()),
|
|
||||||
activity_query: Arc::new(InMemoryActivityLog::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_empty_settings() {
|
|
||||||
let deps = make_deps();
|
|
||||||
let settings = get_settings::execute(&deps, GetSettingsQuery).await.unwrap();
|
|
||||||
assert!(settings.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_returns_stored_settings() {
|
|
||||||
let deps = make_deps();
|
|
||||||
|
|
||||||
update_settings::execute(
|
|
||||||
&deps,
|
|
||||||
UpdateSettingsCommand {
|
|
||||||
settings: vec![
|
|
||||||
("a".into(), "1".into()),
|
|
||||||
("b".into(), "2".into()),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let settings = get_settings::execute(&deps, GetSettingsQuery).await.unwrap();
|
|
||||||
assert_eq!(settings.len(), 2);
|
|
||||||
|
|
||||||
let keys: Vec<&str> = settings.iter().map(|(k, _)| k.as_str()).collect();
|
|
||||||
assert!(keys.contains(&"a"));
|
|
||||||
assert!(keys.contains(&"b"));
|
|
||||||
}
|
|
||||||
@@ -121,10 +121,10 @@ async fn login_fails_for_unknown_email() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn login_fails_for_oidc_only_user() {
|
async fn login_fails_for_user_without_password() {
|
||||||
let repo = Arc::new(InMemoryUserRepository::new());
|
let repo = Arc::new(InMemoryUserRepository::new());
|
||||||
let email = Email::new("oidc@example.com").unwrap();
|
let email = Email::new("external@example.com").unwrap();
|
||||||
let user = domain::models::User::new("oidc|subject", email);
|
let user = domain::models::User::new("external|subject", email);
|
||||||
repo.store.lock().unwrap().insert(user.id(), user);
|
repo.store.lock().unwrap().insert(user.id(), user);
|
||||||
|
|
||||||
let deps = AuthDeps {
|
let deps = AuthDeps {
|
||||||
@@ -138,7 +138,7 @@ async fn login_fails_for_oidc_only_user() {
|
|||||||
let result = login::execute(
|
let result = login::execute(
|
||||||
&deps,
|
&deps,
|
||||||
LoginCommand {
|
LoginCommand {
|
||||||
email: "oidc@example.com".into(),
|
email: "external@example.com".into(),
|
||||||
password: "password123".into(),
|
password: "password123".into(),
|
||||||
remember_me: false,
|
remember_me: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use domain::models::ScheduleConfig;
|
use domain::models::ScheduleConfig;
|
||||||
use domain::value_objects::{ChannelId, RecyclePolicy, UserId};
|
use domain::value_objects::{ChannelId, MediaFilter, RotationPolicy, UserId};
|
||||||
|
|
||||||
pub struct CreateChannelCommand {
|
pub struct CreateChannelCommand {
|
||||||
pub owner_id: UserId,
|
pub owner_id: UserId,
|
||||||
@@ -14,8 +14,9 @@ pub struct UpdateChannelCommand {
|
|||||||
pub description: Option<Option<String>>,
|
pub description: Option<Option<String>>,
|
||||||
pub timezone: Option<String>,
|
pub timezone: Option<String>,
|
||||||
pub schedule_config: Option<ScheduleConfig>,
|
pub schedule_config: Option<ScheduleConfig>,
|
||||||
pub recycle_policy: Option<RecyclePolicy>,
|
pub rotation_policy: Option<RotationPolicy>,
|
||||||
pub auto_schedule: Option<bool>,
|
pub auto_schedule: Option<bool>,
|
||||||
|
pub gap_filler: Option<Option<MediaFilter>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DeleteChannelCommand {
|
pub struct DeleteChannelCommand {
|
||||||
|
|||||||
@@ -7,7 +7,3 @@ pub struct ChannelCommandDeps {
|
|||||||
pub channel_query: Arc<dyn ChannelQuery>,
|
pub channel_query: Arc<dyn ChannelQuery>,
|
||||||
pub event_publisher: Arc<dyn EventPublisher>,
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ChannelQueryDeps {
|
|
||||||
pub channel_query: Arc<dyn ChannelQuery>,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
use domain::models::Channel;
|
|
||||||
use domain::DomainResult;
|
|
||||||
|
|
||||||
use super::deps::ChannelQueryDeps;
|
|
||||||
use super::queries::GetChannelQuery;
|
|
||||||
|
|
||||||
pub async fn execute(deps: &ChannelQueryDeps, query: GetChannelQuery) -> DomainResult<Option<Channel>> {
|
|
||||||
deps.channel_query.find_by_id(query.channel_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/get.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
use domain::models::Channel;
|
|
||||||
use domain::DomainResult;
|
|
||||||
|
|
||||||
use super::deps::ChannelQueryDeps;
|
|
||||||
use super::queries::ListChannelsQuery;
|
|
||||||
|
|
||||||
pub async fn execute(deps: &ChannelQueryDeps, _query: ListChannelsQuery) -> DomainResult<Vec<Channel>> {
|
|
||||||
deps.channel_query.find_all().await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/list.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
use domain::models::Channel;
|
|
||||||
use domain::DomainResult;
|
|
||||||
|
|
||||||
use super::deps::ChannelQueryDeps;
|
|
||||||
use super::queries::ListByOwnerQuery;
|
|
||||||
|
|
||||||
pub async fn execute(deps: &ChannelQueryDeps, query: ListByOwnerQuery) -> DomainResult<Vec<Channel>> {
|
|
||||||
deps.channel_query.find_by_owner(query.owner_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/list_by_owner.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -2,15 +2,10 @@ pub mod commands;
|
|||||||
pub mod create;
|
pub mod create;
|
||||||
pub mod delete;
|
pub mod delete;
|
||||||
pub mod deps;
|
pub mod deps;
|
||||||
pub mod get;
|
|
||||||
pub mod list;
|
|
||||||
pub mod list_by_owner;
|
|
||||||
pub mod queries;
|
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
|
||||||
pub use commands::{CreateChannelCommand, DeleteChannelCommand, UpdateChannelCommand};
|
pub use commands::{CreateChannelCommand, DeleteChannelCommand, UpdateChannelCommand};
|
||||||
pub use deps::{ChannelCommandDeps, ChannelQueryDeps};
|
pub use deps::ChannelCommandDeps;
|
||||||
pub use queries::{GetChannelQuery, ListByOwnerQuery, ListChannelsQuery};
|
|
||||||
|
|
||||||
use domain::models::Channel;
|
use domain::models::Channel;
|
||||||
use domain::value_objects::{ChannelId, UserId};
|
use domain::value_objects::{ChannelId, UserId};
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
use domain::value_objects::{ChannelId, UserId};
|
|
||||||
|
|
||||||
pub struct GetChannelQuery {
|
|
||||||
pub channel_id: ChannelId,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ListChannelsQuery;
|
|
||||||
|
|
||||||
pub struct ListByOwnerQuery {
|
|
||||||
pub owner_id: UserId,
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use domain::testing::{InMemoryChannelRepository, NoopEventPublisher};
|
|
||||||
use domain::value_objects::{ChannelId, UserId};
|
|
||||||
|
|
||||||
use crate::channels::commands::CreateChannelCommand;
|
|
||||||
use crate::channels::deps::{ChannelCommandDeps, ChannelQueryDeps};
|
|
||||||
use crate::channels::queries::GetChannelQuery;
|
|
||||||
use crate::channels::{create, get};
|
|
||||||
|
|
||||||
fn make_deps() -> (ChannelCommandDeps, ChannelQueryDeps) {
|
|
||||||
let repo = Arc::new(InMemoryChannelRepository::new());
|
|
||||||
let cmd_deps = ChannelCommandDeps {
|
|
||||||
channel_command: repo.clone(),
|
|
||||||
channel_query: repo.clone(),
|
|
||||||
event_publisher: Arc::new(NoopEventPublisher::new()),
|
|
||||||
};
|
|
||||||
let query_deps = ChannelQueryDeps {
|
|
||||||
channel_query: repo,
|
|
||||||
};
|
|
||||||
(cmd_deps, query_deps)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_existing_channel() {
|
|
||||||
let (cmd_deps, query_deps) = make_deps();
|
|
||||||
|
|
||||||
let channel = create::execute(
|
|
||||||
&cmd_deps,
|
|
||||||
CreateChannelCommand {
|
|
||||||
owner_id: UserId::generate(),
|
|
||||||
name: "Findable".into(),
|
|
||||||
timezone: "UTC".into(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let found = get::execute(
|
|
||||||
&query_deps,
|
|
||||||
GetChannelQuery {
|
|
||||||
channel_id: channel.id(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(found.is_some());
|
|
||||||
assert_eq!(found.unwrap().name(), "Findable");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_nonexistent_returns_none() {
|
|
||||||
let (_, query_deps) = make_deps();
|
|
||||||
|
|
||||||
let found = get::execute(
|
|
||||||
&query_deps,
|
|
||||||
GetChannelQuery {
|
|
||||||
channel_id: ChannelId::generate(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(found.is_none());
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use domain::testing::{InMemoryChannelRepository, NoopEventPublisher};
|
|
||||||
use domain::value_objects::UserId;
|
|
||||||
|
|
||||||
use crate::channels::commands::CreateChannelCommand;
|
|
||||||
use crate::channels::deps::{ChannelCommandDeps, ChannelQueryDeps};
|
|
||||||
use crate::channels::queries::ListChannelsQuery;
|
|
||||||
use crate::channels::{create, list};
|
|
||||||
|
|
||||||
fn make_deps() -> (ChannelCommandDeps, ChannelQueryDeps) {
|
|
||||||
let repo = Arc::new(InMemoryChannelRepository::new());
|
|
||||||
let cmd_deps = ChannelCommandDeps {
|
|
||||||
channel_command: repo.clone(),
|
|
||||||
channel_query: repo.clone(),
|
|
||||||
event_publisher: Arc::new(NoopEventPublisher::new()),
|
|
||||||
};
|
|
||||||
let query_deps = ChannelQueryDeps {
|
|
||||||
channel_query: repo,
|
|
||||||
};
|
|
||||||
(cmd_deps, query_deps)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_empty_returns_empty() {
|
|
||||||
let (_, query_deps) = make_deps();
|
|
||||||
|
|
||||||
let channels = list::execute(&query_deps, ListChannelsQuery).await.unwrap();
|
|
||||||
assert!(channels.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_returns_all_channels() {
|
|
||||||
let (cmd_deps, query_deps) = make_deps();
|
|
||||||
|
|
||||||
for name in ["A", "B", "C"] {
|
|
||||||
create::execute(
|
|
||||||
&cmd_deps,
|
|
||||||
CreateChannelCommand {
|
|
||||||
owner_id: UserId::generate(),
|
|
||||||
name: name.into(),
|
|
||||||
timezone: "UTC".into(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let channels = list::execute(&query_deps, ListChannelsQuery).await.unwrap();
|
|
||||||
assert_eq!(channels.len(), 3);
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use domain::testing::{InMemoryChannelRepository, NoopEventPublisher};
|
|
||||||
use domain::value_objects::UserId;
|
|
||||||
|
|
||||||
use crate::channels::commands::CreateChannelCommand;
|
|
||||||
use crate::channels::deps::{ChannelCommandDeps, ChannelQueryDeps};
|
|
||||||
use crate::channels::queries::ListByOwnerQuery;
|
|
||||||
use crate::channels::{create, list_by_owner};
|
|
||||||
|
|
||||||
fn make_deps() -> (ChannelCommandDeps, ChannelQueryDeps) {
|
|
||||||
let repo = Arc::new(InMemoryChannelRepository::new());
|
|
||||||
let cmd_deps = ChannelCommandDeps {
|
|
||||||
channel_command: repo.clone(),
|
|
||||||
channel_query: repo.clone(),
|
|
||||||
event_publisher: Arc::new(NoopEventPublisher::new()),
|
|
||||||
};
|
|
||||||
let query_deps = ChannelQueryDeps {
|
|
||||||
channel_query: repo,
|
|
||||||
};
|
|
||||||
(cmd_deps, query_deps)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn filters_by_owner() {
|
|
||||||
let (cmd_deps, query_deps) = make_deps();
|
|
||||||
let alice = UserId::generate();
|
|
||||||
let bob = UserId::generate();
|
|
||||||
|
|
||||||
// Alice: 2 channels
|
|
||||||
for name in ["Alice-1", "Alice-2"] {
|
|
||||||
create::execute(
|
|
||||||
&cmd_deps,
|
|
||||||
CreateChannelCommand {
|
|
||||||
owner_id: alice,
|
|
||||||
name: name.into(),
|
|
||||||
timezone: "UTC".into(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bob: 1 channel
|
|
||||||
create::execute(
|
|
||||||
&cmd_deps,
|
|
||||||
CreateChannelCommand {
|
|
||||||
owner_id: bob,
|
|
||||||
name: "Bob-1".into(),
|
|
||||||
timezone: "UTC".into(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let alice_channels = list_by_owner::execute(
|
|
||||||
&query_deps,
|
|
||||||
ListByOwnerQuery {
|
|
||||||
owner_id: alice,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(alice_channels.len(), 2);
|
|
||||||
assert!(alice_channels.iter().all(|c| c.owner_id() == alice));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn no_channels_returns_empty() {
|
|
||||||
let (_, query_deps) = make_deps();
|
|
||||||
|
|
||||||
let channels = list_by_owner::execute(
|
|
||||||
&query_deps,
|
|
||||||
ListByOwnerQuery {
|
|
||||||
owner_id: UserId::generate(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(channels.is_empty());
|
|
||||||
}
|
|
||||||
@@ -43,8 +43,9 @@ async fn updates_channel_name() {
|
|||||||
description: None,
|
description: None,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
schedule_config: None,
|
schedule_config: None,
|
||||||
recycle_policy: None,
|
rotation_policy: None,
|
||||||
auto_schedule: None,
|
auto_schedule: None,
|
||||||
|
gap_filler: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -80,8 +81,9 @@ async fn update_fails_if_not_owner() {
|
|||||||
description: None,
|
description: None,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
schedule_config: None,
|
schedule_config: None,
|
||||||
recycle_policy: None,
|
rotation_policy: None,
|
||||||
auto_schedule: None,
|
auto_schedule: None,
|
||||||
|
gap_filler: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -106,8 +108,9 @@ async fn update_nonexistent_channel_returns_not_found() {
|
|||||||
description: None,
|
description: None,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
schedule_config: None,
|
schedule_config: None,
|
||||||
recycle_policy: None,
|
rotation_policy: None,
|
||||||
auto_schedule: None,
|
auto_schedule: None,
|
||||||
|
gap_filler: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -146,8 +149,9 @@ async fn update_config_creates_snapshot() {
|
|||||||
description: None,
|
description: None,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
schedule_config: Some(new_config),
|
schedule_config: Some(new_config),
|
||||||
recycle_policy: None,
|
rotation_policy: None,
|
||||||
auto_schedule: None,
|
auto_schedule: None,
|
||||||
|
gap_filler: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -185,8 +189,9 @@ async fn update_without_config_skips_snapshot() {
|
|||||||
description: None,
|
description: None,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
schedule_config: None,
|
schedule_config: None,
|
||||||
recycle_policy: None,
|
rotation_policy: None,
|
||||||
auto_schedule: None,
|
auto_schedule: None,
|
||||||
|
gap_filler: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -223,8 +228,9 @@ async fn update_description_clear() {
|
|||||||
description: Some(Some("A description".into())),
|
description: Some(Some("A description".into())),
|
||||||
timezone: None,
|
timezone: None,
|
||||||
schedule_config: None,
|
schedule_config: None,
|
||||||
recycle_policy: None,
|
rotation_policy: None,
|
||||||
auto_schedule: None,
|
auto_schedule: None,
|
||||||
|
gap_filler: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -241,8 +247,9 @@ async fn update_description_clear() {
|
|||||||
description: Some(None),
|
description: Some(None),
|
||||||
timezone: None,
|
timezone: None,
|
||||||
schedule_config: None,
|
schedule_config: None,
|
||||||
recycle_policy: None,
|
rotation_policy: None,
|
||||||
auto_schedule: None,
|
auto_schedule: None,
|
||||||
|
gap_filler: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -29,12 +29,15 @@ pub async fn execute(deps: &ChannelCommandDeps, cmd: UpdateChannelCommand) -> Do
|
|||||||
if let Some(config) = cmd.schedule_config {
|
if let Some(config) = cmd.schedule_config {
|
||||||
channel.set_schedule_config(config);
|
channel.set_schedule_config(config);
|
||||||
}
|
}
|
||||||
if let Some(policy) = cmd.recycle_policy {
|
if let Some(policy) = cmd.rotation_policy {
|
||||||
channel.set_recycle_policy(policy);
|
channel.set_rotation_policy(policy);
|
||||||
}
|
}
|
||||||
if let Some(auto) = cmd.auto_schedule {
|
if let Some(auto) = cmd.auto_schedule {
|
||||||
channel.set_auto_schedule(auto);
|
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.channel_command.save(&channel).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,7 @@ pub fn execute(deps: &ConfigDeps, _query: GetConfigQuery) -> SystemConfig {
|
|||||||
tags: false,
|
tags: false,
|
||||||
decade: false,
|
decade: false,
|
||||||
search: false,
|
search: false,
|
||||||
streaming_protocol: domain::ports::StreamingProtocol::DirectFile,
|
|
||||||
rescan: false,
|
rescan: false,
|
||||||
transcode: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
SystemConfig {
|
SystemConfig {
|
||||||
|
|||||||
@@ -5,12 +5,6 @@ pub struct SaveSnapshotCommand {
|
|||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PatchLabelCommand {
|
|
||||||
pub channel_id: ChannelId,
|
|
||||||
pub snapshot_id: SnapshotId,
|
|
||||||
pub label: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct RestoreSnapshotCommand {
|
pub struct RestoreSnapshotCommand {
|
||||||
pub channel_id: ChannelId,
|
pub channel_id: ChannelId,
|
||||||
pub snapshot_id: SnapshotId,
|
pub snapshot_id: SnapshotId,
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
use domain::models::ChannelConfigSnapshot;
|
|
||||||
use domain::DomainResult;
|
|
||||||
|
|
||||||
use super::deps::ConfigSnapshotDeps;
|
|
||||||
use super::queries::GetSnapshotQuery;
|
|
||||||
|
|
||||||
pub async fn execute(
|
|
||||||
deps: &ConfigSnapshotDeps,
|
|
||||||
query: GetSnapshotQuery,
|
|
||||||
) -> DomainResult<Option<ChannelConfigSnapshot>> {
|
|
||||||
deps.channel_query
|
|
||||||
.get_config_snapshot(query.channel_id, query.snapshot_id)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
use domain::models::ChannelConfigSnapshot;
|
|
||||||
use domain::DomainResult;
|
|
||||||
|
|
||||||
use super::deps::ConfigSnapshotDeps;
|
|
||||||
use super::queries::ListSnapshotsQuery;
|
|
||||||
|
|
||||||
pub async fn execute(
|
|
||||||
deps: &ConfigSnapshotDeps,
|
|
||||||
query: ListSnapshotsQuery,
|
|
||||||
) -> DomainResult<Vec<ChannelConfigSnapshot>> {
|
|
||||||
deps.channel_query.list_config_snapshots(query.channel_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/list.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod deps;
|
pub mod deps;
|
||||||
pub mod get;
|
|
||||||
pub mod list;
|
|
||||||
pub mod patch_label;
|
|
||||||
pub mod queries;
|
|
||||||
pub mod restore;
|
pub mod restore;
|
||||||
pub mod save;
|
pub mod save;
|
||||||
|
|
||||||
pub use commands::{PatchLabelCommand, RestoreSnapshotCommand, SaveSnapshotCommand};
|
pub use commands::{RestoreSnapshotCommand, SaveSnapshotCommand};
|
||||||
pub use deps::ConfigSnapshotDeps;
|
pub use deps::ConfigSnapshotDeps;
|
||||||
pub use queries::{GetSnapshotQuery, ListSnapshotsQuery};
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
use domain::models::ChannelConfigSnapshot;
|
|
||||||
use domain::DomainResult;
|
|
||||||
|
|
||||||
use super::commands::PatchLabelCommand;
|
|
||||||
use super::deps::ConfigSnapshotDeps;
|
|
||||||
|
|
||||||
pub async fn execute(
|
|
||||||
deps: &ConfigSnapshotDeps,
|
|
||||||
cmd: PatchLabelCommand,
|
|
||||||
) -> DomainResult<Option<ChannelConfigSnapshot>> {
|
|
||||||
deps.channel_command
|
|
||||||
.patch_config_snapshot_label(cmd.channel_id, cmd.snapshot_id, cmd.label)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
use domain::value_objects::{ChannelId, SnapshotId};
|
|
||||||
|
|
||||||
pub struct ListSnapshotsQuery {
|
|
||||||
pub channel_id: ChannelId,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct GetSnapshotQuery {
|
|
||||||
pub channel_id: ChannelId,
|
|
||||||
pub snapshot_id: SnapshotId,
|
|
||||||
}
|
|
||||||
@@ -1,77 +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::queries::ListSnapshotsQuery;
|
|
||||||
use crate::config_snapshots::{list, 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 list_empty() {
|
|
||||||
let (deps, repo) = make_deps();
|
|
||||||
let channel = seed_channel(&repo).await;
|
|
||||||
|
|
||||||
let snaps = list::execute(
|
|
||||||
&deps,
|
|
||||||
ListSnapshotsQuery {
|
|
||||||
channel_id: channel.id(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(snaps.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_returns_saved_snapshots() {
|
|
||||||
let (deps, repo) = make_deps();
|
|
||||||
let channel = seed_channel(&repo).await;
|
|
||||||
|
|
||||||
for label in ["first", "second"] {
|
|
||||||
save::execute(
|
|
||||||
&deps,
|
|
||||||
SaveSnapshotCommand {
|
|
||||||
channel_id: channel.id(),
|
|
||||||
label: Some(label.into()),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let snaps = list::execute(
|
|
||||||
&deps,
|
|
||||||
ListSnapshotsQuery {
|
|
||||||
channel_id: channel.id(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(snaps.len(), 2);
|
|
||||||
// Newest first
|
|
||||||
assert_eq!(snaps[0].version_num(), 2);
|
|
||||||
assert_eq!(snaps[1].version_num(), 1);
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,3 @@ pub struct LibraryCommandDeps {
|
|||||||
pub provider_registry: Arc<dyn IProviderRegistry>,
|
pub provider_registry: Arc<dyn IProviderRegistry>,
|
||||||
pub event_publisher: Arc<dyn EventPublisher>,
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct LibraryQueryDeps {
|
|
||||||
pub library_query: Arc<dyn LibraryQuery>,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
use domain::models::LibraryItem;
|
|
||||||
use domain::DomainResult;
|
|
||||||
|
|
||||||
use super::deps::LibraryQueryDeps;
|
|
||||||
use super::queries::GetItemQuery;
|
|
||||||
|
|
||||||
pub async fn execute(
|
|
||||||
deps: &LibraryQueryDeps,
|
|
||||||
query: GetItemQuery,
|
|
||||||
) -> DomainResult<Option<LibraryItem>> {
|
|
||||||
deps.library_query.get_by_id(&query.item_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/get_item.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
use domain::models::LibrarySyncLogEntry;
|
|
||||||
use domain::DomainResult;
|
|
||||||
|
|
||||||
use super::deps::LibraryQueryDeps;
|
|
||||||
use super::queries::GetSyncStatusQuery;
|
|
||||||
|
|
||||||
pub async fn execute(
|
|
||||||
deps: &LibraryQueryDeps,
|
|
||||||
_query: GetSyncStatusQuery,
|
|
||||||
) -> DomainResult<Vec<LibrarySyncLogEntry>> {
|
|
||||||
deps.library_query.latest_sync_status().await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/get_sync_status.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
use domain::models::LibraryCollection;
|
|
||||||
use domain::DomainResult;
|
|
||||||
|
|
||||||
use super::deps::LibraryQueryDeps;
|
|
||||||
use super::queries::ListCollectionsQuery;
|
|
||||||
|
|
||||||
pub async fn execute(
|
|
||||||
deps: &LibraryQueryDeps,
|
|
||||||
query: ListCollectionsQuery,
|
|
||||||
) -> DomainResult<Vec<LibraryCollection>> {
|
|
||||||
deps.library_query
|
|
||||||
.list_collections(query.provider_id.as_deref())
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/list_collections.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
use domain::DomainResult;
|
|
||||||
|
|
||||||
use super::deps::LibraryQueryDeps;
|
|
||||||
use super::parse_content_type;
|
|
||||||
use super::queries::ListGenresQuery;
|
|
||||||
|
|
||||||
pub async fn execute(deps: &LibraryQueryDeps, query: ListGenresQuery) -> DomainResult<Vec<String>> {
|
|
||||||
let content_type = query
|
|
||||||
.content_type
|
|
||||||
.as_deref()
|
|
||||||
.map(parse_content_type)
|
|
||||||
.transpose()?;
|
|
||||||
|
|
||||||
deps.library_query
|
|
||||||
.list_genres(content_type.as_ref(), query.provider_id.as_deref())
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/list_genres.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
use domain::models::SeasonSummary;
|
|
||||||
use domain::DomainResult;
|
|
||||||
|
|
||||||
use super::deps::LibraryQueryDeps;
|
|
||||||
use super::queries::ListSeasonsQuery;
|
|
||||||
|
|
||||||
pub async fn execute(
|
|
||||||
deps: &LibraryQueryDeps,
|
|
||||||
query: ListSeasonsQuery,
|
|
||||||
) -> DomainResult<Vec<SeasonSummary>> {
|
|
||||||
deps.library_query
|
|
||||||
.list_seasons(&query.series_name, query.provider_id.as_deref())
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/list_seasons.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
use domain::models::ShowSummary;
|
|
||||||
use domain::DomainResult;
|
|
||||||
|
|
||||||
use super::deps::LibraryQueryDeps;
|
|
||||||
use super::queries::ListShowsQuery;
|
|
||||||
|
|
||||||
pub async fn execute(
|
|
||||||
deps: &LibraryQueryDeps,
|
|
||||||
query: ListShowsQuery,
|
|
||||||
) -> DomainResult<Vec<ShowSummary>> {
|
|
||||||
deps.library_query
|
|
||||||
.list_shows(
|
|
||||||
query.provider_id.as_deref(),
|
|
||||||
query.search_term.as_deref(),
|
|
||||||
&query.genres,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/list_shows.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,26 +1,17 @@
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod deps;
|
pub mod deps;
|
||||||
pub mod get_item;
|
|
||||||
pub mod get_sync_status;
|
|
||||||
pub mod list_collections;
|
|
||||||
pub mod list_genres;
|
|
||||||
pub mod list_seasons;
|
|
||||||
pub mod list_shows;
|
|
||||||
pub mod queries;
|
pub mod queries;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
|
||||||
pub use commands::TriggerSyncCommand;
|
pub use commands::TriggerSyncCommand;
|
||||||
pub use deps::{LibraryCommandDeps, LibraryQueryDeps};
|
pub use deps::LibraryCommandDeps;
|
||||||
pub use queries::{
|
pub use queries::SearchItemsQuery;
|
||||||
GetItemQuery, GetSyncStatusQuery, ListCollectionsQuery, ListGenresQuery, ListSeasonsQuery,
|
|
||||||
ListShowsQuery, SearchItemsQuery,
|
|
||||||
};
|
|
||||||
|
|
||||||
use domain::errors::{DomainError, DomainResult};
|
use domain::errors::{DomainError, DomainResult};
|
||||||
use domain::value_objects::ContentType;
|
use domain::value_objects::ContentType;
|
||||||
|
|
||||||
pub(crate) fn parse_content_type(s: &str) -> DomainResult<ContentType> {
|
pub fn parse_content_type(s: &str) -> DomainResult<ContentType> {
|
||||||
match s {
|
match s {
|
||||||
"movie" => Ok(ContentType::Movie),
|
"movie" => Ok(ContentType::Movie),
|
||||||
"episode" => Ok(ContentType::Episode),
|
"episode" => Ok(ContentType::Episode),
|
||||||
|
|||||||
@@ -10,29 +10,3 @@ pub struct SearchItemsQuery {
|
|||||||
pub offset: u32,
|
pub offset: u32,
|
||||||
pub limit: u32,
|
pub limit: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ListCollectionsQuery {
|
|
||||||
pub provider_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ListShowsQuery {
|
|
||||||
pub provider_id: Option<String>,
|
|
||||||
pub search_term: Option<String>,
|
|
||||||
pub genres: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ListSeasonsQuery {
|
|
||||||
pub series_name: String,
|
|
||||||
pub provider_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ListGenresQuery {
|
|
||||||
pub content_type: Option<String>,
|
|
||||||
pub provider_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct GetItemQuery {
|
|
||||||
pub item_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct GetSyncStatusQuery;
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
use domain::DomainResult;
|
use domain::DomainResult;
|
||||||
use domain::models::LibraryItem;
|
use domain::models::MediaItem;
|
||||||
use domain::value_objects::LibrarySearchFilter;
|
use domain::value_objects::LibrarySearchFilter;
|
||||||
|
|
||||||
use super::deps::LibraryQueryDeps;
|
use super::deps::LibraryCommandDeps;
|
||||||
use super::parse_content_type;
|
use super::parse_content_type;
|
||||||
use super::queries::SearchItemsQuery;
|
use super::queries::SearchItemsQuery;
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
deps: &LibraryQueryDeps,
|
deps: &LibraryCommandDeps,
|
||||||
query: SearchItemsQuery,
|
query: SearchItemsQuery,
|
||||||
) -> DomainResult<(Vec<LibraryItem>, u32)> {
|
) -> DomainResult<(Vec<MediaItem>, u32)> {
|
||||||
let content_type = query
|
let content_type = query
|
||||||
.content_type
|
.content_type
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
use domain::models::LibraryItem;
|
|
||||||
use domain::value_objects::ContentType;
|
|
||||||
|
|
||||||
use crate::library::get_item;
|
|
||||||
use crate::library::queries::GetItemQuery;
|
|
||||||
|
|
||||||
#[path = "helpers.rs"]
|
|
||||||
mod helpers;
|
|
||||||
|
|
||||||
fn seed_item(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
|
||||||
let item = LibraryItem::new("test", "m1", "Die Hard", ContentType::Movie, 7800, "2026-01-01");
|
|
||||||
repo.items
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.insert(item.id().to_string(), item);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_existing_item() {
|
|
||||||
let (_cmd, query, repo) = helpers::make_deps();
|
|
||||||
seed_item(&repo);
|
|
||||||
|
|
||||||
let item = get_item::execute(
|
|
||||||
&query,
|
|
||||||
GetItemQuery {
|
|
||||||
item_id: "test::m1".into(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(item.is_some());
|
|
||||||
assert_eq!(item.unwrap().title(), "Die Hard");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_nonexistent_item_returns_none() {
|
|
||||||
let (_cmd, query, _repo) = helpers::make_deps();
|
|
||||||
|
|
||||||
let item = get_item::execute(
|
|
||||||
&query,
|
|
||||||
GetItemQuery {
|
|
||||||
item_id: "test::missing".into(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(item.is_none());
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
use crate::library::get_sync_status;
|
|
||||||
use crate::library::queries::GetSyncStatusQuery;
|
|
||||||
|
|
||||||
#[path = "helpers.rs"]
|
|
||||||
mod helpers;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_sync_status_empty() {
|
|
||||||
let (_cmd, query, _repo) = helpers::make_deps();
|
|
||||||
|
|
||||||
let entries = get_sync_status::execute(&query, GetSyncStatusQuery)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(entries.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_sync_status_after_sync() {
|
|
||||||
let (cmd, query, _repo) = helpers::make_deps();
|
|
||||||
|
|
||||||
// Start a sync log entry
|
|
||||||
let log_id = domain::ports::LibraryCommand::log_sync_start(&*cmd.library_command, "test")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Finish it
|
|
||||||
let result = domain::models::LibrarySyncResult::new("test", 42, 500);
|
|
||||||
domain::ports::LibraryCommand::log_sync_finish(&*cmd.library_command, log_id, &result)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let entries = get_sync_status::execute(&query, GetSyncStatusQuery)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(entries.len(), 1);
|
|
||||||
assert_eq!(entries[0].provider_id(), "test");
|
|
||||||
assert_eq!(entries[0].items_found(), 42);
|
|
||||||
assert_eq!(entries[0].status(), "success");
|
|
||||||
}
|
|
||||||
@@ -4,16 +4,13 @@ use async_trait::async_trait;
|
|||||||
|
|
||||||
use domain::errors::DomainResult;
|
use domain::errors::DomainResult;
|
||||||
use domain::models::MediaItem;
|
use domain::models::MediaItem;
|
||||||
use domain::ports::{
|
use domain::ports::{Collection, IProviderRegistry, ProviderCapabilities, SeriesSummary};
|
||||||
Collection, IProviderRegistry, ProviderCapabilities, SeriesSummary, StreamQuality,
|
use domain::SourceUri;
|
||||||
StreamingProtocol,
|
|
||||||
};
|
|
||||||
use domain::testing::{InMemoryLibraryRepository, NoopEventPublisher, NoopLibrarySync};
|
use domain::testing::{InMemoryLibraryRepository, NoopEventPublisher, NoopLibrarySync};
|
||||||
use domain::value_objects::{ContentType, MediaFilter, MediaItemId};
|
use domain::value_objects::{ContentType, MediaFilter, MediaItemId};
|
||||||
|
|
||||||
use crate::library::deps::{LibraryCommandDeps, LibraryQueryDeps};
|
use crate::library::deps::LibraryCommandDeps;
|
||||||
|
|
||||||
/// Minimal IProviderRegistry for library tests.
|
|
||||||
pub(crate) struct TestProviderRegistry;
|
pub(crate) struct TestProviderRegistry;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -30,11 +27,7 @@ impl IProviderRegistry for TestProviderRegistry {
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_stream_url(
|
async fn get_source_uri(&self, _item_id: &MediaItemId) -> DomainResult<SourceUri> {
|
||||||
&self,
|
|
||||||
_item_id: &MediaItemId,
|
|
||||||
_quality: &StreamQuality,
|
|
||||||
) -> DomainResult<String> {
|
|
||||||
Err(domain::DomainError::InfrastructureError(
|
Err(domain::DomainError::InfrastructureError(
|
||||||
"TestProviderRegistry does not support streaming".into(),
|
"TestProviderRegistry does not support streaming".into(),
|
||||||
))
|
))
|
||||||
@@ -56,9 +49,7 @@ impl IProviderRegistry for TestProviderRegistry {
|
|||||||
tags: false,
|
tags: false,
|
||||||
decade: false,
|
decade: false,
|
||||||
search: true,
|
search: true,
|
||||||
streaming_protocol: StreamingProtocol::Hls,
|
|
||||||
rescan: false,
|
rescan: false,
|
||||||
transcode: false,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,24 +74,14 @@ impl IProviderRegistry for TestProviderRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build LibraryCommandDeps and LibraryQueryDeps backed by InMemory repos.
|
pub(crate) fn make_deps() -> (LibraryCommandDeps, Arc<InMemoryLibraryRepository>) {
|
||||||
///
|
|
||||||
/// Returns deps plus the underlying repo for seeding test data.
|
|
||||||
pub(crate) fn make_deps() -> (
|
|
||||||
LibraryCommandDeps,
|
|
||||||
LibraryQueryDeps,
|
|
||||||
Arc<InMemoryLibraryRepository>,
|
|
||||||
) {
|
|
||||||
let repo = Arc::new(InMemoryLibraryRepository::new());
|
let repo = Arc::new(InMemoryLibraryRepository::new());
|
||||||
let cmd_deps = LibraryCommandDeps {
|
let deps = LibraryCommandDeps {
|
||||||
library_command: repo.clone(),
|
library_command: repo.clone(),
|
||||||
library_query: repo.clone(),
|
library_query: repo.clone(),
|
||||||
library_sync: Arc::new(NoopLibrarySync::new()),
|
library_sync: Arc::new(NoopLibrarySync::new()),
|
||||||
provider_registry: Arc::new(TestProviderRegistry),
|
provider_registry: Arc::new(TestProviderRegistry),
|
||||||
event_publisher: Arc::new(NoopEventPublisher::new()),
|
event_publisher: Arc::new(NoopEventPublisher::new()),
|
||||||
};
|
};
|
||||||
let query_deps = LibraryQueryDeps {
|
(deps, repo)
|
||||||
library_query: repo.clone(),
|
|
||||||
};
|
|
||||||
(cmd_deps, query_deps, repo)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
use domain::models::{LibraryItem, LibraryItemRow};
|
|
||||||
use domain::value_objects::ContentType;
|
|
||||||
|
|
||||||
use crate::library::list_collections;
|
|
||||||
use crate::library::queries::ListCollectionsQuery;
|
|
||||||
|
|
||||||
#[path = "helpers.rs"]
|
|
||||||
mod helpers;
|
|
||||||
|
|
||||||
fn seed_with_collections(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
|
||||||
let mut store = repo.items.lock().unwrap();
|
|
||||||
|
|
||||||
let item = LibraryItem::from_persistence(LibraryItemRow {
|
|
||||||
id: "test::m1".into(),
|
|
||||||
provider_id: "test".into(),
|
|
||||||
external_id: "m1".into(),
|
|
||||||
title: "Die Hard".into(),
|
|
||||||
content_type: ContentType::Movie,
|
|
||||||
duration_secs: 7800,
|
|
||||||
series_name: None,
|
|
||||||
season_number: None,
|
|
||||||
episode_number: None,
|
|
||||||
year: None,
|
|
||||||
genres: vec![],
|
|
||||||
tags: vec![],
|
|
||||||
collection_id: Some("col-1".into()),
|
|
||||||
collection_name: Some("Movies".into()),
|
|
||||||
collection_type: Some("movies".into()),
|
|
||||||
thumbnail_url: None,
|
|
||||||
synced_at: "2026-01-01".into(),
|
|
||||||
});
|
|
||||||
store.insert(item.id().to_string(), item);
|
|
||||||
|
|
||||||
let item2 = LibraryItem::from_persistence(LibraryItemRow {
|
|
||||||
id: "test::e1".into(),
|
|
||||||
provider_id: "test".into(),
|
|
||||||
external_id: "e1".into(),
|
|
||||||
title: "BB S01E01".into(),
|
|
||||||
content_type: ContentType::Episode,
|
|
||||||
duration_secs: 2700,
|
|
||||||
series_name: Some("Breaking Bad".into()),
|
|
||||||
season_number: Some(1),
|
|
||||||
episode_number: Some(1),
|
|
||||||
year: None,
|
|
||||||
genres: vec![],
|
|
||||||
tags: vec![],
|
|
||||||
collection_id: Some("col-2".into()),
|
|
||||||
collection_name: Some("TV Shows".into()),
|
|
||||||
collection_type: Some("tvshows".into()),
|
|
||||||
thumbnail_url: None,
|
|
||||||
synced_at: "2026-01-01".into(),
|
|
||||||
});
|
|
||||||
store.insert(item2.id().to_string(), item2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_collections_returns_distinct() {
|
|
||||||
let (_cmd, query, repo) = helpers::make_deps();
|
|
||||||
seed_with_collections(&repo);
|
|
||||||
|
|
||||||
let cols = list_collections::execute(
|
|
||||||
&query,
|
|
||||||
ListCollectionsQuery { provider_id: None },
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(cols.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_collections_empty_library() {
|
|
||||||
let (_cmd, query, _repo) = helpers::make_deps();
|
|
||||||
|
|
||||||
let cols = list_collections::execute(
|
|
||||||
&query,
|
|
||||||
ListCollectionsQuery { provider_id: None },
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(cols.is_empty());
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
use domain::models::{LibraryItem, LibraryItemRow};
|
|
||||||
use domain::value_objects::ContentType;
|
|
||||||
|
|
||||||
use crate::library::list_genres;
|
|
||||||
use crate::library::queries::ListGenresQuery;
|
|
||||||
|
|
||||||
#[path = "helpers.rs"]
|
|
||||||
mod helpers;
|
|
||||||
|
|
||||||
fn seed_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
|
||||||
let mut store = repo.items.lock().unwrap();
|
|
||||||
|
|
||||||
let item1 = LibraryItem::from_persistence(LibraryItemRow {
|
|
||||||
id: "test::m1".into(),
|
|
||||||
provider_id: "test".into(),
|
|
||||||
external_id: "m1".into(),
|
|
||||||
title: "Die Hard".into(),
|
|
||||||
content_type: ContentType::Movie,
|
|
||||||
duration_secs: 7800,
|
|
||||||
series_name: None,
|
|
||||||
season_number: None,
|
|
||||||
episode_number: None,
|
|
||||||
year: None,
|
|
||||||
genres: vec!["Action".into(), "Thriller".into()],
|
|
||||||
tags: vec![],
|
|
||||||
collection_id: None,
|
|
||||||
collection_name: None,
|
|
||||||
collection_type: None,
|
|
||||||
thumbnail_url: None,
|
|
||||||
synced_at: "2026-01-01".into(),
|
|
||||||
});
|
|
||||||
let item2 = LibraryItem::from_persistence(LibraryItemRow {
|
|
||||||
id: "test::m2".into(),
|
|
||||||
provider_id: "test".into(),
|
|
||||||
external_id: "m2".into(),
|
|
||||||
title: "Alien".into(),
|
|
||||||
content_type: ContentType::Movie,
|
|
||||||
duration_secs: 7020,
|
|
||||||
series_name: None,
|
|
||||||
season_number: None,
|
|
||||||
episode_number: None,
|
|
||||||
year: None,
|
|
||||||
genres: vec!["Sci-Fi".into(), "Action".into()],
|
|
||||||
tags: vec![],
|
|
||||||
collection_id: None,
|
|
||||||
collection_name: None,
|
|
||||||
collection_type: None,
|
|
||||||
thumbnail_url: None,
|
|
||||||
synced_at: "2026-01-01".into(),
|
|
||||||
});
|
|
||||||
|
|
||||||
store.insert(item1.id().to_string(), item1);
|
|
||||||
store.insert(item2.id().to_string(), item2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_genres_returns_unique() {
|
|
||||||
let (_cmd, query, repo) = helpers::make_deps();
|
|
||||||
seed_with_genres(&repo);
|
|
||||||
|
|
||||||
let genres = list_genres::execute(
|
|
||||||
&query,
|
|
||||||
ListGenresQuery {
|
|
||||||
content_type: None,
|
|
||||||
provider_id: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(genres.len(), 3); // Action, Sci-Fi, Thriller (deduped)
|
|
||||||
assert!(genres.contains(&"Action".to_string()));
|
|
||||||
assert!(genres.contains(&"Sci-Fi".to_string()));
|
|
||||||
assert!(genres.contains(&"Thriller".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_genres_empty_library() {
|
|
||||||
let (_cmd, query, _repo) = helpers::make_deps();
|
|
||||||
|
|
||||||
let genres = list_genres::execute(
|
|
||||||
&query,
|
|
||||||
ListGenresQuery {
|
|
||||||
content_type: None,
|
|
||||||
provider_id: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(genres.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_genres_invalid_content_type_errors() {
|
|
||||||
let (_cmd, query, _repo) = helpers::make_deps();
|
|
||||||
|
|
||||||
let result = list_genres::execute(
|
|
||||||
&query,
|
|
||||||
ListGenresQuery {
|
|
||||||
content_type: Some("invalid".into()),
|
|
||||||
provider_id: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
use domain::models::{LibraryItem, LibraryItemRow};
|
|
||||||
use domain::value_objects::ContentType;
|
|
||||||
|
|
||||||
use crate::library::list_seasons;
|
|
||||||
use crate::library::queries::ListSeasonsQuery;
|
|
||||||
|
|
||||||
#[path = "helpers.rs"]
|
|
||||||
mod helpers;
|
|
||||||
|
|
||||||
fn seed_episodes(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
|
||||||
let mut store = repo.items.lock().unwrap();
|
|
||||||
|
|
||||||
for (i, season) in [1u32, 1, 2, 2, 2, 3].iter().enumerate() {
|
|
||||||
let item = LibraryItem::from_persistence(LibraryItemRow {
|
|
||||||
id: format!("test::e{i}"),
|
|
||||||
provider_id: "test".into(),
|
|
||||||
external_id: format!("e{i}"),
|
|
||||||
title: format!("BB S{season:02}E{:02}", i + 1),
|
|
||||||
content_type: ContentType::Episode,
|
|
||||||
duration_secs: 2700,
|
|
||||||
series_name: Some("Breaking Bad".into()),
|
|
||||||
season_number: Some(*season),
|
|
||||||
episode_number: Some(i as u32 + 1),
|
|
||||||
year: None,
|
|
||||||
genres: vec![],
|
|
||||||
tags: vec![],
|
|
||||||
collection_id: None,
|
|
||||||
collection_name: None,
|
|
||||||
collection_type: None,
|
|
||||||
thumbnail_url: None,
|
|
||||||
synced_at: "2026-01-01".into(),
|
|
||||||
});
|
|
||||||
store.insert(item.id().to_string(), item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_seasons_for_series() {
|
|
||||||
let (_cmd, query, repo) = helpers::make_deps();
|
|
||||||
seed_episodes(&repo);
|
|
||||||
|
|
||||||
let seasons = list_seasons::execute(
|
|
||||||
&query,
|
|
||||||
ListSeasonsQuery {
|
|
||||||
series_name: "Breaking Bad".into(),
|
|
||||||
provider_id: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(seasons.len(), 3);
|
|
||||||
// Sorted by season_number
|
|
||||||
assert_eq!(seasons[0].season_number(), 1);
|
|
||||||
assert_eq!(seasons[0].episode_count(), 2);
|
|
||||||
assert_eq!(seasons[1].season_number(), 2);
|
|
||||||
assert_eq!(seasons[1].episode_count(), 3);
|
|
||||||
assert_eq!(seasons[2].season_number(), 3);
|
|
||||||
assert_eq!(seasons[2].episode_count(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_seasons_unknown_series() {
|
|
||||||
let (_cmd, query, _repo) = helpers::make_deps();
|
|
||||||
|
|
||||||
let seasons = list_seasons::execute(
|
|
||||||
&query,
|
|
||||||
ListSeasonsQuery {
|
|
||||||
series_name: "Nonexistent".into(),
|
|
||||||
provider_id: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(seasons.is_empty());
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
use domain::models::{LibraryItem, LibraryItemRow};
|
|
||||||
use domain::value_objects::ContentType;
|
|
||||||
|
|
||||||
use crate::library::list_shows;
|
|
||||||
use crate::library::queries::ListShowsQuery;
|
|
||||||
|
|
||||||
#[path = "helpers.rs"]
|
|
||||||
mod helpers;
|
|
||||||
|
|
||||||
fn seed_episodes(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
|
||||||
let mut store = repo.items.lock().unwrap();
|
|
||||||
|
|
||||||
for (i, (series, season)) in [
|
|
||||||
("Breaking Bad", 1u32),
|
|
||||||
("Breaking Bad", 1),
|
|
||||||
("Breaking Bad", 2),
|
|
||||||
("The Wire", 1),
|
|
||||||
("The Wire", 1),
|
|
||||||
]
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
{
|
|
||||||
let item = LibraryItem::from_persistence(LibraryItemRow {
|
|
||||||
id: format!("test::e{i}"),
|
|
||||||
provider_id: "test".into(),
|
|
||||||
external_id: format!("e{i}"),
|
|
||||||
title: format!("{series} S{season:02}E{i:02}"),
|
|
||||||
content_type: ContentType::Episode,
|
|
||||||
duration_secs: 2700,
|
|
||||||
series_name: Some(series.to_string()),
|
|
||||||
season_number: Some(*season),
|
|
||||||
episode_number: Some(i as u32 + 1),
|
|
||||||
year: None,
|
|
||||||
genres: vec![],
|
|
||||||
tags: vec![],
|
|
||||||
collection_id: None,
|
|
||||||
collection_name: None,
|
|
||||||
collection_type: None,
|
|
||||||
thumbnail_url: None,
|
|
||||||
synced_at: "2026-01-01".into(),
|
|
||||||
});
|
|
||||||
store.insert(item.id().to_string(), item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_shows_returns_summaries() {
|
|
||||||
let (_cmd, query, repo) = helpers::make_deps();
|
|
||||||
seed_episodes(&repo);
|
|
||||||
|
|
||||||
let shows = list_shows::execute(
|
|
||||||
&query,
|
|
||||||
ListShowsQuery {
|
|
||||||
provider_id: None,
|
|
||||||
search_term: None,
|
|
||||||
genres: vec![],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(shows.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_shows_with_search_term() {
|
|
||||||
let (_cmd, query, repo) = helpers::make_deps();
|
|
||||||
seed_episodes(&repo);
|
|
||||||
|
|
||||||
let shows = list_shows::execute(
|
|
||||||
&query,
|
|
||||||
ListShowsQuery {
|
|
||||||
provider_id: None,
|
|
||||||
search_term: Some("breaking".into()),
|
|
||||||
genres: vec![],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(shows.len(), 1);
|
|
||||||
assert_eq!(shows[0].series_name(), "Breaking Bad");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_shows_empty() {
|
|
||||||
let (_cmd, query, _repo) = helpers::make_deps();
|
|
||||||
|
|
||||||
let shows = list_shows::execute(
|
|
||||||
&query,
|
|
||||||
ListShowsQuery {
|
|
||||||
provider_id: None,
|
|
||||||
search_term: None,
|
|
||||||
genres: vec![],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(shows.is_empty());
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use domain::models::{LibraryItem, LibraryItemRow};
|
use domain::models::{MediaItem, MediaItemRow};
|
||||||
use domain::value_objects::ContentType;
|
use domain::value_objects::{ContentType, MediaItemId, MediaRole};
|
||||||
|
|
||||||
use crate::library::queries::SearchItemsQuery;
|
use crate::library::queries::SearchItemsQuery;
|
||||||
use crate::library::search;
|
use crate::library::search;
|
||||||
@@ -10,25 +10,26 @@ mod helpers;
|
|||||||
fn seed_items(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
fn seed_items(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
||||||
let mut store = repo.items.lock().unwrap();
|
let mut store = repo.items.lock().unwrap();
|
||||||
let items = vec![
|
let items = vec![
|
||||||
LibraryItem::new("test", "m1", "Die Hard", ContentType::Movie, 7800, "2026-01-01"),
|
MediaItem::new_library("test", "m1", "Die Hard", ContentType::Movie, 7800, "2026-01-01"),
|
||||||
LibraryItem::new("test", "m2", "Alien", ContentType::Movie, 7020, "2026-01-01"),
|
MediaItem::new_library("test", "m2", "Alien", ContentType::Movie, 7020, "2026-01-01"),
|
||||||
LibraryItem::new("test", "e1", "BB S01E01", ContentType::Episode, 2700, "2026-01-01"),
|
MediaItem::new_library("test", "e1", "BB S01E01", ContentType::Episode, 2700, "2026-01-01"),
|
||||||
];
|
];
|
||||||
for item in items {
|
for item in items {
|
||||||
store.insert(item.id().to_string(), item);
|
store.insert(item.id().value().to_string(), item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibraryRepository>) {
|
||||||
let mut store = repo.items.lock().unwrap();
|
let mut store = repo.items.lock().unwrap();
|
||||||
|
|
||||||
let action = LibraryItem::from_persistence(LibraryItemRow {
|
let action = MediaItem::from_persistence(MediaItemRow {
|
||||||
id: "test::m1".into(),
|
id: MediaItemId::new("test::m1"),
|
||||||
provider_id: "test".into(),
|
provider_id: "test".into(),
|
||||||
external_id: "m1".into(),
|
external_id: "m1".into(),
|
||||||
title: "Die Hard".into(),
|
title: "Die Hard".into(),
|
||||||
content_type: ContentType::Movie,
|
content_type: ContentType::Movie,
|
||||||
duration_secs: 7800,
|
duration_secs: 7800,
|
||||||
|
description: None,
|
||||||
series_name: None,
|
series_name: None,
|
||||||
season_number: None,
|
season_number: None,
|
||||||
episode_number: None,
|
episode_number: None,
|
||||||
@@ -39,15 +40,18 @@ fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibrary
|
|||||||
collection_name: None,
|
collection_name: None,
|
||||||
collection_type: None,
|
collection_type: None,
|
||||||
thumbnail_url: None,
|
thumbnail_url: None,
|
||||||
synced_at: "2026-01-01".into(),
|
synced_at: Some("2026-01-01".into()),
|
||||||
|
role: MediaRole::default(),
|
||||||
|
chapters: Vec::new(),
|
||||||
});
|
});
|
||||||
let scifi = LibraryItem::from_persistence(LibraryItemRow {
|
let scifi = MediaItem::from_persistence(MediaItemRow {
|
||||||
id: "test::m2".into(),
|
id: MediaItemId::new("test::m2"),
|
||||||
provider_id: "test".into(),
|
provider_id: "test".into(),
|
||||||
external_id: "m2".into(),
|
external_id: "m2".into(),
|
||||||
title: "Alien".into(),
|
title: "Alien".into(),
|
||||||
content_type: ContentType::Movie,
|
content_type: ContentType::Movie,
|
||||||
duration_secs: 7020,
|
duration_secs: 7020,
|
||||||
|
description: None,
|
||||||
series_name: None,
|
series_name: None,
|
||||||
season_number: None,
|
season_number: None,
|
||||||
episode_number: None,
|
episode_number: None,
|
||||||
@@ -58,15 +62,18 @@ fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibrary
|
|||||||
collection_name: None,
|
collection_name: None,
|
||||||
collection_type: None,
|
collection_type: None,
|
||||||
thumbnail_url: None,
|
thumbnail_url: None,
|
||||||
synced_at: "2026-01-01".into(),
|
synced_at: Some("2026-01-01".into()),
|
||||||
|
role: MediaRole::default(),
|
||||||
|
chapters: Vec::new(),
|
||||||
});
|
});
|
||||||
let comedy = LibraryItem::from_persistence(LibraryItemRow {
|
let comedy = MediaItem::from_persistence(MediaItemRow {
|
||||||
id: "test::m3".into(),
|
id: MediaItemId::new("test::m3"),
|
||||||
provider_id: "test".into(),
|
provider_id: "test".into(),
|
||||||
external_id: "m3".into(),
|
external_id: "m3".into(),
|
||||||
title: "Airplane!".into(),
|
title: "Airplane!".into(),
|
||||||
content_type: ContentType::Movie,
|
content_type: ContentType::Movie,
|
||||||
duration_secs: 5280,
|
duration_secs: 5280,
|
||||||
|
description: None,
|
||||||
series_name: None,
|
series_name: None,
|
||||||
season_number: None,
|
season_number: None,
|
||||||
episode_number: None,
|
episode_number: None,
|
||||||
@@ -77,21 +84,23 @@ fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibrary
|
|||||||
collection_name: None,
|
collection_name: None,
|
||||||
collection_type: None,
|
collection_type: None,
|
||||||
thumbnail_url: None,
|
thumbnail_url: None,
|
||||||
synced_at: "2026-01-01".into(),
|
synced_at: Some("2026-01-01".into()),
|
||||||
|
role: MediaRole::default(),
|
||||||
|
chapters: Vec::new(),
|
||||||
});
|
});
|
||||||
|
|
||||||
store.insert(action.id().to_string(), action);
|
store.insert(action.id().value().to_string(), action);
|
||||||
store.insert(scifi.id().to_string(), scifi);
|
store.insert(scifi.id().value().to_string(), scifi);
|
||||||
store.insert(comedy.id().to_string(), comedy);
|
store.insert(comedy.id().value().to_string(), comedy);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn search_empty_filter_returns_all() {
|
async fn search_empty_filter_returns_all() {
|
||||||
let (_cmd, query, repo) = helpers::make_deps();
|
let (deps, repo) = helpers::make_deps();
|
||||||
seed_items(&repo);
|
seed_items(&repo);
|
||||||
|
|
||||||
let (items, total) = search::execute(
|
let (items, total) = search::execute(
|
||||||
&query,
|
&deps,
|
||||||
SearchItemsQuery {
|
SearchItemsQuery {
|
||||||
provider_id: None,
|
provider_id: None,
|
||||||
content_type: None,
|
content_type: None,
|
||||||
@@ -114,11 +123,11 @@ async fn search_empty_filter_returns_all() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn search_by_content_type() {
|
async fn search_by_content_type() {
|
||||||
let (_cmd, query, repo) = helpers::make_deps();
|
let (deps, repo) = helpers::make_deps();
|
||||||
seed_items(&repo);
|
seed_items(&repo);
|
||||||
|
|
||||||
let (items, total) = search::execute(
|
let (items, total) = search::execute(
|
||||||
&query,
|
&deps,
|
||||||
SearchItemsQuery {
|
SearchItemsQuery {
|
||||||
provider_id: None,
|
provider_id: None,
|
||||||
content_type: Some("movie".into()),
|
content_type: Some("movie".into()),
|
||||||
@@ -141,11 +150,11 @@ async fn search_by_content_type() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn search_by_genre() {
|
async fn search_by_genre() {
|
||||||
let (_cmd, query, repo) = helpers::make_deps();
|
let (deps, repo) = helpers::make_deps();
|
||||||
seed_items_with_genres(&repo);
|
seed_items_with_genres(&repo);
|
||||||
|
|
||||||
let (items, total) = search::execute(
|
let (items, total) = search::execute(
|
||||||
&query,
|
&deps,
|
||||||
SearchItemsQuery {
|
SearchItemsQuery {
|
||||||
provider_id: None,
|
provider_id: None,
|
||||||
content_type: None,
|
content_type: None,
|
||||||
@@ -169,11 +178,11 @@ async fn search_by_genre() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn search_by_search_term() {
|
async fn search_by_search_term() {
|
||||||
let (_cmd, query, repo) = helpers::make_deps();
|
let (deps, repo) = helpers::make_deps();
|
||||||
seed_items(&repo);
|
seed_items(&repo);
|
||||||
|
|
||||||
let (items, total) = search::execute(
|
let (items, total) = search::execute(
|
||||||
&query,
|
&deps,
|
||||||
SearchItemsQuery {
|
SearchItemsQuery {
|
||||||
provider_id: None,
|
provider_id: None,
|
||||||
content_type: None,
|
content_type: None,
|
||||||
@@ -197,10 +206,10 @@ async fn search_by_search_term() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn search_invalid_content_type_errors() {
|
async fn search_invalid_content_type_errors() {
|
||||||
let (_cmd, query, _repo) = helpers::make_deps();
|
let (deps, _repo) = helpers::make_deps();
|
||||||
|
|
||||||
let result = search::execute(
|
let result = search::execute(
|
||||||
&query,
|
&deps,
|
||||||
SearchItemsQuery {
|
SearchItemsQuery {
|
||||||
provider_id: None,
|
provider_id: None,
|
||||||
content_type: Some("bogus".into()),
|
content_type: Some("bogus".into()),
|
||||||
@@ -221,11 +230,11 @@ async fn search_invalid_content_type_errors() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn search_pagination() {
|
async fn search_pagination() {
|
||||||
let (_cmd, query, repo) = helpers::make_deps();
|
let (deps, repo) = helpers::make_deps();
|
||||||
seed_items(&repo);
|
seed_items(&repo);
|
||||||
|
|
||||||
let (items, total) = search::execute(
|
let (items, total) = search::execute(
|
||||||
&query,
|
&deps,
|
||||||
SearchItemsQuery {
|
SearchItemsQuery {
|
||||||
provider_id: None,
|
provider_id: None,
|
||||||
content_type: None,
|
content_type: None,
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ mod helpers;
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn trigger_sync_returns_provider_ids() {
|
async fn trigger_sync_returns_provider_ids() {
|
||||||
let (cmd, _query, _repo) = helpers::make_deps();
|
let (deps, _repo) = helpers::make_deps();
|
||||||
|
|
||||||
let ids = sync::execute(
|
let ids = sync::execute(
|
||||||
&cmd,
|
&deps,
|
||||||
TriggerSyncCommand { provider_id: None },
|
TriggerSyncCommand { provider_id: None },
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -20,10 +20,10 @@ async fn trigger_sync_returns_provider_ids() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn trigger_sync_specific_provider() {
|
async fn trigger_sync_specific_provider() {
|
||||||
let (cmd, _query, _repo) = helpers::make_deps();
|
let (deps, _repo) = helpers::make_deps();
|
||||||
|
|
||||||
let ids = sync::execute(
|
let ids = sync::execute(
|
||||||
&cmd,
|
&deps,
|
||||||
TriggerSyncCommand {
|
TriggerSyncCommand {
|
||||||
provider_id: Some("test".into()),
|
provider_id: Some("test".into()),
|
||||||
},
|
},
|
||||||
@@ -36,16 +36,15 @@ async fn trigger_sync_specific_provider() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn trigger_sync_while_running_errors() {
|
async fn trigger_sync_while_running_errors() {
|
||||||
let (cmd, _query, repo) = helpers::make_deps();
|
let (deps, repo) = helpers::make_deps();
|
||||||
|
|
||||||
// Simulate a running sync by inserting a log entry with "running" status
|
repo.items.lock().unwrap();
|
||||||
repo.items.lock().unwrap(); // just verify repo is accessible
|
let _log_id = domain::ports::LibraryCommand::log_sync_start(&*deps.library_command, "test")
|
||||||
let _log_id = domain::ports::LibraryCommand::log_sync_start(&*cmd.library_command, "test")
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let result = sync::execute(
|
let result = sync::execute(
|
||||||
&cmd,
|
&deps,
|
||||||
TriggerSyncCommand { provider_id: None },
|
TriggerSyncCommand { provider_id: None },
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -4,7 +4,3 @@ pub struct UpsertProviderCommand {
|
|||||||
pub config: serde_json::Value,
|
pub config: serde_json::Value,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DeleteProviderCommand {
|
|
||||||
pub id: String,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
use domain::DomainResult;
|
|
||||||
|
|
||||||
use super::commands::DeleteProviderCommand;
|
|
||||||
use super::deps::ProviderDeps;
|
|
||||||
|
|
||||||
pub async fn execute(deps: &ProviderDeps, cmd: DeleteProviderCommand) -> DomainResult<()> {
|
|
||||||
deps.provider_config_command.delete(&cmd.id).await
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
use domain::models::ProviderConfigRow;
|
|
||||||
use domain::DomainResult;
|
|
||||||
|
|
||||||
use super::deps::ProviderDeps;
|
|
||||||
use super::queries::GetProviderQuery;
|
|
||||||
|
|
||||||
pub async fn execute(
|
|
||||||
deps: &ProviderDeps,
|
|
||||||
query: GetProviderQuery,
|
|
||||||
) -> DomainResult<Option<ProviderConfigRow>> {
|
|
||||||
deps.provider_config_query.get_by_id(&query.id).await
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
use domain::models::ProviderConfigRow;
|
|
||||||
use domain::DomainResult;
|
|
||||||
|
|
||||||
use super::deps::ProviderDeps;
|
|
||||||
use super::queries::ListProvidersQuery;
|
|
||||||
|
|
||||||
pub async fn execute(
|
|
||||||
deps: &ProviderDeps,
|
|
||||||
_query: ListProvidersQuery,
|
|
||||||
) -> DomainResult<Vec<ProviderConfigRow>> {
|
|
||||||
deps.provider_config_query.get_all().await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/list.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod delete;
|
|
||||||
pub mod deps;
|
pub mod deps;
|
||||||
pub mod get;
|
|
||||||
pub mod list;
|
|
||||||
pub mod queries;
|
|
||||||
pub mod upsert;
|
pub mod upsert;
|
||||||
|
|
||||||
pub use commands::{DeleteProviderCommand, UpsertProviderCommand};
|
pub use commands::UpsertProviderCommand;
|
||||||
pub use deps::ProviderDeps;
|
pub use deps::ProviderDeps;
|
||||||
pub use queries::{GetProviderQuery, ListProvidersQuery};
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
pub struct ListProvidersQuery;
|
|
||||||
|
|
||||||
pub struct GetProviderQuery {
|
|
||||||
pub id: String,
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use domain::testing::InMemoryProviderConfig;
|
|
||||||
|
|
||||||
use crate::providers::commands::UpsertProviderCommand;
|
|
||||||
use crate::providers::deps::ProviderDeps;
|
|
||||||
use crate::providers::queries::ListProvidersQuery;
|
|
||||||
use crate::providers::{list, upsert};
|
|
||||||
|
|
||||||
fn make_deps() -> ProviderDeps {
|
|
||||||
let repo = Arc::new(InMemoryProviderConfig::new());
|
|
||||||
ProviderDeps {
|
|
||||||
provider_config_command: repo.clone(),
|
|
||||||
provider_config_query: repo,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_empty() {
|
|
||||||
let deps = make_deps();
|
|
||||||
let providers = list::execute(&deps, ListProvidersQuery).await.unwrap();
|
|
||||||
assert!(providers.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_returns_all_providers() {
|
|
||||||
let deps = make_deps();
|
|
||||||
|
|
||||||
for id in ["jf-1", "local-1"] {
|
|
||||||
upsert::execute(
|
|
||||||
&deps,
|
|
||||||
UpsertProviderCommand {
|
|
||||||
id: id.into(),
|
|
||||||
provider_type: "jellyfin".into(),
|
|
||||||
config: serde_json::json!({}),
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let providers = list::execute(&deps, ListProvidersQuery).await.unwrap();
|
|
||||||
assert_eq!(providers.len(), 2);
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,3 @@ use uuid::Uuid;
|
|||||||
pub struct GenerateScheduleCommand {
|
pub struct GenerateScheduleCommand {
|
||||||
pub channel_id: Uuid,
|
pub channel_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DeleteSchedulesAfterCommand {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub target_generation: u32,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
use domain::value_objects::ChannelId;
|
|
||||||
use domain::DomainResult;
|
|
||||||
|
|
||||||
use super::commands::DeleteSchedulesAfterCommand;
|
|
||||||
use super::deps::ScheduleDeps;
|
|
||||||
|
|
||||||
pub async fn execute(
|
|
||||||
deps: &ScheduleDeps,
|
|
||||||
cmd: DeleteSchedulesAfterCommand,
|
|
||||||
) -> DomainResult<()> {
|
|
||||||
let channel_id = ChannelId::from(cmd.channel_id);
|
|
||||||
deps.schedule_command
|
|
||||||
.delete_schedules_after(channel_id, cmd.target_generation)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/delete_after.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::ports::{ChannelQuery, EventPublisher, ScheduleCommand, ScheduleQuery};
|
use domain::ports::{ChannelQuery, EventPublisher, IProviderRegistry, ScheduleCommand, ScheduleQuery};
|
||||||
use domain::ScheduleEngineService;
|
use domain::ScheduleEngineService;
|
||||||
|
|
||||||
pub struct ScheduleDeps {
|
pub struct ScheduleDeps {
|
||||||
@@ -9,4 +9,5 @@ pub struct ScheduleDeps {
|
|||||||
pub schedule_query: Arc<dyn ScheduleQuery>,
|
pub schedule_query: Arc<dyn ScheduleQuery>,
|
||||||
pub schedule_command: Arc<dyn ScheduleCommand>,
|
pub schedule_command: Arc<dyn ScheduleCommand>,
|
||||||
pub event_publisher: Arc<dyn EventPublisher>,
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
|
pub provider_registry: Arc<dyn IProviderRegistry>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
use chrono::Utc;
|
|
||||||
|
|
||||||
use domain::models::GeneratedSchedule;
|
|
||||||
use domain::value_objects::ChannelId;
|
|
||||||
use domain::DomainResult;
|
|
||||||
|
|
||||||
use super::deps::ScheduleDeps;
|
|
||||||
use super::queries::GetActiveScheduleQuery;
|
|
||||||
|
|
||||||
pub async fn execute(
|
|
||||||
deps: &ScheduleDeps,
|
|
||||||
query: GetActiveScheduleQuery,
|
|
||||||
) -> DomainResult<Option<GeneratedSchedule>> {
|
|
||||||
let channel_id = ChannelId::from(query.channel_id);
|
|
||||||
deps.schedule_query.find_active(channel_id, Utc::now()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/get_active.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
||||||
use domain::ports::StreamQuality;
|
use domain::value_objects::{ChannelId, SourceUri};
|
||||||
use domain::value_objects::ChannelId;
|
|
||||||
use domain::{DomainResult, ScheduleEngineService};
|
use domain::{DomainResult, ScheduleEngineService};
|
||||||
|
|
||||||
use super::deps::ScheduleDeps;
|
use super::deps::ScheduleDeps;
|
||||||
use super::queries::GetStreamUrlQuery;
|
use super::queries::GetSourceQuery;
|
||||||
|
|
||||||
pub async fn execute(deps: &ScheduleDeps, query: GetStreamUrlQuery) -> DomainResult<Option<String>> {
|
pub async fn execute(deps: &ScheduleDeps, query: GetSourceQuery) -> DomainResult<Option<SourceUri>> {
|
||||||
let channel_id = ChannelId::from(query.channel_id);
|
let channel_id = ChannelId::from(query.channel_id);
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|
||||||
@@ -22,9 +21,9 @@ pub async fn execute(deps: &ScheduleDeps, query: GetStreamUrlQuery) -> DomainRes
|
|||||||
};
|
};
|
||||||
|
|
||||||
let item_id = broadcast.slot().item().id().clone();
|
let item_id = broadcast.slot().item().id().clone();
|
||||||
let url = deps
|
let uri = deps
|
||||||
.schedule_engine
|
.provider_registry
|
||||||
.get_stream_url(&item_id, &StreamQuality::Direct)
|
.get_source_uri(&item_id)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Some(url))
|
Ok(Some(uri))
|
||||||
}
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
use domain::models::GeneratedSchedule;
|
|
||||||
use domain::value_objects::ChannelId;
|
|
||||||
use domain::DomainResult;
|
|
||||||
|
|
||||||
use super::deps::ScheduleDeps;
|
|
||||||
use super::queries::ListHistoryQuery;
|
|
||||||
|
|
||||||
pub async fn execute(
|
|
||||||
deps: &ScheduleDeps,
|
|
||||||
query: ListHistoryQuery,
|
|
||||||
) -> DomainResult<Vec<GeneratedSchedule>> {
|
|
||||||
let channel_id = ChannelId::from(query.channel_id);
|
|
||||||
deps.schedule_query
|
|
||||||
.list_schedule_history(channel_id)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/list_history.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,17 +1,11 @@
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod delete_after;
|
|
||||||
pub mod deps;
|
pub mod deps;
|
||||||
pub mod generate;
|
pub mod generate;
|
||||||
pub mod get_active;
|
|
||||||
pub mod get_current_broadcast;
|
pub mod get_current_broadcast;
|
||||||
pub mod get_epg;
|
pub mod get_epg;
|
||||||
pub mod get_stream_url;
|
pub mod get_source;
|
||||||
pub mod list_history;
|
|
||||||
pub mod queries;
|
pub mod queries;
|
||||||
|
|
||||||
pub use commands::{DeleteSchedulesAfterCommand, GenerateScheduleCommand};
|
pub use commands::GenerateScheduleCommand;
|
||||||
pub use deps::ScheduleDeps;
|
pub use deps::ScheduleDeps;
|
||||||
pub use queries::{
|
pub use queries::{GetCurrentBroadcastQuery, GetEpgQuery, GetSourceQuery};
|
||||||
GetActiveScheduleQuery, GetCurrentBroadcastQuery, GetEpgQuery, GetStreamUrlQuery,
|
|
||||||
ListHistoryQuery,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub struct GetActiveScheduleQuery {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct GetCurrentBroadcastQuery {
|
pub struct GetCurrentBroadcastQuery {
|
||||||
pub channel_id: Uuid,
|
pub channel_id: Uuid,
|
||||||
}
|
}
|
||||||
@@ -12,10 +8,6 @@ pub struct GetEpgQuery {
|
|||||||
pub channel_id: Uuid,
|
pub channel_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct GetStreamUrlQuery {
|
pub struct GetSourceQuery {
|
||||||
pub channel_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ListHistoryQuery {
|
|
||||||
pub channel_id: Uuid,
|
pub channel_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
use domain::models::{Channel, GeneratedSchedule};
|
|
||||||
use domain::value_objects::UserId;
|
|
||||||
|
|
||||||
use crate::schedule::commands::DeleteSchedulesAfterCommand;
|
|
||||||
use crate::schedule::delete_after;
|
|
||||||
use crate::schedule::queries::ListHistoryQuery;
|
|
||||||
use crate::schedule::list_history;
|
|
||||||
|
|
||||||
#[path = "helpers.rs"]
|
|
||||||
mod helpers;
|
|
||||||
use helpers::make_schedule_deps;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn delete_after_removes_later_generations() {
|
|
||||||
let (deps, channel_repo, schedule_repo) = make_schedule_deps();
|
|
||||||
|
|
||||||
let channel = Channel::new(UserId::generate(), "Cleanup", "UTC");
|
|
||||||
let channel_id = channel.id();
|
|
||||||
channel_repo
|
|
||||||
.channels
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.insert(channel_id, channel);
|
|
||||||
|
|
||||||
// Manually insert schedules with different generations.
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
for generation in 1..=3 {
|
|
||||||
let sched = GeneratedSchedule::new(
|
|
||||||
channel_id,
|
|
||||||
now,
|
|
||||||
now + chrono::Duration::hours(24),
|
|
||||||
generation,
|
|
||||||
vec![],
|
|
||||||
);
|
|
||||||
schedule_repo
|
|
||||||
.schedules
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.insert(sched.id(), sched);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete generations > 1.
|
|
||||||
delete_after::execute(
|
|
||||||
&deps,
|
|
||||||
DeleteSchedulesAfterCommand {
|
|
||||||
channel_id: channel_id.value(),
|
|
||||||
target_generation: 1,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let remaining = list_history::execute(
|
|
||||||
&deps,
|
|
||||||
ListHistoryQuery {
|
|
||||||
channel_id: channel_id.value(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(remaining.len(), 1);
|
|
||||||
assert_eq!(remaining[0].generation(), 1);
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
use crate::schedule::get_active;
|
|
||||||
use crate::schedule::queries::GetActiveScheduleQuery;
|
|
||||||
|
|
||||||
#[path = "helpers.rs"]
|
|
||||||
mod helpers;
|
|
||||||
use helpers::make_schedule_deps;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn returns_none_when_no_schedule_exists() {
|
|
||||||
let (deps, _, _) = make_schedule_deps();
|
|
||||||
|
|
||||||
let result = get_active::execute(
|
|
||||||
&deps,
|
|
||||||
GetActiveScheduleQuery {
|
|
||||||
channel_id: uuid::Uuid::new_v4(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(result.is_none());
|
|
||||||
}
|
|
||||||
@@ -4,17 +4,14 @@ use async_trait::async_trait;
|
|||||||
|
|
||||||
use domain::errors::DomainResult;
|
use domain::errors::DomainResult;
|
||||||
use domain::models::MediaItem;
|
use domain::models::MediaItem;
|
||||||
use domain::ports::{
|
use domain::ports::{Collection, IProviderRegistry, ProviderCapabilities, SeriesSummary};
|
||||||
Collection, IProviderRegistry, ProviderCapabilities,
|
use domain::SourceUri;
|
||||||
SeriesSummary, StreamQuality, StreamingProtocol,
|
use domain::testing::{InMemoryChannelRepository, InMemoryLibraryRepository, InMemoryScheduleRepository, NoopEventPublisher};
|
||||||
};
|
|
||||||
use domain::testing::{InMemoryChannelRepository, InMemoryScheduleRepository, NoopEventPublisher};
|
|
||||||
use domain::value_objects::{ContentType, MediaFilter, MediaItemId};
|
use domain::value_objects::{ContentType, MediaFilter, MediaItemId};
|
||||||
use domain::ScheduleEngineService;
|
use domain::ScheduleEngineService;
|
||||||
|
|
||||||
use crate::schedule::deps::ScheduleDeps;
|
use crate::schedule::deps::ScheduleDeps;
|
||||||
|
|
||||||
/// Minimal IProviderRegistry backed by a NoopMediaProvider.
|
|
||||||
pub(crate) struct TestProviderRegistry;
|
pub(crate) struct TestProviderRegistry;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -31,11 +28,7 @@ impl IProviderRegistry for TestProviderRegistry {
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_stream_url(
|
async fn get_source_uri(&self, _item_id: &MediaItemId) -> DomainResult<SourceUri> {
|
||||||
&self,
|
|
||||||
_item_id: &MediaItemId,
|
|
||||||
_quality: &StreamQuality,
|
|
||||||
) -> DomainResult<String> {
|
|
||||||
Err(domain::DomainError::InfrastructureError(
|
Err(domain::DomainError::InfrastructureError(
|
||||||
"TestProviderRegistry does not support streaming".into(),
|
"TestProviderRegistry does not support streaming".into(),
|
||||||
))
|
))
|
||||||
@@ -57,9 +50,7 @@ impl IProviderRegistry for TestProviderRegistry {
|
|||||||
tags: false,
|
tags: false,
|
||||||
decade: false,
|
decade: false,
|
||||||
search: false,
|
search: false,
|
||||||
streaming_protocol: StreamingProtocol::Hls,
|
|
||||||
rescan: false,
|
rescan: false,
|
||||||
transcode: false,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,9 +75,6 @@ impl IProviderRegistry for TestProviderRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build ScheduleDeps backed by InMemory repos and a test provider registry.
|
|
||||||
///
|
|
||||||
/// Returns the deps plus the underlying repos for test assertions.
|
|
||||||
pub(crate) fn make_schedule_deps() -> (
|
pub(crate) fn make_schedule_deps() -> (
|
||||||
ScheduleDeps,
|
ScheduleDeps,
|
||||||
Arc<InMemoryChannelRepository>,
|
Arc<InMemoryChannelRepository>,
|
||||||
@@ -94,10 +82,11 @@ pub(crate) fn make_schedule_deps() -> (
|
|||||||
) {
|
) {
|
||||||
let channel_repo = Arc::new(InMemoryChannelRepository::new());
|
let channel_repo = Arc::new(InMemoryChannelRepository::new());
|
||||||
let schedule_repo = Arc::new(InMemoryScheduleRepository::new());
|
let schedule_repo = Arc::new(InMemoryScheduleRepository::new());
|
||||||
|
let library_repo = Arc::new(InMemoryLibraryRepository::new());
|
||||||
let provider_registry = Arc::new(TestProviderRegistry);
|
let provider_registry = Arc::new(TestProviderRegistry);
|
||||||
|
|
||||||
let engine = Arc::new(ScheduleEngineService::new(
|
let engine = Arc::new(ScheduleEngineService::new(
|
||||||
provider_registry,
|
library_repo,
|
||||||
channel_repo.clone(),
|
channel_repo.clone(),
|
||||||
schedule_repo.clone(),
|
schedule_repo.clone(),
|
||||||
schedule_repo.clone(),
|
schedule_repo.clone(),
|
||||||
@@ -109,6 +98,7 @@ pub(crate) fn make_schedule_deps() -> (
|
|||||||
schedule_query: schedule_repo.clone(),
|
schedule_query: schedule_repo.clone(),
|
||||||
schedule_command: schedule_repo.clone(),
|
schedule_command: schedule_repo.clone(),
|
||||||
event_publisher: Arc::new(NoopEventPublisher::new()),
|
event_publisher: Arc::new(NoopEventPublisher::new()),
|
||||||
|
provider_registry,
|
||||||
};
|
};
|
||||||
|
|
||||||
(deps, channel_repo, schedule_repo)
|
(deps, channel_repo, schedule_repo)
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
use crate::schedule::list_history;
|
|
||||||
use crate::schedule::queries::ListHistoryQuery;
|
|
||||||
|
|
||||||
#[path = "helpers.rs"]
|
|
||||||
mod helpers;
|
|
||||||
use helpers::make_schedule_deps;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn returns_empty_for_new_channel() {
|
|
||||||
let (deps, _, _) = make_schedule_deps();
|
|
||||||
|
|
||||||
let result = list_history::execute(
|
|
||||||
&deps,
|
|
||||||
ListHistoryQuery {
|
|
||||||
channel_id: uuid::Uuid::new_v4(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(result.is_empty());
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::value_objects::{ChannelId, ScheduleId, SlotId};
|
use crate::value_objects::{ChannelId, ScheduleId, SlotId};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
pub enum DomainEvent {
|
pub enum DomainEvent {
|
||||||
BroadcastTransition {
|
BroadcastTransition {
|
||||||
@@ -18,6 +20,58 @@ pub enum DomainEvent {
|
|||||||
UserRegistered { user_id: crate::value_objects::UserId },
|
UserRegistered { user_id: crate::value_objects::UserId },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct EventEnvelope {
|
||||||
|
id: i64,
|
||||||
|
event: DomainEvent,
|
||||||
|
retry_count: u32,
|
||||||
|
created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEnvelope {
|
||||||
|
pub fn new(id: i64, event: DomainEvent) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
event,
|
||||||
|
retry_count: 0,
|
||||||
|
created_at: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_persistence(
|
||||||
|
id: i64,
|
||||||
|
event: DomainEvent,
|
||||||
|
retry_count: u32,
|
||||||
|
created_at: String,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
event,
|
||||||
|
retry_count,
|
||||||
|
created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id(&self) -> i64 {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn event(&self) -> &DomainEvent {
|
||||||
|
&self.event
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_event(self) -> DomainEvent {
|
||||||
|
self.event
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn retry_count(&self) -> u32 {
|
||||||
|
self.retry_count
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn created_at(&self) -> &str {
|
||||||
|
&self.created_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "tests/mod.rs"]
|
#[path = "tests/mod.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ pub mod value_objects;
|
|||||||
pub use errors::{DomainError, DomainResult};
|
pub use errors::{DomainError, DomainResult};
|
||||||
pub use events::DomainEvent;
|
pub use events::DomainEvent;
|
||||||
pub use models::*;
|
pub use models::*;
|
||||||
pub use services::{generate_m3u, generate_xmltv, ScheduleEngineService};
|
pub use services::{generate_ical, generate_m3u, generate_xmltv, parse_ical, ScheduleEngineService};
|
||||||
pub use value_objects::*;
|
pub use value_objects::*;
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::value_objects::{
|
use crate::value_objects::{
|
||||||
AccessMode, BlockId, ChannelId, FillStrategy, LogoPosition, MediaFilter, MediaItemId,
|
AccessMode, BlockId, ChannelId, FillStrategy, InterstitialRule, LogoPosition, MediaFilter,
|
||||||
RecyclePolicy, UserId, Weekday,
|
MediaItemId, MidRollRule, RotationPolicy, UserId, Weekday,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SECONDS_IN_DAY: u32 = 86_400;
|
const SECONDS_IN_DAY: u32 = 86_400;
|
||||||
@@ -19,10 +19,9 @@ pub struct Channel {
|
|||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
timezone: String,
|
timezone: String,
|
||||||
schedule_config: ScheduleConfig,
|
schedule_config: ScheduleConfig,
|
||||||
recycle_policy: RecyclePolicy,
|
rotation_policy: RotationPolicy,
|
||||||
auto_schedule: bool,
|
auto_schedule: bool,
|
||||||
access_mode: AccessMode,
|
access_mode: AccessMode,
|
||||||
access_password_hash: Option<String>,
|
|
||||||
logo: Option<String>,
|
logo: Option<String>,
|
||||||
logo_position: LogoPosition,
|
logo_position: LogoPosition,
|
||||||
logo_opacity: f32,
|
logo_opacity: f32,
|
||||||
@@ -30,6 +29,8 @@ pub struct Channel {
|
|||||||
webhook_poll_interval_secs: u32,
|
webhook_poll_interval_secs: u32,
|
||||||
webhook_body_template: Option<String>,
|
webhook_body_template: Option<String>,
|
||||||
webhook_headers: Option<String>,
|
webhook_headers: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
gap_filler: Option<MediaFilter>,
|
||||||
created_at: DateTime<Utc>,
|
created_at: DateTime<Utc>,
|
||||||
updated_at: DateTime<Utc>,
|
updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
@@ -41,10 +42,9 @@ pub struct ChannelRow {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub timezone: String,
|
pub timezone: String,
|
||||||
pub schedule_config: ScheduleConfig,
|
pub schedule_config: ScheduleConfig,
|
||||||
pub recycle_policy: RecyclePolicy,
|
pub rotation_policy: RotationPolicy,
|
||||||
pub auto_schedule: bool,
|
pub auto_schedule: bool,
|
||||||
pub access_mode: AccessMode,
|
pub access_mode: AccessMode,
|
||||||
pub access_password_hash: Option<String>,
|
|
||||||
pub logo: Option<String>,
|
pub logo: Option<String>,
|
||||||
pub logo_position: LogoPosition,
|
pub logo_position: LogoPosition,
|
||||||
pub logo_opacity: f32,
|
pub logo_opacity: f32,
|
||||||
@@ -52,6 +52,7 @@ pub struct ChannelRow {
|
|||||||
pub webhook_poll_interval_secs: u32,
|
pub webhook_poll_interval_secs: u32,
|
||||||
pub webhook_body_template: Option<String>,
|
pub webhook_body_template: Option<String>,
|
||||||
pub webhook_headers: Option<String>,
|
pub webhook_headers: Option<String>,
|
||||||
|
pub gap_filler: Option<MediaFilter>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
@@ -70,10 +71,9 @@ impl Channel {
|
|||||||
description: None,
|
description: None,
|
||||||
timezone: timezone.into(),
|
timezone: timezone.into(),
|
||||||
schedule_config: ScheduleConfig::default(),
|
schedule_config: ScheduleConfig::default(),
|
||||||
recycle_policy: RecyclePolicy::default(),
|
rotation_policy: RotationPolicy::default(),
|
||||||
auto_schedule: false,
|
auto_schedule: false,
|
||||||
access_mode: AccessMode::default(),
|
access_mode: AccessMode::default(),
|
||||||
access_password_hash: None,
|
|
||||||
logo: None,
|
logo: None,
|
||||||
logo_position: LogoPosition::default(),
|
logo_position: LogoPosition::default(),
|
||||||
logo_opacity: DEFAULT_LOGO_OPACITY,
|
logo_opacity: DEFAULT_LOGO_OPACITY,
|
||||||
@@ -81,6 +81,7 @@ impl Channel {
|
|||||||
webhook_poll_interval_secs: DEFAULT_WEBHOOK_POLL_INTERVAL_SECS,
|
webhook_poll_interval_secs: DEFAULT_WEBHOOK_POLL_INTERVAL_SECS,
|
||||||
webhook_body_template: None,
|
webhook_body_template: None,
|
||||||
webhook_headers: None,
|
webhook_headers: None,
|
||||||
|
gap_filler: None,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
}
|
||||||
@@ -94,10 +95,9 @@ impl Channel {
|
|||||||
description: row.description,
|
description: row.description,
|
||||||
timezone: row.timezone,
|
timezone: row.timezone,
|
||||||
schedule_config: row.schedule_config,
|
schedule_config: row.schedule_config,
|
||||||
recycle_policy: row.recycle_policy,
|
rotation_policy: row.rotation_policy,
|
||||||
auto_schedule: row.auto_schedule,
|
auto_schedule: row.auto_schedule,
|
||||||
access_mode: row.access_mode,
|
access_mode: row.access_mode,
|
||||||
access_password_hash: row.access_password_hash,
|
|
||||||
logo: row.logo,
|
logo: row.logo,
|
||||||
logo_position: row.logo_position,
|
logo_position: row.logo_position,
|
||||||
logo_opacity: row.logo_opacity,
|
logo_opacity: row.logo_opacity,
|
||||||
@@ -105,6 +105,7 @@ impl Channel {
|
|||||||
webhook_poll_interval_secs: row.webhook_poll_interval_secs,
|
webhook_poll_interval_secs: row.webhook_poll_interval_secs,
|
||||||
webhook_body_template: row.webhook_body_template,
|
webhook_body_template: row.webhook_body_template,
|
||||||
webhook_headers: row.webhook_headers,
|
webhook_headers: row.webhook_headers,
|
||||||
|
gap_filler: row.gap_filler,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
updated_at: row.updated_at,
|
updated_at: row.updated_at,
|
||||||
}
|
}
|
||||||
@@ -134,8 +135,8 @@ impl Channel {
|
|||||||
&self.schedule_config
|
&self.schedule_config
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn recycle_policy(&self) -> &RecyclePolicy {
|
pub fn rotation_policy(&self) -> &RotationPolicy {
|
||||||
&self.recycle_policy
|
&self.rotation_policy
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn auto_schedule(&self) -> bool {
|
pub fn auto_schedule(&self) -> bool {
|
||||||
@@ -146,10 +147,6 @@ impl Channel {
|
|||||||
&self.access_mode
|
&self.access_mode
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn access_password_hash(&self) -> Option<&str> {
|
|
||||||
self.access_password_hash.as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn logo(&self) -> Option<&str> {
|
pub fn logo(&self) -> Option<&str> {
|
||||||
self.logo.as_deref()
|
self.logo.as_deref()
|
||||||
}
|
}
|
||||||
@@ -206,8 +203,8 @@ impl Channel {
|
|||||||
self.updated_at = Utc::now();
|
self.updated_at = Utc::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_recycle_policy(&mut self, policy: RecyclePolicy) {
|
pub fn set_rotation_policy(&mut self, policy: RotationPolicy) {
|
||||||
self.recycle_policy = policy;
|
self.rotation_policy = policy;
|
||||||
self.updated_at = Utc::now();
|
self.updated_at = Utc::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +212,15 @@ impl Channel {
|
|||||||
self.auto_schedule = enabled;
|
self.auto_schedule = enabled;
|
||||||
self.updated_at = Utc::now();
|
self.updated_at = Utc::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn gap_filler(&self) -> Option<&MediaFilter> {
|
||||||
|
self.gap_filler.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_gap_filler(&mut self, gap_filler: Option<MediaFilter>) {
|
||||||
|
self.gap_filler = gap_filler;
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// deny_unknown_fields required so #[serde(untagged)] compat enum correctly rejects V1 payloads
|
// deny_unknown_fields required so #[serde(untagged)] compat enum correctly rejects V1 payloads
|
||||||
@@ -276,6 +282,13 @@ impl ScheduleConfig {
|
|||||||
&self.day_blocks
|
&self.day_blocks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn find_block_mut(&mut self, block_id: BlockId) -> Option<&mut ProgrammingBlock> {
|
||||||
|
self.day_blocks
|
||||||
|
.values_mut()
|
||||||
|
.flatten()
|
||||||
|
.find(|b| b.id() == block_id)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn insert_day(&mut self, day: Weekday, blocks: Vec<ProgrammingBlock>) {
|
pub fn insert_day(&mut self, day: Weekday, blocks: Vec<ProgrammingBlock>) {
|
||||||
self.day_blocks.insert(day, blocks);
|
self.day_blocks.insert(day, blocks);
|
||||||
}
|
}
|
||||||
@@ -331,13 +344,13 @@ pub struct ProgrammingBlock {
|
|||||||
loop_on_finish: bool,
|
loop_on_finish: bool,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
ignore_recycle_policy: bool,
|
ignore_rotation_policy: bool,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
access_mode: AccessMode,
|
interstitial_rule: Option<InterstitialRule>,
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default)]
|
||||||
access_password_hash: Option<String>,
|
mid_roll_rule: Option<MidRollRule>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProgrammingBlock {
|
impl ProgrammingBlock {
|
||||||
@@ -356,12 +369,33 @@ impl ProgrammingBlock {
|
|||||||
content: BlockContent::Algorithmic {
|
content: BlockContent::Algorithmic {
|
||||||
filter,
|
filter,
|
||||||
strategy,
|
strategy,
|
||||||
provider_id: String::new(),
|
|
||||||
},
|
},
|
||||||
loop_on_finish: true,
|
loop_on_finish: true,
|
||||||
ignore_recycle_policy: false,
|
ignore_rotation_policy: false,
|
||||||
access_mode: AccessMode::default(),
|
interstitial_rule: None,
|
||||||
access_password_hash: None,
|
mid_roll_rule: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_parts(
|
||||||
|
id: BlockId,
|
||||||
|
name: impl Into<String>,
|
||||||
|
start_time: NaiveTime,
|
||||||
|
duration_mins: u32,
|
||||||
|
content: BlockContent,
|
||||||
|
interstitial_rule: Option<InterstitialRule>,
|
||||||
|
mid_roll_rule: Option<MidRollRule>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
name: name.into(),
|
||||||
|
start_time,
|
||||||
|
duration_mins,
|
||||||
|
content,
|
||||||
|
loop_on_finish: true,
|
||||||
|
ignore_rotation_policy: false,
|
||||||
|
interstitial_rule,
|
||||||
|
mid_roll_rule,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,12 +412,11 @@ impl ProgrammingBlock {
|
|||||||
duration_mins,
|
duration_mins,
|
||||||
content: BlockContent::Manual {
|
content: BlockContent::Manual {
|
||||||
items,
|
items,
|
||||||
provider_id: String::new(),
|
|
||||||
},
|
},
|
||||||
loop_on_finish: true,
|
loop_on_finish: true,
|
||||||
ignore_recycle_policy: false,
|
ignore_rotation_policy: false,
|
||||||
access_mode: AccessMode::default(),
|
interstitial_rule: None,
|
||||||
access_password_hash: None,
|
mid_roll_rule: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,16 +444,34 @@ impl ProgrammingBlock {
|
|||||||
self.loop_on_finish
|
self.loop_on_finish
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ignore_recycle_policy(&self) -> bool {
|
pub fn ignore_rotation_policy(&self) -> bool {
|
||||||
self.ignore_recycle_policy
|
self.ignore_rotation_policy
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn access_mode(&self) -> &AccessMode {
|
pub fn interstitial_rule(&self) -> Option<&InterstitialRule> {
|
||||||
&self.access_mode
|
self.interstitial_rule.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn access_password_hash(&self) -> Option<&str> {
|
pub fn mid_roll_rule(&self) -> Option<&MidRollRule> {
|
||||||
self.access_password_hash.as_deref()
|
self.mid_roll_rule.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_interstitial_rule(mut self, rule: InterstitialRule) -> Self {
|
||||||
|
self.interstitial_rule = Some(rule);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_mid_roll_rule(mut self, rule: MidRollRule) -> Self {
|
||||||
|
self.mid_roll_rule = Some(rule);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_interstitial_rule(&mut self, rule: Option<InterstitialRule>) {
|
||||||
|
self.interstitial_rule = rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_mid_roll_rule(&mut self, rule: Option<MidRollRule>) {
|
||||||
|
self.mid_roll_rule = rule;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,14 +480,10 @@ impl ProgrammingBlock {
|
|||||||
pub enum BlockContent {
|
pub enum BlockContent {
|
||||||
Manual {
|
Manual {
|
||||||
items: Vec<MediaItemId>,
|
items: Vec<MediaItemId>,
|
||||||
#[serde(default)]
|
|
||||||
provider_id: String,
|
|
||||||
},
|
},
|
||||||
Algorithmic {
|
Algorithmic {
|
||||||
filter: MediaFilter,
|
filter: MediaFilter,
|
||||||
strategy: FillStrategy,
|
strategy: FillStrategy,
|
||||||
#[serde(default)]
|
|
||||||
provider_id: String,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,172 +1,5 @@
|
|||||||
use crate::value_objects::ContentType;
|
|
||||||
|
|
||||||
const SYNC_STATUS_RUNNING: &str = "running";
|
const SYNC_STATUS_RUNNING: &str = "running";
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct LibraryItem {
|
|
||||||
id: String,
|
|
||||||
provider_id: String,
|
|
||||||
external_id: String,
|
|
||||||
title: String,
|
|
||||||
content_type: ContentType,
|
|
||||||
duration_secs: u32,
|
|
||||||
series_name: Option<String>,
|
|
||||||
season_number: Option<u32>,
|
|
||||||
episode_number: Option<u32>,
|
|
||||||
year: Option<u16>,
|
|
||||||
genres: Vec<String>,
|
|
||||||
tags: Vec<String>,
|
|
||||||
collection_id: Option<String>,
|
|
||||||
collection_name: Option<String>,
|
|
||||||
collection_type: Option<String>,
|
|
||||||
thumbnail_url: Option<String>,
|
|
||||||
synced_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct LibraryItemRow {
|
|
||||||
pub id: String,
|
|
||||||
pub provider_id: String,
|
|
||||||
pub external_id: String,
|
|
||||||
pub title: String,
|
|
||||||
pub content_type: ContentType,
|
|
||||||
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: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LibraryItem {
|
|
||||||
pub fn new(
|
|
||||||
provider_id: impl Into<String>,
|
|
||||||
external_id: impl Into<String>,
|
|
||||||
title: impl Into<String>,
|
|
||||||
content_type: ContentType,
|
|
||||||
duration_secs: u32,
|
|
||||||
synced_at: impl Into<String>,
|
|
||||||
) -> Self {
|
|
||||||
let provider_id = provider_id.into();
|
|
||||||
let external_id = external_id.into();
|
|
||||||
let id = format!("{}::{}", provider_id, external_id);
|
|
||||||
Self {
|
|
||||||
id,
|
|
||||||
provider_id,
|
|
||||||
external_id,
|
|
||||||
title: title.into(),
|
|
||||||
content_type,
|
|
||||||
duration_secs,
|
|
||||||
series_name: None,
|
|
||||||
season_number: None,
|
|
||||||
episode_number: None,
|
|
||||||
year: None,
|
|
||||||
genres: Vec::new(),
|
|
||||||
tags: Vec::new(),
|
|
||||||
collection_id: None,
|
|
||||||
collection_name: None,
|
|
||||||
collection_type: None,
|
|
||||||
thumbnail_url: None,
|
|
||||||
synced_at: synced_at.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_persistence(row: LibraryItemRow) -> Self {
|
|
||||||
Self {
|
|
||||||
id: row.id,
|
|
||||||
provider_id: row.provider_id,
|
|
||||||
external_id: row.external_id,
|
|
||||||
title: row.title,
|
|
||||||
content_type: row.content_type,
|
|
||||||
duration_secs: row.duration_secs,
|
|
||||||
series_name: row.series_name,
|
|
||||||
season_number: row.season_number,
|
|
||||||
episode_number: row.episode_number,
|
|
||||||
year: row.year,
|
|
||||||
genres: row.genres,
|
|
||||||
tags: row.tags,
|
|
||||||
collection_id: row.collection_id,
|
|
||||||
collection_name: row.collection_name,
|
|
||||||
collection_type: row.collection_type,
|
|
||||||
thumbnail_url: row.thumbnail_url,
|
|
||||||
synced_at: row.synced_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn id(&self) -> &str {
|
|
||||||
&self.id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn provider_id(&self) -> &str {
|
|
||||||
&self.provider_id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn external_id(&self) -> &str {
|
|
||||||
&self.external_id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn title(&self) -> &str {
|
|
||||||
&self.title
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn content_type(&self) -> &ContentType {
|
|
||||||
&self.content_type
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn duration_secs(&self) -> u32 {
|
|
||||||
self.duration_secs
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn series_name(&self) -> Option<&str> {
|
|
||||||
self.series_name.as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn season_number(&self) -> Option<u32> {
|
|
||||||
self.season_number
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn episode_number(&self) -> Option<u32> {
|
|
||||||
self.episode_number
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn year(&self) -> Option<u16> {
|
|
||||||
self.year
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn genres(&self) -> &[String] {
|
|
||||||
&self.genres
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn tags(&self) -> &[String] {
|
|
||||||
&self.tags
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn collection_id(&self) -> Option<&str> {
|
|
||||||
self.collection_id.as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn collection_name(&self) -> Option<&str> {
|
|
||||||
self.collection_name.as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn collection_type(&self) -> Option<&str> {
|
|
||||||
self.collection_type.as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn thumbnail_url(&self) -> Option<&str> {
|
|
||||||
self.thumbnail_url.as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn synced_at(&self) -> &str {
|
|
||||||
&self.synced_at
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct LibraryCollection {
|
pub struct LibraryCollection {
|
||||||
id: String,
|
id: String,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::value_objects::{ChannelId, ContentType, MediaItemId, PlaybackRecordId};
|
use crate::value_objects::{Chapter, ChannelId, ContentType, MediaItemId, MediaRole, PlaybackRecordId};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MediaItem {
|
pub struct MediaItem {
|
||||||
@@ -10,14 +10,27 @@ pub struct MediaItem {
|
|||||||
content_type: ContentType,
|
content_type: ContentType,
|
||||||
duration_secs: u32,
|
duration_secs: u32,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
genres: Vec<String>,
|
genres: Vec<String>,
|
||||||
year: Option<u16>,
|
year: Option<u16>,
|
||||||
|
#[serde(default)]
|
||||||
tags: Vec<String>,
|
tags: Vec<String>,
|
||||||
series_name: Option<String>,
|
series_name: Option<String>,
|
||||||
season_number: Option<u32>,
|
season_number: Option<u32>,
|
||||||
episode_number: Option<u32>,
|
episode_number: Option<u32>,
|
||||||
thumbnail_url: Option<String>,
|
thumbnail_url: Option<String>,
|
||||||
collection_id: Option<String>,
|
collection_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
provider_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
external_id: String,
|
||||||
|
collection_name: Option<String>,
|
||||||
|
collection_type: Option<String>,
|
||||||
|
synced_at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
role: MediaRole,
|
||||||
|
#[serde(default)]
|
||||||
|
chapters: Vec<Chapter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct MediaItemRow {
|
pub struct MediaItemRow {
|
||||||
@@ -34,6 +47,13 @@ pub struct MediaItemRow {
|
|||||||
pub episode_number: Option<u32>,
|
pub episode_number: Option<u32>,
|
||||||
pub thumbnail_url: Option<String>,
|
pub thumbnail_url: Option<String>,
|
||||||
pub collection_id: Option<String>,
|
pub collection_id: Option<String>,
|
||||||
|
pub provider_id: String,
|
||||||
|
pub external_id: String,
|
||||||
|
pub collection_name: Option<String>,
|
||||||
|
pub collection_type: Option<String>,
|
||||||
|
pub synced_at: Option<String>,
|
||||||
|
pub role: MediaRole,
|
||||||
|
pub chapters: Vec<Chapter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MediaItem {
|
impl MediaItem {
|
||||||
@@ -57,6 +77,48 @@ impl MediaItem {
|
|||||||
episode_number: None,
|
episode_number: None,
|
||||||
thumbnail_url: None,
|
thumbnail_url: None,
|
||||||
collection_id: 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_library(
|
||||||
|
provider_id: impl Into<String>,
|
||||||
|
external_id: impl Into<String>,
|
||||||
|
title: impl Into<String>,
|
||||||
|
content_type: ContentType,
|
||||||
|
duration_secs: u32,
|
||||||
|
synced_at: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
let provider_id = provider_id.into();
|
||||||
|
let external_id = external_id.into();
|
||||||
|
let id = MediaItemId::new(format!("{}::{}", provider_id, external_id));
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
title: title.into(),
|
||||||
|
content_type,
|
||||||
|
duration_secs,
|
||||||
|
description: None,
|
||||||
|
genres: Vec::new(),
|
||||||
|
year: None,
|
||||||
|
tags: Vec::new(),
|
||||||
|
series_name: None,
|
||||||
|
season_number: None,
|
||||||
|
episode_number: None,
|
||||||
|
thumbnail_url: None,
|
||||||
|
collection_id: None,
|
||||||
|
provider_id,
|
||||||
|
external_id,
|
||||||
|
collection_name: None,
|
||||||
|
collection_type: None,
|
||||||
|
synced_at: Some(synced_at.into()),
|
||||||
|
role: MediaRole::default(),
|
||||||
|
chapters: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +137,13 @@ impl MediaItem {
|
|||||||
episode_number: row.episode_number,
|
episode_number: row.episode_number,
|
||||||
thumbnail_url: row.thumbnail_url,
|
thumbnail_url: row.thumbnail_url,
|
||||||
collection_id: row.collection_id,
|
collection_id: row.collection_id,
|
||||||
|
provider_id: row.provider_id,
|
||||||
|
external_id: row.external_id,
|
||||||
|
collection_name: row.collection_name,
|
||||||
|
collection_type: row.collection_type,
|
||||||
|
synced_at: row.synced_at,
|
||||||
|
role: row.role,
|
||||||
|
chapters: row.chapters,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +198,42 @@ impl MediaItem {
|
|||||||
pub fn collection_id(&self) -> Option<&str> {
|
pub fn collection_id(&self) -> Option<&str> {
|
||||||
self.collection_id.as_deref()
|
self.collection_id.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn provider_id(&self) -> &str {
|
||||||
|
&self.provider_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn external_id(&self) -> &str {
|
||||||
|
&self.external_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn collection_name(&self) -> Option<&str> {
|
||||||
|
self.collection_name.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn collection_type(&self) -> Option<&str> {
|
||||||
|
self.collection_type.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn synced_at(&self) -> Option<&str> {
|
||||||
|
self.synced_at.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn role(&self) -> &MediaRole {
|
||||||
|
&self.role
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn chapters(&self) -> &[Chapter] {
|
||||||
|
&self.chapters
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_chapters(&mut self, chapters: Vec<Chapter>) {
|
||||||
|
self.chapters = chapters;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_role(&mut self, role: MediaRole) {
|
||||||
|
self.role = role;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ pub use channel::{
|
|||||||
pub use collections::{PageParams, Paginated};
|
pub use collections::{PageParams, Paginated};
|
||||||
pub use config_snapshot::ChannelConfigSnapshot;
|
pub use config_snapshot::ChannelConfigSnapshot;
|
||||||
pub use library::{
|
pub use library::{
|
||||||
LibraryCollection, LibraryItem, LibraryItemRow, LibrarySyncLogEntry, LibrarySyncResult,
|
LibraryCollection, LibrarySyncLogEntry, LibrarySyncResult,
|
||||||
SeasonSummary, ShowSummary,
|
SeasonSummary, ShowSummary,
|
||||||
};
|
};
|
||||||
pub use media::{MediaItem, MediaItemRow, PlaybackRecord};
|
pub use media::{MediaItem, MediaItemRow, PlaybackRecord};
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ fn programming_block_getters() {
|
|||||||
assert_eq!(block.start_time(), t(8, 0));
|
assert_eq!(block.start_time(), t(8, 0));
|
||||||
assert_eq!(block.duration_mins(), 120);
|
assert_eq!(block.duration_mins(), 120);
|
||||||
assert!(block.loop_on_finish());
|
assert!(block.loop_on_finish());
|
||||||
assert!(!block.ignore_recycle_policy());
|
assert!(!block.ignore_rotation_policy());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -102,9 +102,8 @@ fn manual_block_creation() {
|
|||||||
let items = vec![MediaItemId::new("item1"), MediaItemId::new("item2")];
|
let items = vec![MediaItemId::new("item1"), MediaItemId::new("item2")];
|
||||||
let block = ProgrammingBlock::new_manual("Manual Block", t(20, 0), 60, items);
|
let block = ProgrammingBlock::new_manual("Manual Block", t(20, 0), 60, items);
|
||||||
match block.content() {
|
match block.content() {
|
||||||
BlockContent::Manual { items, provider_id } => {
|
BlockContent::Manual { items } => {
|
||||||
assert_eq!(items.len(), 2);
|
assert_eq!(items.len(), 2);
|
||||||
assert!(provider_id.is_empty());
|
|
||||||
}
|
}
|
||||||
_ => panic!("Expected Manual content"),
|
_ => panic!("Expected Manual content"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn library_item_new_generates_composite_id() {
|
|
||||||
let item = LibraryItem::new("jellyfin", "abc123", "Test Movie", ContentType::Movie, 7200, "2026-03-19T00:00:00Z");
|
|
||||||
assert_eq!(item.id(), "jellyfin::abc123");
|
|
||||||
assert_eq!(item.provider_id(), "jellyfin");
|
|
||||||
assert_eq!(item.external_id(), "abc123");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn library_item_new_defaults_optional_fields() {
|
|
||||||
let item = LibraryItem::new("jf", "1", "Movie", ContentType::Movie, 3600, "2026-01-01");
|
|
||||||
assert!(item.series_name().is_none());
|
|
||||||
assert!(item.season_number().is_none());
|
|
||||||
assert!(item.genres().is_empty());
|
|
||||||
assert!(item.tags().is_empty());
|
|
||||||
assert!(item.collection_id().is_none());
|
|
||||||
assert!(item.thumbnail_url().is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn library_item_from_persistence_all_fields() {
|
|
||||||
let item = LibraryItem::from_persistence(LibraryItemRow {
|
|
||||||
id: "jf::abc".into(),
|
|
||||||
provider_id: "jf".into(),
|
|
||||||
external_id: "abc".into(),
|
|
||||||
title: "Breaking Bad S01E01".into(),
|
|
||||||
content_type: ContentType::Episode,
|
|
||||||
duration_secs: 2700,
|
|
||||||
series_name: Some("Breaking Bad".into()),
|
|
||||||
season_number: Some(1),
|
|
||||||
episode_number: Some(1),
|
|
||||||
year: Some(2008),
|
|
||||||
genres: vec!["Drama".into()],
|
|
||||||
tags: vec!["tv".into()],
|
|
||||||
collection_id: Some("col-1".into()),
|
|
||||||
collection_name: Some("TV Shows".into()),
|
|
||||||
collection_type: Some("tvshows".into()),
|
|
||||||
thumbnail_url: Some("http://thumb.jpg".into()),
|
|
||||||
synced_at: "2026-03-19T00:00:00Z".into(),
|
|
||||||
});
|
|
||||||
assert_eq!(item.series_name(), Some("Breaking Bad"));
|
|
||||||
assert_eq!(item.season_number(), Some(1));
|
|
||||||
assert_eq!(item.year(), Some(2008));
|
|
||||||
assert_eq!(item.collection_name(), Some("TV Shows"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn library_collection_new_and_getters() {
|
fn library_collection_new_and_getters() {
|
||||||
let col = LibraryCollection::new("col-1", "Movies");
|
let col = LibraryCollection::new("col-1", "Movies");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::value_objects::PlaybackRecordId;
|
use crate::value_objects::{Chapter, PlaybackRecordId};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn media_item_new_defaults() {
|
fn media_item_new_defaults() {
|
||||||
@@ -15,6 +15,33 @@ fn media_item_new_defaults() {
|
|||||||
assert!(item.genres().is_empty());
|
assert!(item.genres().is_empty());
|
||||||
assert!(item.year().is_none());
|
assert!(item.year().is_none());
|
||||||
assert!(item.series_name().is_none());
|
assert!(item.series_name().is_none());
|
||||||
|
assert_eq!(item.provider_id(), "");
|
||||||
|
assert_eq!(item.external_id(), "");
|
||||||
|
assert!(item.synced_at().is_none());
|
||||||
|
assert_eq!(item.role(), &MediaRole::Program);
|
||||||
|
assert!(item.chapters().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn media_item_new_library_generates_composite_id() {
|
||||||
|
let item = MediaItem::new_library("jellyfin", "abc123", "Test Movie", ContentType::Movie, 7200, "2026-03-19T00:00:00Z");
|
||||||
|
assert_eq!(item.id().value(), "jellyfin::abc123");
|
||||||
|
assert_eq!(item.provider_id(), "jellyfin");
|
||||||
|
assert_eq!(item.external_id(), "abc123");
|
||||||
|
assert_eq!(item.synced_at(), Some("2026-03-19T00:00:00Z"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn media_item_new_library_defaults_optional_fields() {
|
||||||
|
let item = MediaItem::new_library("jf", "1", "Movie", ContentType::Movie, 3600, "2026-01-01");
|
||||||
|
assert!(item.series_name().is_none());
|
||||||
|
assert!(item.season_number().is_none());
|
||||||
|
assert!(item.genres().is_empty());
|
||||||
|
assert!(item.tags().is_empty());
|
||||||
|
assert!(item.collection_id().is_none());
|
||||||
|
assert!(item.thumbnail_url().is_none());
|
||||||
|
assert!(item.collection_name().is_none());
|
||||||
|
assert!(item.collection_type().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -33,6 +60,13 @@ fn media_item_from_persistence_round_trip() {
|
|||||||
episode_number: Some(1),
|
episode_number: Some(1),
|
||||||
thumbnail_url: Some("http://thumb.jpg".into()),
|
thumbnail_url: Some("http://thumb.jpg".into()),
|
||||||
collection_id: Some("col-1".into()),
|
collection_id: Some("col-1".into()),
|
||||||
|
provider_id: "jf".into(),
|
||||||
|
external_id: "abc".into(),
|
||||||
|
collection_name: Some("TV Shows".into()),
|
||||||
|
collection_type: Some("tvshows".into()),
|
||||||
|
synced_at: Some("2026-03-19T00:00:00Z".into()),
|
||||||
|
role: MediaRole::Program,
|
||||||
|
chapters: vec![Chapter::new(Some("Intro".into()), 0.0, 30.0)],
|
||||||
});
|
});
|
||||||
assert_eq!(item.title(), "Breaking Bad S01E01");
|
assert_eq!(item.title(), "Breaking Bad S01E01");
|
||||||
assert_eq!(item.series_name(), Some("Breaking Bad"));
|
assert_eq!(item.series_name(), Some("Breaking Bad"));
|
||||||
@@ -40,6 +74,11 @@ fn media_item_from_persistence_round_trip() {
|
|||||||
assert_eq!(item.episode_number(), Some(1));
|
assert_eq!(item.episode_number(), Some(1));
|
||||||
assert_eq!(item.year(), Some(2008));
|
assert_eq!(item.year(), Some(2008));
|
||||||
assert_eq!(item.collection_id(), Some("col-1"));
|
assert_eq!(item.collection_id(), Some("col-1"));
|
||||||
|
assert_eq!(item.provider_id(), "jf");
|
||||||
|
assert_eq!(item.external_id(), "abc");
|
||||||
|
assert_eq!(item.collection_name(), Some("TV Shows"));
|
||||||
|
assert_eq!(item.collection_type(), Some("tvshows"));
|
||||||
|
assert_eq!(item.synced_at(), Some("2026-03-19T00:00:00Z"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ use super::*;
|
|||||||
#[test]
|
#[test]
|
||||||
fn new_generates_id_and_timestamp() {
|
fn new_generates_id_and_timestamp() {
|
||||||
let email = Email::new("test@example.com").unwrap();
|
let email = Email::new("test@example.com").unwrap();
|
||||||
let user = User::new("oidc|123", email);
|
let user = User::new("external|123", email);
|
||||||
assert!(!user.is_admin());
|
assert!(!user.is_admin());
|
||||||
assert!(user.password_hash().is_none());
|
assert!(user.password_hash().is_none());
|
||||||
assert_eq!(user.subject(), "oidc|123");
|
assert_eq!(user.subject(), "external|123");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
use crate::errors::DomainResult;
|
use crate::errors::DomainResult;
|
||||||
pub use crate::events::DomainEvent;
|
pub use crate::events::{DomainEvent, EventEnvelope};
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait EventPublisher: Send + Sync {
|
pub trait EventPublisher: Send + Sync {
|
||||||
@@ -10,7 +10,9 @@ pub trait EventPublisher: Send + Sync {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait EventConsumer: Send + Sync {
|
pub trait EventConsumer: Send + Sync {
|
||||||
async fn recv(&self) -> DomainResult<DomainEvent>;
|
async fn poll_next(&self) -> DomainResult<Option<EventEnvelope>>;
|
||||||
|
async fn ack(&self, event_id: i64) -> DomainResult<()>;
|
||||||
|
async fn nack(&self, event_id: i64, error: &str) -> DomainResult<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -2,22 +2,24 @@ use async_trait::async_trait;
|
|||||||
|
|
||||||
use crate::errors::DomainResult;
|
use crate::errors::DomainResult;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
LibraryCollection, LibraryItem, LibrarySyncLogEntry, LibrarySyncResult,
|
LibraryCollection, LibrarySyncLogEntry, LibrarySyncResult, MediaItem,
|
||||||
SeasonSummary, ShowSummary,
|
SeasonSummary, ShowSummary,
|
||||||
};
|
};
|
||||||
use crate::value_objects::{ContentType, LibrarySearchFilter};
|
use crate::value_objects::{ContentType, LibrarySearchFilter, MediaRole};
|
||||||
|
|
||||||
use super::media::IMediaProvider;
|
use super::media::IMediaProvider;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait LibraryCommand: Send + Sync {
|
pub trait LibraryCommand: Send + Sync {
|
||||||
async fn upsert_items(&self, provider_id: &str, items: Vec<LibraryItem>) -> DomainResult<()>;
|
async fn upsert_items(&self, provider_id: &str, items: Vec<MediaItem>) -> DomainResult<()>;
|
||||||
|
|
||||||
async fn clear_provider(&self, provider_id: &str) -> DomainResult<()>;
|
async fn clear_provider(&self, provider_id: &str) -> DomainResult<()>;
|
||||||
|
|
||||||
async fn log_sync_start(&self, provider_id: &str) -> DomainResult<i64>;
|
async fn log_sync_start(&self, provider_id: &str) -> DomainResult<i64>;
|
||||||
|
|
||||||
async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()>;
|
async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()>;
|
||||||
|
|
||||||
|
async fn update_role(&self, item_id: &str, role: MediaRole) -> DomainResult<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -25,9 +27,9 @@ pub trait LibraryQuery: Send + Sync {
|
|||||||
async fn search(
|
async fn search(
|
||||||
&self,
|
&self,
|
||||||
filter: &LibrarySearchFilter,
|
filter: &LibrarySearchFilter,
|
||||||
) -> DomainResult<(Vec<LibraryItem>, u32)>;
|
) -> DomainResult<(Vec<MediaItem>, u32)>;
|
||||||
|
|
||||||
async fn get_by_id(&self, id: &str) -> DomainResult<Option<LibraryItem>>;
|
async fn get_by_id(&self, id: &str) -> DomainResult<Option<MediaItem>>;
|
||||||
|
|
||||||
async fn list_collections(
|
async fn list_collections(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user