Compare commits
40 Commits
e3a65d8052
...
33b440d297
| Author | SHA1 | Date | |
|---|---|---|---|
| 33b440d297 | |||
| 9b18d3ff6d | |||
| c0e685a4ee | |||
| 031cba5cfb | |||
| f6b2481758 | |||
| ff5f299a84 | |||
| 25b33b6a0e | |||
| eff14228af | |||
| 9dcd169689 | |||
| 98a54245b1 | |||
| d650e2ba07 | |||
| a46ced4aca | |||
| f2f56b7143 | |||
| 8051ab58c0 | |||
| dd4389d53e | |||
| c869e9ab84 | |||
| 56d742a74c | |||
| afed5c01b4 | |||
| b93b14efe4 | |||
| e389e9e002 | |||
| 72ef9b9e1b | |||
| 0fe80b545e | |||
| e8179d1f53 | |||
| 1428f264bb | |||
| 4cdde568f6 | |||
| 1410d3e6bb | |||
| 466d34b5d0 | |||
| ebf0614fdf | |||
| ef86a967cd | |||
| 6fd47f2d93 | |||
| 2976600d12 | |||
| 848d4752e2 | |||
| 7ddf94c75f | |||
| 87e7d85239 | |||
| 6d12ac4c5e | |||
| 0166e829c1 | |||
| 616c60e213 | |||
| 528b155327 | |||
| b2e71403d8 | |||
| 3ff146615f |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,5 @@
|
|||||||
transcode/
|
transcode/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
|
/target
|
||||||
3481
Cargo.lock
generated
Normal file
3481
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
Cargo.toml
Normal file
39
Cargo.toml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
[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"]
|
||||||
|
exclude = ["k-tv-backend", "k-tv-frontend"]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
async-trait = "0.1"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
chrono-tz = { version = "0.10", features = ["serde"] }
|
||||||
|
email_address = "0.2"
|
||||||
|
rand = "0.8"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
thiserror = "2"
|
||||||
|
url = { version = "2.5", features = ["serde"] }
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio", "macros", "chrono", "uuid"] }
|
||||||
|
axum = { version = "0.8" }
|
||||||
|
axum-extra = { version = "0.10" }
|
||||||
|
tower = "0.5"
|
||||||
|
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
utoipa = { version = "5", features = ["chrono", "uuid"] }
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
|
||||||
|
# Internal crates
|
||||||
|
domain = { path = "crates/domain" }
|
||||||
|
application = { path = "crates/application" }
|
||||||
|
api-types = { path = "crates/api-types" }
|
||||||
|
infra-wiring = { path = "crates/infra-wiring" }
|
||||||
|
adapter-common = { path = "crates/adapters/adapter-common" }
|
||||||
|
adapter-sqlite = { path = "crates/adapters/sqlite" }
|
||||||
|
adapter-auth = { path = "crates/adapters/auth" }
|
||||||
|
adapter-jellyfin = { path = "crates/adapters/jellyfin" }
|
||||||
|
adapter-local-files = { path = "crates/adapters/local-files" }
|
||||||
|
adapter-event-publisher = { path = "crates/adapters/event-publisher" }
|
||||||
44
Makefile
Normal file
44
Makefile
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
.DEFAULT_GOAL := check
|
||||||
|
|
||||||
|
# Run the full local check suite — same order as CI would.
|
||||||
|
check: fmt-check clippy test
|
||||||
|
@echo "✅ All checks passed"
|
||||||
|
|
||||||
|
# Apply rustfmt to all files.
|
||||||
|
fmt:
|
||||||
|
cargo fmt
|
||||||
|
|
||||||
|
# Check formatting without modifying files (CI-safe).
|
||||||
|
fmt-check:
|
||||||
|
cargo fmt --check
|
||||||
|
|
||||||
|
# Run Clippy and treat warnings as errors.
|
||||||
|
clippy:
|
||||||
|
cargo clippy -- -D warnings
|
||||||
|
|
||||||
|
# Run the test suite.
|
||||||
|
test:
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Apply fmt + clippy auto-fixes in one shot.
|
||||||
|
fix:
|
||||||
|
cargo fmt
|
||||||
|
cargo clippy --fix --allow-dirty --allow-staged
|
||||||
|
|
||||||
|
# Build the frontend SPA.
|
||||||
|
build-app:
|
||||||
|
cd app && npm run build
|
||||||
|
|
||||||
|
# Run the backend (builds frontend first if needed).
|
||||||
|
dev: build-app
|
||||||
|
JWT_SECRET=dev-secret ALLOW_REGISTRATION=true cargo run -p presentation
|
||||||
|
|
||||||
|
# Run backend only (skip frontend build, assumes build-app was run).
|
||||||
|
dev-api:
|
||||||
|
JWT_SECRET=dev-secret ALLOW_REGISTRATION=true cargo run -p presentation
|
||||||
|
|
||||||
|
# Build and push Docker image to private registry.
|
||||||
|
deploy:
|
||||||
|
./deploy.sh
|
||||||
|
|
||||||
|
.PHONY: check fmt fmt-check clippy test fix build-app dev dev-api deploy
|
||||||
13
crates/adapters/adapter-common/Cargo.toml
Normal file
13
crates/adapters/adapter-common/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "adapter-common"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
sqlx = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
203
crates/adapters/adapter-common/src/lib.rs
Normal file
203
crates/adapters/adapter-common/src/lib.rs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use domain::{DomainError, RecyclePolicy, ScheduleConfig, ScheduleConfigCompat};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub fn map_sqlx_error(err: sqlx::Error) -> DomainError {
|
||||||
|
tracing::error!(error = %err, "database error");
|
||||||
|
DomainError::RepositoryError(err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_dt(s: &str) -> Result<DateTime<Utc>, DomainError> {
|
||||||
|
DateTime::parse_from_rfc3339(s)
|
||||||
|
.map(|dt| dt.with_timezone(&Utc))
|
||||||
|
.or_else(|_| {
|
||||||
|
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").map(|dt| dt.and_utc())
|
||||||
|
})
|
||||||
|
.map_err(|e| DomainError::RepositoryError(format!("Invalid datetime '{}': {}", s, e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_uuid(s: &str, context: &str) -> Result<Uuid, DomainError> {
|
||||||
|
Uuid::parse_str(s)
|
||||||
|
.map_err(|e| DomainError::RepositoryError(format!("Invalid {} UUID '{}': {}", context, s, e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_json<T: DeserializeOwned>(json: &str, context: &str) -> Result<T, DomainError> {
|
||||||
|
serde_json::from_str(json)
|
||||||
|
.map_err(|e| DomainError::RepositoryError(format!("Invalid {} JSON: {}", context, e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_schedule_config(json: &str) -> Result<ScheduleConfig, DomainError> {
|
||||||
|
let compat: ScheduleConfigCompat = parse_json(json, "schedule_config")?;
|
||||||
|
Ok(ScheduleConfig::from(compat))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_recycle_policy(json: &str) -> Result<RecyclePolicy, DomainError> {
|
||||||
|
parse_json(json, "recycle_policy")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_enum_or_default<T: DeserializeOwned + Default>(value: String) -> T {
|
||||||
|
serde_json::from_value(serde_json::Value::String(value)).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn serialize_enum_as_string<T: serde::Serialize>(v: &T, fallback: &str) -> String {
|
||||||
|
serde_json::to_value(v)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(str::to_owned))
|
||||||
|
.unwrap_or_else(|| fallback.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn content_type_str(ct: &domain::ContentType) -> &'static str {
|
||||||
|
match ct {
|
||||||
|
domain::ContentType::Movie => "movie",
|
||||||
|
domain::ContentType::Episode => "episode",
|
||||||
|
domain::ContentType::Short => "short",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_content_type(s: &str) -> domain::ContentType {
|
||||||
|
match s {
|
||||||
|
"episode" => domain::ContentType::Episode,
|
||||||
|
"short" => domain::ContentType::Short,
|
||||||
|
_ => domain::ContentType::Movie,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_genres_blob(blob: &str) -> Vec<String> {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
blob.split("],[")
|
||||||
|
.flat_map(|chunk| {
|
||||||
|
let cleaned = chunk.trim_start_matches('[').trim_end_matches(']');
|
||||||
|
cleaned
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|s| {
|
||||||
|
let s = s.trim().trim_matches('"');
|
||||||
|
if s.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(s.to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.collect::<HashSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::Datelike;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_dt_rfc3339() {
|
||||||
|
let dt = parse_dt("2026-03-19T12:30:00Z").unwrap();
|
||||||
|
assert_eq!(dt.year(), 2026);
|
||||||
|
assert_eq!(dt.month(), 3);
|
||||||
|
assert_eq!(dt.day(), 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_dt_sqlite_format() {
|
||||||
|
let dt = parse_dt("2026-03-19 12:30:00").unwrap();
|
||||||
|
assert_eq!(dt.year(), 2026);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_dt_invalid() {
|
||||||
|
assert!(parse_dt("not-a-date").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_uuid_valid() {
|
||||||
|
let u = Uuid::new_v4();
|
||||||
|
let parsed = parse_uuid(&u.to_string(), "test").unwrap();
|
||||||
|
assert_eq!(parsed, u);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_uuid_invalid() {
|
||||||
|
let err = parse_uuid("not-a-uuid", "channel id").unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(msg.contains("channel id"), "error should contain context: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_json_valid() {
|
||||||
|
let val: Vec<i32> = parse_json("[1,2,3]", "test").unwrap();
|
||||||
|
assert_eq!(val, vec![1, 2, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_json_invalid() {
|
||||||
|
let err = parse_json::<Vec<i32>>("not json", "test_field").unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(msg.contains("test_field"), "error should contain context: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_schedule_config_v2() {
|
||||||
|
let json = r#"{"day_blocks":{}}"#;
|
||||||
|
let cfg = parse_schedule_config(json).unwrap();
|
||||||
|
assert!(cfg.day_blocks().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_schedule_config_v1_compat() {
|
||||||
|
let json = r#"{"blocks":[]}"#;
|
||||||
|
let cfg = parse_schedule_config(json).unwrap();
|
||||||
|
assert_eq!(cfg.day_blocks().len(), 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_recycle_policy_valid() {
|
||||||
|
let json = r#"{"cooldown_days":7,"cooldown_generations":3,"min_available_ratio":0.3}"#;
|
||||||
|
let policy = parse_recycle_policy(json).unwrap();
|
||||||
|
assert_eq!(policy.cooldown_days, Some(7));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_enum_or_default_valid() {
|
||||||
|
use domain::AccessMode;
|
||||||
|
let mode: AccessMode = parse_enum_or_default("public".to_string());
|
||||||
|
assert!(matches!(mode, AccessMode::Public));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_enum_or_default_fallback() {
|
||||||
|
use domain::AccessMode;
|
||||||
|
let mode: AccessMode = parse_enum_or_default("garbage".to_string());
|
||||||
|
assert!(matches!(mode, AccessMode::Public));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_sqlx_error_produces_repository_error() {
|
||||||
|
let sqlx_err = sqlx::Error::RowNotFound;
|
||||||
|
let domain_err = map_sqlx_error(sqlx_err);
|
||||||
|
assert!(matches!(domain_err, DomainError::RepositoryError(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialize_enum_as_string_valid() {
|
||||||
|
use domain::AccessMode;
|
||||||
|
let result = serialize_enum_as_string(&AccessMode::Public, "fallback");
|
||||||
|
assert_eq!(result, "public");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_type_roundtrip() {
|
||||||
|
use domain::ContentType;
|
||||||
|
assert_eq!(parse_content_type(content_type_str(&ContentType::Movie)), ContentType::Movie);
|
||||||
|
assert_eq!(parse_content_type(content_type_str(&ContentType::Episode)), ContentType::Episode);
|
||||||
|
assert_eq!(parse_content_type(content_type_str(&ContentType::Short)), ContentType::Short);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_genres_blob_basic() {
|
||||||
|
let genres = parse_genres_blob(r#"["Action","Comedy"],["Drama","Action"]"#);
|
||||||
|
assert!(genres.contains(&"Action".to_string()));
|
||||||
|
assert!(genres.contains(&"Comedy".to_string()));
|
||||||
|
assert!(genres.contains(&"Drama".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
22
crates/adapters/auth/Cargo.toml
Normal file
22
crates/adapters/auth/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "adapter-auth"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["jwt"]
|
||||||
|
jwt = ["dep:jsonwebtoken"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|
||||||
|
# JWT deps
|
||||||
|
jsonwebtoken = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
# Password hashing
|
||||||
|
password-auth = "1"
|
||||||
341
crates/adapters/auth/src/jwt.rs
Normal file
341
crates/adapters/auth/src/jwt.rs
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
use domain::User;
|
||||||
|
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
const MIN_SECRET_LENGTH: usize = 32;
|
||||||
|
const SECS_PER_HOUR: usize = 3600;
|
||||||
|
const SECS_PER_DAY: usize = 86400;
|
||||||
|
const TOKEN_TYPE_ACCESS: &str = "access";
|
||||||
|
const TOKEN_TYPE_REFRESH: &str = "refresh";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct JwtConfig {
|
||||||
|
pub secret: String,
|
||||||
|
pub issuer: Option<String>,
|
||||||
|
pub audience: Option<String>,
|
||||||
|
pub expiry_hours: u64,
|
||||||
|
pub refresh_expiry_days: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwtConfig {
|
||||||
|
pub fn new(
|
||||||
|
secret: String,
|
||||||
|
issuer: Option<String>,
|
||||||
|
audience: Option<String>,
|
||||||
|
expiry_hours: Option<u64>,
|
||||||
|
refresh_expiry_days: Option<u64>,
|
||||||
|
is_production: bool,
|
||||||
|
) -> Result<Self, JwtError> {
|
||||||
|
if is_production && secret.len() < MIN_SECRET_LENGTH {
|
||||||
|
return Err(JwtError::WeakSecret {
|
||||||
|
min_length: MIN_SECRET_LENGTH,
|
||||||
|
actual_length: secret.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
secret,
|
||||||
|
issuer,
|
||||||
|
audience,
|
||||||
|
expiry_hours: expiry_hours.unwrap_or(24),
|
||||||
|
refresh_expiry_days: refresh_expiry_days.unwrap_or(30),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_unchecked(secret: String) -> Self {
|
||||||
|
Self {
|
||||||
|
secret,
|
||||||
|
issuer: None,
|
||||||
|
audience: None,
|
||||||
|
expiry_hours: 24,
|
||||||
|
refresh_expiry_days: 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_token_type() -> String {
|
||||||
|
TOKEN_TYPE_ACCESS.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct JwtClaims {
|
||||||
|
pub sub: String,
|
||||||
|
pub email: String,
|
||||||
|
pub exp: usize,
|
||||||
|
pub iat: usize,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub iss: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub aud: Option<String>,
|
||||||
|
#[serde(default = "default_token_type")]
|
||||||
|
pub token_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum JwtError {
|
||||||
|
#[error("JWT secret too weak: minimum {min_length} bytes, got {actual_length}")]
|
||||||
|
WeakSecret {
|
||||||
|
min_length: usize,
|
||||||
|
actual_length: usize,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("Token creation failed: {0}")]
|
||||||
|
CreationFailed(#[from] jsonwebtoken::errors::Error),
|
||||||
|
|
||||||
|
#[error("Token validation failed: {0}")]
|
||||||
|
ValidationFailed(String),
|
||||||
|
|
||||||
|
#[error("Token expired")]
|
||||||
|
Expired,
|
||||||
|
|
||||||
|
#[error("Invalid token format")]
|
||||||
|
InvalidFormat,
|
||||||
|
|
||||||
|
#[error("Missing configuration")]
|
||||||
|
MissingConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct JwtValidator {
|
||||||
|
config: JwtConfig,
|
||||||
|
encoding_key: EncodingKey,
|
||||||
|
decoding_key: DecodingKey,
|
||||||
|
validation: Validation,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwtValidator {
|
||||||
|
pub fn new(config: JwtConfig) -> Self {
|
||||||
|
let encoding_key = EncodingKey::from_secret(config.secret.as_bytes());
|
||||||
|
let decoding_key = DecodingKey::from_secret(config.secret.as_bytes());
|
||||||
|
|
||||||
|
let mut validation = Validation::new(Algorithm::HS256);
|
||||||
|
|
||||||
|
if let Some(ref issuer) = config.issuer {
|
||||||
|
validation.set_issuer(&[issuer]);
|
||||||
|
}
|
||||||
|
if let Some(ref audience) = config.audience {
|
||||||
|
validation.set_audience(&[audience]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
encoding_key,
|
||||||
|
decoding_key,
|
||||||
|
validation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_token(&self, user: &User) -> Result<String, JwtError> {
|
||||||
|
let now = now_secs();
|
||||||
|
let expiry = now + (self.config.expiry_hours as usize * SECS_PER_HOUR);
|
||||||
|
|
||||||
|
let claims = JwtClaims {
|
||||||
|
sub: user.id().to_string(),
|
||||||
|
email: user.email().as_ref().to_string(),
|
||||||
|
exp: expiry,
|
||||||
|
iat: now,
|
||||||
|
iss: self.config.issuer.clone(),
|
||||||
|
aud: self.config.audience.clone(),
|
||||||
|
token_type: TOKEN_TYPE_ACCESS.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
encode(&Header::new(Algorithm::HS256), &claims, &self.encoding_key)
|
||||||
|
.map_err(JwtError::CreationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_refresh_token(&self, user: &User) -> Result<String, JwtError> {
|
||||||
|
let now = now_secs();
|
||||||
|
let expiry = now + (self.config.refresh_expiry_days as usize * SECS_PER_DAY);
|
||||||
|
|
||||||
|
let claims = JwtClaims {
|
||||||
|
sub: user.id().to_string(),
|
||||||
|
email: user.email().as_ref().to_string(),
|
||||||
|
exp: expiry,
|
||||||
|
iat: now,
|
||||||
|
iss: self.config.issuer.clone(),
|
||||||
|
aud: self.config.audience.clone(),
|
||||||
|
token_type: TOKEN_TYPE_REFRESH.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
encode(&Header::new(Algorithm::HS256), &claims, &self.encoding_key)
|
||||||
|
.map_err(JwtError::CreationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_token(&self, token: &str) -> Result<JwtClaims, JwtError> {
|
||||||
|
let token_data =
|
||||||
|
decode::<JwtClaims>(token, &self.decoding_key, &self.validation).map_err(|e| {
|
||||||
|
match e.kind() {
|
||||||
|
jsonwebtoken::errors::ErrorKind::ExpiredSignature => JwtError::Expired,
|
||||||
|
jsonwebtoken::errors::ErrorKind::InvalidToken => JwtError::InvalidFormat,
|
||||||
|
_ => JwtError::ValidationFailed(e.to_string()),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(token_data.claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_access_token(&self, token: &str) -> Result<JwtClaims, JwtError> {
|
||||||
|
let claims = self.validate_token(token)?;
|
||||||
|
if claims.token_type != TOKEN_TYPE_ACCESS {
|
||||||
|
return Err(JwtError::ValidationFailed(
|
||||||
|
"Not an access token".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_refresh_token(&self, token: &str) -> Result<JwtClaims, JwtError> {
|
||||||
|
let claims = self.validate_token(token)?;
|
||||||
|
if claims.token_type != TOKEN_TYPE_REFRESH {
|
||||||
|
return Err(JwtError::ValidationFailed(
|
||||||
|
"Not a refresh token".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_unverified(&self, token: &str) -> Result<JwtClaims, JwtError> {
|
||||||
|
let mut insecure = Validation::new(Algorithm::HS256);
|
||||||
|
insecure.insecure_disable_signature_validation();
|
||||||
|
insecure.validate_exp = false;
|
||||||
|
insecure.validate_aud = false;
|
||||||
|
|
||||||
|
let token_data = decode::<JwtClaims>(token, &self.decoding_key, &insecure)
|
||||||
|
.map_err(|_| JwtError::InvalidFormat)?;
|
||||||
|
Ok(token_data.claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expiry_hours(&self) -> u64 {
|
||||||
|
self.config.expiry_hours
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct JwtTokenService {
|
||||||
|
validator: JwtValidator,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwtTokenService {
|
||||||
|
pub fn new(validator: JwtValidator) -> Self {
|
||||||
|
Self { validator }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl domain::ports::TokenService for JwtTokenService {
|
||||||
|
fn create_access_token(&self, user: &domain::User) -> domain::DomainResult<String> {
|
||||||
|
self.validator.create_token(user).map_err(|e| {
|
||||||
|
domain::DomainError::InfrastructureError(format!("Failed to create access token: {e}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_refresh_token(&self, user: &domain::User) -> domain::DomainResult<String> {
|
||||||
|
self.validator.create_refresh_token(user).map_err(|e| {
|
||||||
|
domain::DomainError::InfrastructureError(format!(
|
||||||
|
"Failed to create refresh token: {e}"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_refresh_token(&self, token: &str) -> domain::DomainResult<domain::UserId> {
|
||||||
|
let claims = self.validator.validate_refresh_token(token).map_err(|e| {
|
||||||
|
tracing::debug!("Refresh token validation failed: {:?}", e);
|
||||||
|
domain::DomainError::Unauthenticated("Invalid refresh token".to_string())
|
||||||
|
})?;
|
||||||
|
let user_id: uuid::Uuid = claims.sub.parse().map_err(|_| {
|
||||||
|
domain::DomainError::Unauthenticated("Invalid user ID in token".to_string())
|
||||||
|
})?;
|
||||||
|
Ok(domain::UserId::from(user_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_expiry_secs(&self) -> u64 {
|
||||||
|
self.validator.expiry_hours() * 3600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for JwtValidator {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("JwtValidator")
|
||||||
|
.field("issuer", &self.config.issuer)
|
||||||
|
.field("audience", &self.config.audience)
|
||||||
|
.field("expiry_hours", &self.config.expiry_hours)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_secs() -> usize {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("Time went backwards")
|
||||||
|
.as_secs() as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::Email;
|
||||||
|
|
||||||
|
fn test_user() -> User {
|
||||||
|
let email = Email::new("test@example.com").unwrap();
|
||||||
|
User::new("test-subject", email)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_and_validate_token() {
|
||||||
|
let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string());
|
||||||
|
let validator = JwtValidator::new(config);
|
||||||
|
let user = test_user();
|
||||||
|
|
||||||
|
let token = validator.create_token(&user).expect("create token");
|
||||||
|
let claims = validator.validate_token(&token).expect("validate token");
|
||||||
|
|
||||||
|
assert_eq!(claims.sub, user.id().to_string());
|
||||||
|
assert_eq!(claims.email, "test@example.com");
|
||||||
|
assert_eq!(claims.token_type, "access");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refresh_token_round_trip() {
|
||||||
|
let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string());
|
||||||
|
let validator = JwtValidator::new(config);
|
||||||
|
let user = test_user();
|
||||||
|
|
||||||
|
let token = validator.create_refresh_token(&user).unwrap();
|
||||||
|
let claims = validator.validate_refresh_token(&token).unwrap();
|
||||||
|
assert_eq!(claims.token_type, "refresh");
|
||||||
|
|
||||||
|
assert!(validator.validate_access_token(&token).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weak_secret_rejected_in_production() {
|
||||||
|
let result = JwtConfig::new("short".to_string(), None, None, None, None, true);
|
||||||
|
assert!(matches!(result, Err(JwtError::WeakSecret { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weak_secret_allowed_in_development() {
|
||||||
|
let result = JwtConfig::new("short".to_string(), None, None, None, None, false);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_token_rejected() {
|
||||||
|
let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string());
|
||||||
|
let validator = JwtValidator::new(config);
|
||||||
|
assert!(validator.validate_token("invalid.token.here").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_secret_rejected() {
|
||||||
|
let v1 = JwtValidator::new(JwtConfig::new_unchecked(
|
||||||
|
"secret-one-that-is-long-enough".to_string(),
|
||||||
|
));
|
||||||
|
let v2 = JwtValidator::new(JwtConfig::new_unchecked(
|
||||||
|
"secret-two-that-is-long-enough".to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let user = test_user();
|
||||||
|
let token = v1.create_token(&user).unwrap();
|
||||||
|
assert!(v2.validate_token(&token).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
9
crates/adapters/auth/src/lib.rs
Normal file
9
crates/adapters/auth/src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
pub mod password;
|
||||||
|
|
||||||
|
#[cfg(feature = "jwt")]
|
||||||
|
pub mod jwt;
|
||||||
|
|
||||||
|
pub use password::PasswordAuthService;
|
||||||
|
|
||||||
|
#[cfg(feature = "jwt")]
|
||||||
|
pub use jwt::{JwtClaims, JwtConfig, JwtError, JwtTokenService, JwtValidator};
|
||||||
33
crates/adapters/auth/src/password.rs
Normal file
33
crates/adapters/auth/src/password.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
use domain::errors::DomainResult;
|
||||||
|
use domain::ports::AuthService;
|
||||||
|
|
||||||
|
pub struct PasswordAuthService;
|
||||||
|
|
||||||
|
impl AuthService for PasswordAuthService {
|
||||||
|
fn hash_password(&self, password: &str) -> DomainResult<String> {
|
||||||
|
Ok(password_auth::generate_hash(password))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_password(&self, password: &str, hash: &str) -> DomainResult<bool> {
|
||||||
|
Ok(password_auth::verify_password(password, hash).is_ok())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_and_verify_round_trip() {
|
||||||
|
let svc = PasswordAuthService;
|
||||||
|
let hash = svc.hash_password("supersecret").unwrap();
|
||||||
|
assert!(svc.verify_password("supersecret", &hash).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_password_rejected() {
|
||||||
|
let svc = PasswordAuthService;
|
||||||
|
let hash = svc.hash_password("correct").unwrap();
|
||||||
|
assert!(!svc.verify_password("wrong", &hash).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
10
crates/adapters/event-publisher/Cargo.toml
Normal file
10
crates/adapters/event-publisher/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "adapter-event-publisher"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
42
crates/adapters/event-publisher/src/lib.rs
Normal file
42
crates/adapters/event-publisher/src/lib.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::errors::{DomainError, DomainResult};
|
||||||
|
use domain::events::DomainEvent;
|
||||||
|
use domain::ports::events::{EventConsumer, EventPublisher};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
pub struct ChannelEventBus {
|
||||||
|
tx: broadcast::Sender<DomainEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelEventBus {
|
||||||
|
pub fn new(capacity: usize) -> Self {
|
||||||
|
let (tx, _) = broadcast::channel(capacity);
|
||||||
|
Self { tx }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscriber(&self) -> broadcast::Receiver<DomainEvent> {
|
||||||
|
self.tx.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sender(&self) -> broadcast::Sender<DomainEvent> {
|
||||||
|
self.tx.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl EventPublisher for ChannelEventBus {
|
||||||
|
async fn publish(&self, event: DomainEvent) -> DomainResult<()> {
|
||||||
|
let _ = self.tx.send(event);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl EventConsumer for ChannelEventBus {
|
||||||
|
async fn recv(&self) -> DomainResult<DomainEvent> {
|
||||||
|
let mut rx = self.tx.subscribe();
|
||||||
|
rx.recv()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
12
crates/adapters/jellyfin/Cargo.toml
Normal file
12
crates/adapters/jellyfin/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "adapter-jellyfin"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
6
crates/adapters/jellyfin/src/config.rs
Normal file
6
crates/adapters/jellyfin/src/config.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct JellyfinConfig {
|
||||||
|
pub base_url: String,
|
||||||
|
pub api_key: String,
|
||||||
|
pub user_id: String,
|
||||||
|
}
|
||||||
7
crates/adapters/jellyfin/src/lib.rs
Normal file
7
crates/adapters/jellyfin/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
mod config;
|
||||||
|
mod mapping;
|
||||||
|
mod models;
|
||||||
|
mod provider;
|
||||||
|
|
||||||
|
pub use config::JellyfinConfig;
|
||||||
|
pub use provider::JellyfinMediaProvider;
|
||||||
34
crates/adapters/jellyfin/src/mapping.rs
Normal file
34
crates/adapters/jellyfin/src/mapping.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use domain::{ContentType, MediaItem, MediaItemId, MediaItemRow};
|
||||||
|
|
||||||
|
use crate::models::JellyfinItem;
|
||||||
|
|
||||||
|
pub(crate) const TICKS_PER_SEC: i64 = 10_000_000;
|
||||||
|
|
||||||
|
pub(crate) fn map_jellyfin_item(item: JellyfinItem) -> Option<MediaItem> {
|
||||||
|
let content_type = match item.item_type.as_str() {
|
||||||
|
"Movie" => ContentType::Movie,
|
||||||
|
"Episode" => ContentType::Episode,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let duration_secs = item
|
||||||
|
.run_time_ticks
|
||||||
|
.map(|t| (t / TICKS_PER_SEC) as u32)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
Some(MediaItem::from_persistence(MediaItemRow {
|
||||||
|
id: MediaItemId::new(item.id),
|
||||||
|
title: item.name,
|
||||||
|
content_type,
|
||||||
|
duration_secs,
|
||||||
|
description: item.overview,
|
||||||
|
genres: item.genres.unwrap_or_default(),
|
||||||
|
year: item.production_year,
|
||||||
|
tags: item.tags.unwrap_or_default(),
|
||||||
|
series_name: item.series_name,
|
||||||
|
season_number: item.parent_index_number,
|
||||||
|
episode_number: item.index_number,
|
||||||
|
thumbnail_url: None,
|
||||||
|
collection_id: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
60
crates/adapters/jellyfin/src/models.rs
Normal file
60
crates/adapters/jellyfin/src/models.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
use domain::ContentType;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(crate) struct JellyfinItemsResponse {
|
||||||
|
#[serde(rename = "Items")]
|
||||||
|
pub items: Vec<JellyfinItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(crate) struct JellyfinItem {
|
||||||
|
#[serde(rename = "Id")]
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "Name")]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "Type")]
|
||||||
|
pub item_type: String,
|
||||||
|
#[serde(rename = "RunTimeTicks")]
|
||||||
|
pub run_time_ticks: Option<i64>,
|
||||||
|
#[serde(rename = "Overview")]
|
||||||
|
pub overview: Option<String>,
|
||||||
|
#[serde(rename = "Genres")]
|
||||||
|
pub genres: Option<Vec<String>>,
|
||||||
|
#[serde(rename = "ProductionYear")]
|
||||||
|
pub production_year: Option<u16>,
|
||||||
|
#[serde(rename = "Tags")]
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
#[serde(rename = "SeriesName")]
|
||||||
|
pub series_name: Option<String>,
|
||||||
|
#[serde(rename = "ParentIndexNumber")]
|
||||||
|
pub parent_index_number: Option<u32>,
|
||||||
|
#[serde(rename = "IndexNumber")]
|
||||||
|
pub index_number: Option<u32>,
|
||||||
|
#[serde(rename = "CollectionType")]
|
||||||
|
pub collection_type: Option<String>,
|
||||||
|
#[serde(rename = "RecursiveItemCount")]
|
||||||
|
pub recursive_item_count: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 {
|
||||||
|
match ct {
|
||||||
|
ContentType::Movie => "Movie",
|
||||||
|
ContentType::Episode => "Episode",
|
||||||
|
ContentType::Short => "Movie",
|
||||||
|
}
|
||||||
|
}
|
||||||
401
crates/adapters/jellyfin/src/provider.rs
Normal file
401
crates/adapters/jellyfin/src/provider.rs
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use domain::ports::{
|
||||||
|
Collection, IMediaProvider, ProviderCapabilities, SeriesSummary, StreamQuality,
|
||||||
|
StreamingProtocol,
|
||||||
|
};
|
||||||
|
use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId};
|
||||||
|
|
||||||
|
use crate::config::JellyfinConfig;
|
||||||
|
use crate::mapping::{map_jellyfin_item, TICKS_PER_SEC};
|
||||||
|
use crate::models::{
|
||||||
|
jellyfin_item_type, JellyfinItemsResponse, JellyfinPlaybackInfoResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_HLS_BITRATE: u32 = 8_000_000;
|
||||||
|
|
||||||
|
pub struct JellyfinMediaProvider {
|
||||||
|
client: reqwest::Client,
|
||||||
|
config: JellyfinConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JellyfinMediaProvider {
|
||||||
|
pub fn new(config: JellyfinConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
config: JellyfinConfig {
|
||||||
|
base_url: config.base_url.trim_end_matches('/').to_string(),
|
||||||
|
..config
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_items_for_series(
|
||||||
|
&self,
|
||||||
|
filter: &MediaFilter,
|
||||||
|
series_name: Option<&str>,
|
||||||
|
) -> DomainResult<Vec<MediaItem>> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/Users/{}/Items",
|
||||||
|
self.config.base_url, self.config.user_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut params: Vec<(&str, String)> = vec![
|
||||||
|
("Recursive", "true".into()),
|
||||||
|
(
|
||||||
|
"Fields",
|
||||||
|
"Genres,Tags,RunTimeTicks,ProductionYear,Overview".into(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(ct) = &filter.content_type {
|
||||||
|
params.push(("IncludeItemTypes", jellyfin_item_type(ct).into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filter.genres.is_empty() {
|
||||||
|
params.push(("Genres", filter.genres.join("|")));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(decade) = filter.decade {
|
||||||
|
params.push(("MinYear", decade.to_string()));
|
||||||
|
params.push(("MaxYear", (decade + 9).to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filter.tags.is_empty() {
|
||||||
|
params.push(("Tags", filter.tags.join("|")));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(min) = filter.min_duration_secs {
|
||||||
|
params.push(("MinRunTimeTicks", (min as i64 * TICKS_PER_SEC).to_string()));
|
||||||
|
}
|
||||||
|
if let Some(max) = filter.max_duration_secs {
|
||||||
|
params.push(("MaxRunTimeTicks", (max as i64 * TICKS_PER_SEC).to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(name) = series_name {
|
||||||
|
params.push(("SeriesName", name.to_string()));
|
||||||
|
params.push(("SortBy", "ParentIndexNumber,IndexNumber".into()));
|
||||||
|
params.push(("SortOrder", "Ascending".into()));
|
||||||
|
if filter.content_type.is_none() {
|
||||||
|
params.push(("IncludeItemTypes", "Episode".into()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let Some(parent_id) = filter.collections.first() {
|
||||||
|
params.push(("ParentId", parent_id.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(q) = &filter.search_term {
|
||||||
|
params.push(("SearchTerm", q.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.header("X-Emby-Token", &self.config.api_key)
|
||||||
|
.query(¶ms)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::InfrastructureError(format!("Jellyfin request failed: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(DomainError::InfrastructureError(format!(
|
||||||
|
"Jellyfin returned HTTP {}",
|
||||||
|
response.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: JellyfinItemsResponse = response.json().await.map_err(|e| {
|
||||||
|
DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// WHY: Jellyfin's SeriesName query param is a fuzzy match that can return
|
||||||
|
// items from other shows; post-filter to guarantee correctness.
|
||||||
|
let items = body.items.into_iter().filter_map(map_jellyfin_item);
|
||||||
|
let items: Vec<MediaItem> = if let Some(name) = series_name {
|
||||||
|
items
|
||||||
|
.filter(|item| {
|
||||||
|
item.series_name()
|
||||||
|
.map(|s| s.eq_ignore_ascii_case(name))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
items.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
impl IMediaProvider for JellyfinMediaProvider {
|
||||||
|
fn capabilities(&self) -> ProviderCapabilities {
|
||||||
|
ProviderCapabilities {
|
||||||
|
collections: true,
|
||||||
|
series: true,
|
||||||
|
genres: true,
|
||||||
|
tags: true,
|
||||||
|
decade: true,
|
||||||
|
search: true,
|
||||||
|
streaming_protocol: StreamingProtocol::Hls,
|
||||||
|
rescan: false,
|
||||||
|
transcode: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_items(&self, filter: &MediaFilter) -> DomainResult<Vec<MediaItem>> {
|
||||||
|
match filter.series_names.len() {
|
||||||
|
0 | 1 => {
|
||||||
|
let series = filter.series_names.first().map(String::as_str);
|
||||||
|
self.fetch_items_for_series(filter, series).await
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let mut per_series: Vec<Vec<MediaItem>> = Vec::new();
|
||||||
|
for series_name in &filter.series_names {
|
||||||
|
let items = self
|
||||||
|
.fetch_items_for_series(filter, Some(series_name.as_str()))
|
||||||
|
.await?;
|
||||||
|
if !items.is_empty() {
|
||||||
|
per_series.push(items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let max_len = per_series.iter().map(|s| s.len()).max().unwrap_or(0);
|
||||||
|
let mut all = Vec::with_capacity(per_series.iter().map(|s| s.len()).sum());
|
||||||
|
for i in 0..max_len {
|
||||||
|
for s in &per_series {
|
||||||
|
if let Some(item) = s.get(i) {
|
||||||
|
all.push(item.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult<Option<MediaItem>> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/Users/{}/Items",
|
||||||
|
self.config.base_url, self.config.user_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.header("X-Emby-Token", &self.config.api_key)
|
||||||
|
.query(&[
|
||||||
|
("Ids", item_id.as_ref()),
|
||||||
|
("Fields", "Genres,Tags,RunTimeTicks,ProductionYear"),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::InfrastructureError(format!("Jellyfin request failed: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: JellyfinItemsResponse = response.json().await.map_err(|e| {
|
||||||
|
DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(body.items.into_iter().next().and_then(map_jellyfin_item))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_collections(&self) -> DomainResult<Vec<Collection>> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/Users/{}/Views",
|
||||||
|
self.config.base_url, self.config.user_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.header("X-Emby-Token", &self.config.api_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::InfrastructureError(format!("Jellyfin request failed: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(DomainError::InfrastructureError(format!(
|
||||||
|
"Jellyfin returned HTTP {}",
|
||||||
|
response.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: JellyfinItemsResponse = response.json().await.map_err(|e| {
|
||||||
|
DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(body
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| Collection {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
collection_type: item.collection_type,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_series(&self, collection_id: Option<&str>) -> DomainResult<Vec<SeriesSummary>> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/Users/{}/Items",
|
||||||
|
self.config.base_url, self.config.user_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut params: Vec<(&str, String)> = vec![
|
||||||
|
("Recursive", "true".into()),
|
||||||
|
("IncludeItemTypes", "Series".into()),
|
||||||
|
(
|
||||||
|
"Fields",
|
||||||
|
"Genres,ProductionYear,RecursiveItemCount".into(),
|
||||||
|
),
|
||||||
|
("SortBy", "SortName".into()),
|
||||||
|
("SortOrder", "Ascending".into()),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(id) = collection_id {
|
||||||
|
params.push(("ParentId", id.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.header("X-Emby-Token", &self.config.api_key)
|
||||||
|
.query(¶ms)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::InfrastructureError(format!("Jellyfin request failed: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(DomainError::InfrastructureError(format!(
|
||||||
|
"Jellyfin returned HTTP {}",
|
||||||
|
response.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: JellyfinItemsResponse = response.json().await.map_err(|e| {
|
||||||
|
DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(body
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| SeriesSummary {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
episode_count: item.recursive_item_count.unwrap_or(0),
|
||||||
|
genres: item.genres.unwrap_or_default(),
|
||||||
|
year: item.production_year,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_genres(&self, content_type: Option<&ContentType>) -> DomainResult<Vec<String>> {
|
||||||
|
let url = format!("{}/Genres", self.config.base_url);
|
||||||
|
|
||||||
|
let mut params: Vec<(&str, String)> = vec![
|
||||||
|
("UserId", self.config.user_id.clone()),
|
||||||
|
("SortBy", "SortName".into()),
|
||||||
|
("SortOrder", "Ascending".into()),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(ct) = content_type {
|
||||||
|
params.push(("IncludeItemTypes", jellyfin_item_type(ct).into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.header("X-Emby-Token", &self.config.api_key)
|
||||||
|
.query(¶ms)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::InfrastructureError(format!("Jellyfin request failed: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(DomainError::InfrastructureError(format!(
|
||||||
|
"Jellyfin returned HTTP {}",
|
||||||
|
response.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: JellyfinItemsResponse = response.json().await.map_err(|e| {
|
||||||
|
DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(body.items.into_iter().map(|item| item.name).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_stream_url(
|
||||||
|
&self,
|
||||||
|
item_id: &MediaItemId,
|
||||||
|
quality: &StreamQuality,
|
||||||
|
) -> DomainResult<String> {
|
||||||
|
match quality {
|
||||||
|
StreamQuality::Direct => {
|
||||||
|
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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
crates/adapters/local-files/Cargo.toml
Normal file
18
crates/adapters/local-files/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "adapter-local-files"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
infra-wiring = { workspace = true, features = ["sqlite"] }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
sqlx = { workspace = true, features = ["sqlite"] }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
walkdir = "2"
|
||||||
|
base64 = "0.22"
|
||||||
8
crates/adapters/local-files/src/config.rs
Normal file
8
crates/adapters/local-files/src/config.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub struct LocalFilesConfig {
|
||||||
|
pub root_dir: PathBuf,
|
||||||
|
pub base_url: String,
|
||||||
|
pub transcode_dir: Option<PathBuf>,
|
||||||
|
pub cleanup_ttl_hours: u32,
|
||||||
|
}
|
||||||
186
crates/adapters/local-files/src/index.rs
Normal file
186
crates/adapters/local-files/src/index.rs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use domain::MediaItemId;
|
||||||
|
|
||||||
|
use crate::config::LocalFilesConfig;
|
||||||
|
use crate::scanner::{scan_dir, LocalFileItem};
|
||||||
|
|
||||||
|
pub fn encode_id(rel_path: &str) -> MediaItemId {
|
||||||
|
use base64::Engine as _;
|
||||||
|
MediaItemId::new(
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(rel_path.as_bytes()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_id(id: &MediaItemId) -> Option<String> {
|
||||||
|
use base64::Engine as _;
|
||||||
|
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode(id.as_ref())
|
||||||
|
.ok()?;
|
||||||
|
String::from_utf8(bytes).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LocalIndex {
|
||||||
|
items: Arc<RwLock<HashMap<MediaItemId, LocalFileItem>>>,
|
||||||
|
pub root_dir: PathBuf,
|
||||||
|
provider_id: String,
|
||||||
|
pool: sqlx::SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalIndex {
|
||||||
|
pub async fn new(
|
||||||
|
config: &LocalFilesConfig,
|
||||||
|
pool: sqlx::SqlitePool,
|
||||||
|
provider_id: String,
|
||||||
|
) -> Self {
|
||||||
|
let idx = Self {
|
||||||
|
items: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
root_dir: config.root_dir.clone(),
|
||||||
|
provider_id,
|
||||||
|
pool,
|
||||||
|
};
|
||||||
|
idx.load_from_db().await;
|
||||||
|
idx
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_from_db(&self) {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
id: String,
|
||||||
|
rel_path: String,
|
||||||
|
title: String,
|
||||||
|
duration_secs: i64,
|
||||||
|
year: Option<i64>,
|
||||||
|
tags: String,
|
||||||
|
top_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT id, rel_path, title, duration_secs, year, tags, top_dir \
|
||||||
|
FROM local_files_index WHERE provider_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&self.provider_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match rows {
|
||||||
|
Ok(rows) => {
|
||||||
|
let mut map = self.items.write().await;
|
||||||
|
for row in rows {
|
||||||
|
let tags: Vec<String> =
|
||||||
|
serde_json::from_str(&row.tags).unwrap_or_default();
|
||||||
|
let item = LocalFileItem {
|
||||||
|
rel_path: row.rel_path,
|
||||||
|
title: row.title,
|
||||||
|
duration_secs: row.duration_secs as u32,
|
||||||
|
year: row.year.map(|y| y as u16),
|
||||||
|
tags,
|
||||||
|
top_dir: row.top_dir,
|
||||||
|
};
|
||||||
|
map.insert(MediaItemId::new(row.id), item);
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"Local files index [{}]: loaded {} items from DB",
|
||||||
|
self.provider_id,
|
||||||
|
map.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Table might not exist yet on first run -- that's fine.
|
||||||
|
tracing::debug!("Could not load local files index from DB: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rescan(&self) -> u32 {
|
||||||
|
info!(
|
||||||
|
"Local files [{}]: scanning {:?}",
|
||||||
|
self.provider_id, self.root_dir
|
||||||
|
);
|
||||||
|
let new_items = scan_dir(&self.root_dir).await;
|
||||||
|
let count = new_items.len() as u32;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut map = self.items.write().await;
|
||||||
|
map.clear();
|
||||||
|
for item in &new_items {
|
||||||
|
let id = encode_id(&item.rel_path);
|
||||||
|
map.insert(id, item.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = self.save_to_db(&new_items).await {
|
||||||
|
error!("Failed to persist local files index: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Local files [{}]: indexed {} items",
|
||||||
|
self.provider_id, count
|
||||||
|
);
|
||||||
|
count
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_to_db(&self, items: &[LocalFileItem]) -> Result<(), sqlx::Error> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM local_files_index WHERE provider_id = ?")
|
||||||
|
.bind(&self.provider_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
for item in items {
|
||||||
|
let id = encode_id(&item.rel_path).into_inner();
|
||||||
|
let tags_json =
|
||||||
|
serde_json::to_string(&item.tags).unwrap_or_else(|_| "[]".into());
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO local_files_index \
|
||||||
|
(id, rel_path, title, duration_secs, year, tags, top_dir, scanned_at, provider_id) \
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&item.rel_path)
|
||||||
|
.bind(&item.title)
|
||||||
|
.bind(item.duration_secs as i64)
|
||||||
|
.bind(item.year.map(|y| y as i64))
|
||||||
|
.bind(&tags_json)
|
||||||
|
.bind(&item.top_dir)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&self.provider_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(&self, id: &MediaItemId) -> Option<LocalFileItem> {
|
||||||
|
self.items.read().await.get(id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_all(&self) -> Vec<(MediaItemId, LocalFileItem)> {
|
||||||
|
self.items
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn collections(&self) -> Vec<String> {
|
||||||
|
let map = self.items.read().await;
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for item in map.values() {
|
||||||
|
seen.insert(item.top_dir.clone());
|
||||||
|
}
|
||||||
|
let mut dirs: Vec<String> = seen.into_iter().collect();
|
||||||
|
dirs.sort();
|
||||||
|
dirs
|
||||||
|
}
|
||||||
|
}
|
||||||
41
crates/adapters/local-files/src/lib.rs
Normal file
41
crates/adapters/local-files/src/lib.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod index;
|
||||||
|
pub mod provider;
|
||||||
|
pub mod scanner;
|
||||||
|
pub mod transcoder;
|
||||||
|
|
||||||
|
pub use config::LocalFilesConfig;
|
||||||
|
pub use index::LocalIndex;
|
||||||
|
pub use provider::{LocalFilesProvider, decode_stream_id};
|
||||||
|
pub use transcoder::TranscodeManager;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct LocalFilesBundle {
|
||||||
|
pub provider: LocalFilesProvider,
|
||||||
|
pub local_index: Arc<LocalIndex>,
|
||||||
|
pub transcode_manager: Option<Arc<TranscodeManager>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalFilesBundle {
|
||||||
|
pub async fn build(
|
||||||
|
config: LocalFilesConfig,
|
||||||
|
pool: sqlx::SqlitePool,
|
||||||
|
provider_id: String,
|
||||||
|
) -> Self {
|
||||||
|
let local_index = Arc::new(LocalIndex::new(&config, pool, provider_id).await);
|
||||||
|
|
||||||
|
let transcode_manager = config.transcode_dir.as_ref().map(|dir| {
|
||||||
|
TranscodeManager::new(dir.clone(), config.cleanup_ttl_hours)
|
||||||
|
});
|
||||||
|
|
||||||
|
let provider =
|
||||||
|
LocalFilesProvider::new(Arc::clone(&local_index), &config, transcode_manager.clone());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
provider,
|
||||||
|
local_index,
|
||||||
|
transcode_manager,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
194
crates/adapters/local-files/src/provider.rs
Normal file
194
crates/adapters/local-files/src/provider.rs
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::ports::{
|
||||||
|
Collection, IMediaProvider, ProviderCapabilities, StreamQuality, StreamingProtocol,
|
||||||
|
};
|
||||||
|
use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId, MediaItemRow};
|
||||||
|
|
||||||
|
use crate::config::LocalFilesConfig;
|
||||||
|
use crate::index::{decode_id, LocalIndex};
|
||||||
|
use crate::scanner::LocalFileItem;
|
||||||
|
use crate::transcoder::TranscodeManager;
|
||||||
|
|
||||||
|
pub struct LocalFilesProvider {
|
||||||
|
pub index: Arc<LocalIndex>,
|
||||||
|
base_url: String,
|
||||||
|
transcode_manager: Option<Arc<TranscodeManager>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHORT_DURATION_SECS: u32 = 1200;
|
||||||
|
const DECADE_SPAN: u16 = 9;
|
||||||
|
|
||||||
|
impl LocalFilesProvider {
|
||||||
|
pub fn new(
|
||||||
|
index: Arc<LocalIndex>,
|
||||||
|
config: &LocalFilesConfig,
|
||||||
|
transcode_manager: Option<Arc<TranscodeManager>>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
index,
|
||||||
|
base_url: config.base_url.trim_end_matches('/').to_string(),
|
||||||
|
transcode_manager,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_media_item(id: MediaItemId, item: &LocalFileItem) -> MediaItem {
|
||||||
|
let content_type = if item.duration_secs < SHORT_DURATION_SECS {
|
||||||
|
ContentType::Short
|
||||||
|
} else {
|
||||||
|
ContentType::Movie
|
||||||
|
};
|
||||||
|
MediaItem::from_persistence(MediaItemRow {
|
||||||
|
id,
|
||||||
|
title: item.title.clone(),
|
||||||
|
content_type,
|
||||||
|
duration_secs: item.duration_secs,
|
||||||
|
description: None,
|
||||||
|
genres: vec![],
|
||||||
|
year: item.year,
|
||||||
|
tags: item.tags.clone(),
|
||||||
|
series_name: None,
|
||||||
|
season_number: None,
|
||||||
|
episode_number: None,
|
||||||
|
thumbnail_url: None,
|
||||||
|
collection_id: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl IMediaProvider for LocalFilesProvider {
|
||||||
|
fn capabilities(&self) -> ProviderCapabilities {
|
||||||
|
ProviderCapabilities {
|
||||||
|
collections: true,
|
||||||
|
series: false,
|
||||||
|
genres: false,
|
||||||
|
tags: true,
|
||||||
|
decade: true,
|
||||||
|
search: true,
|
||||||
|
streaming_protocol: if self.transcode_manager.is_some() {
|
||||||
|
StreamingProtocol::Hls
|
||||||
|
} else {
|
||||||
|
StreamingProtocol::DirectFile
|
||||||
|
},
|
||||||
|
rescan: true,
|
||||||
|
transcode: self.transcode_manager.is_some(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_items(&self, filter: &MediaFilter) -> DomainResult<Vec<MediaItem>> {
|
||||||
|
let all = self.index.get_all().await;
|
||||||
|
|
||||||
|
let results = all
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(id, item)| {
|
||||||
|
let content_type = if item.duration_secs < SHORT_DURATION_SECS {
|
||||||
|
ContentType::Short
|
||||||
|
} else {
|
||||||
|
ContentType::Movie
|
||||||
|
};
|
||||||
|
if let Some(ref ct) = filter.content_type
|
||||||
|
&& &content_type != ct
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filter.collections.is_empty()
|
||||||
|
&& !filter.collections.contains(&item.top_dir)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filter.tags.is_empty() {
|
||||||
|
let has = filter
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.any(|tag| item.tags.iter().any(|t| t.eq_ignore_ascii_case(tag)));
|
||||||
|
if !has {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(decade) = filter.decade {
|
||||||
|
match item.year {
|
||||||
|
Some(y) if y >= decade && y <= decade + DECADE_SPAN => {}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(min) = filter.min_duration_secs
|
||||||
|
&& item.duration_secs < min
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Some(max) = filter.max_duration_secs
|
||||||
|
&& item.duration_secs > max
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref q) = filter.search_term
|
||||||
|
&& !item.title.to_lowercase().contains(&q.to_lowercase())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(to_media_item(id, &item))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult<Option<MediaItem>> {
|
||||||
|
Ok(self
|
||||||
|
.index
|
||||||
|
.get(item_id)
|
||||||
|
.await
|
||||||
|
.map(|item| to_media_item(item_id.clone(), &item)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_stream_url(
|
||||||
|
&self,
|
||||||
|
item_id: &MediaItemId,
|
||||||
|
quality: &StreamQuality,
|
||||||
|
) -> DomainResult<String> {
|
||||||
|
match quality {
|
||||||
|
StreamQuality::Transcode(_) if self.transcode_manager.is_some() => {
|
||||||
|
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>> {
|
||||||
|
let dirs = self.index.collections().await;
|
||||||
|
Ok(dirs
|
||||||
|
.into_iter()
|
||||||
|
.map(|d| Collection {
|
||||||
|
id: d.clone(),
|
||||||
|
name: d,
|
||||||
|
collection_type: None,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_stream_id(encoded: &str) -> Option<String> {
|
||||||
|
decode_id(&MediaItemId::new(encoded))
|
||||||
|
}
|
||||||
162
crates/adapters/local-files/src/scanner.rs
Normal file
162
crates/adapters/local-files/src/scanner.rs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mkv", "avi", "mov", "webm", "m4v"];
|
||||||
|
const ROOT_COLLECTION_NAME: &str = "__root__";
|
||||||
|
const YEAR_DIGITS: usize = 4;
|
||||||
|
const MIN_YEAR: u16 = 1900;
|
||||||
|
const MAX_YEAR: u16 = 2099;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LocalFileItem {
|
||||||
|
pub rel_path: String,
|
||||||
|
pub title: String,
|
||||||
|
pub duration_secs: u32,
|
||||||
|
pub year: Option<u16>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub top_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn scan_dir(root: &Path) -> Vec<LocalFileItem> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
|
||||||
|
let walker = walkdir::WalkDir::new(root).follow_links(true);
|
||||||
|
for entry in walker.into_iter().filter_map(|e| e.ok()) {
|
||||||
|
if !entry.file_type().is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
let ext = path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|e| e.to_lowercase());
|
||||||
|
match ext {
|
||||||
|
Some(ref e) if VIDEO_EXTENSIONS.contains(&e.as_str()) => {}
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rel = match path.strip_prefix(root) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rel_path: String = rel
|
||||||
|
.components()
|
||||||
|
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/");
|
||||||
|
|
||||||
|
let top_dir = rel
|
||||||
|
.components()
|
||||||
|
.next()
|
||||||
|
.filter(|_| rel.components().count() > 1)
|
||||||
|
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| ROOT_COLLECTION_NAME.to_string());
|
||||||
|
|
||||||
|
let stem = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let title = stem.replace(['_', '-', '.'], " ");
|
||||||
|
let title = title.trim().to_string();
|
||||||
|
|
||||||
|
let search_str = format!(
|
||||||
|
"{} {}",
|
||||||
|
stem,
|
||||||
|
rel.parent()
|
||||||
|
.and_then(|p| p.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
);
|
||||||
|
let year = extract_year(&search_str);
|
||||||
|
|
||||||
|
let tags: Vec<String> = rel
|
||||||
|
.parent()
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|p| p.components())
|
||||||
|
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let duration_secs = get_duration(path).await.unwrap_or(0);
|
||||||
|
|
||||||
|
items.push(LocalFileItem {
|
||||||
|
rel_path,
|
||||||
|
title,
|
||||||
|
duration_secs,
|
||||||
|
year,
|
||||||
|
tags,
|
||||||
|
top_dir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_year(s: &str) -> Option<u16> {
|
||||||
|
let chars: Vec<char> = s.chars().collect();
|
||||||
|
let n = chars.len();
|
||||||
|
if n < YEAR_DIGITS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for i in 0..=(n - YEAR_DIGITS) {
|
||||||
|
if !chars[i..i + YEAR_DIGITS].iter().all(|c| c.is_ascii_digit()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let s4: String = chars[i..i + YEAR_DIGITS].iter().collect();
|
||||||
|
let num: u16 = s4.parse().ok()?;
|
||||||
|
if !(MIN_YEAR..=MAX_YEAR).contains(&num) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let before_ok = i == 0 || !chars[i - 1].is_ascii_digit();
|
||||||
|
let after_ok = i + YEAR_DIGITS >= n || !chars[i + YEAR_DIGITS].is_ascii_digit();
|
||||||
|
if before_ok && after_ok {
|
||||||
|
return Some(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_duration(path: &Path) -> Option<u32> {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Fmt {
|
||||||
|
duration: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Out {
|
||||||
|
format: Fmt,
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = Command::new("ffprobe")
|
||||||
|
.args([
|
||||||
|
"-v",
|
||||||
|
"quiet",
|
||||||
|
"-print_format",
|
||||||
|
"json",
|
||||||
|
"-show_format",
|
||||||
|
path.to_str()?,
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let parsed: Out = serde_json::from_slice(&output.stdout).ok()?;
|
||||||
|
let dur: f64 = parsed.format.duration?.parse().ok()?;
|
||||||
|
Some(dur as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_year_basic() {
|
||||||
|
assert_eq!(extract_year("Movie 2024 HD"), Some(2024));
|
||||||
|
assert_eq!(extract_year("1999_classic"), Some(1999));
|
||||||
|
assert_eq!(extract_year("no year here"), None);
|
||||||
|
assert_eq!(extract_year("12345"), None);
|
||||||
|
assert_eq!(extract_year("2100"), None);
|
||||||
|
assert_eq!(extract_year("1900"), Some(1900));
|
||||||
|
assert_eq!(extract_year("2099"), Some(2099));
|
||||||
|
}
|
||||||
|
}
|
||||||
241
crates/adapters/local-files/src/transcoder.rs
Normal file
241
crates/adapters/local-files/src/transcoder.rs
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicU32, Ordering},
|
||||||
|
};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use tokio::sync::{Mutex, watch};
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
use domain::{DomainError, DomainResult};
|
||||||
|
|
||||||
|
const SECS_PER_HOUR: u64 = 3600;
|
||||||
|
const CLEANUP_INTERVAL: Duration = Duration::from_secs(SECS_PER_HOUR);
|
||||||
|
const TRANSCODE_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
|
const TRANSCODE_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||||
|
const FFMPEG_CRF: &str = "23";
|
||||||
|
const FFMPEG_AUDIO_BITRATE: &str = "128k";
|
||||||
|
const HLS_SEGMENT_SECS: &str = "6";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum TranscodeStatus {
|
||||||
|
Ready,
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TranscodeManager {
|
||||||
|
pub transcode_dir: PathBuf,
|
||||||
|
cleanup_ttl_hours: Arc<AtomicU32>,
|
||||||
|
active: Arc<Mutex<HashMap<String, watch::Sender<Option<TranscodeStatus>>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TranscodeManager {
|
||||||
|
pub fn new(transcode_dir: PathBuf, cleanup_ttl_hours: u32) -> Arc<Self> {
|
||||||
|
let mgr = Arc::new(Self {
|
||||||
|
transcode_dir,
|
||||||
|
cleanup_ttl_hours: Arc::new(AtomicU32::new(cleanup_ttl_hours)),
|
||||||
|
active: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
});
|
||||||
|
// uses Weak to avoid keeping manager alive
|
||||||
|
let weak = Arc::downgrade(&mgr);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(CLEANUP_INTERVAL);
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
match weak.upgrade() {
|
||||||
|
Some(m) => m.run_cleanup().await,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mgr
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_cleanup_ttl(&self, hours: u32) {
|
||||||
|
self.cleanup_ttl_hours.store(hours, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_cleanup_ttl(&self) -> u32 {
|
||||||
|
self.cleanup_ttl_hours.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_transcoded(&self, item_id: &str, src_path: &Path) -> DomainResult<()> {
|
||||||
|
let out_dir = self.transcode_dir.join(item_id);
|
||||||
|
let playlist = out_dir.join("playlist.m3u8");
|
||||||
|
|
||||||
|
if playlist.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rx = {
|
||||||
|
let mut map = self.active.lock().await;
|
||||||
|
if let Some(tx) = map.get(item_id) {
|
||||||
|
tx.subscribe()
|
||||||
|
} else {
|
||||||
|
let (tx, rx) = watch::channel::<Option<TranscodeStatus>>(None);
|
||||||
|
map.insert(item_id.to_string(), tx.clone());
|
||||||
|
|
||||||
|
let item_id_owned = item_id.to_string();
|
||||||
|
let src_owned = src_path.to_path_buf();
|
||||||
|
let out_dir_owned = out_dir.clone();
|
||||||
|
let playlist_owned = playlist.clone();
|
||||||
|
let active_ref = Arc::clone(&self.active);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = tokio::fs::create_dir_all(&out_dir_owned).await;
|
||||||
|
let status =
|
||||||
|
do_transcode(&src_owned, &out_dir_owned, &playlist_owned).await;
|
||||||
|
if matches!(status, TranscodeStatus::Ready) {
|
||||||
|
info!("transcode ready: {}", item_id_owned);
|
||||||
|
} else if let TranscodeStatus::Failed(ref e) = status {
|
||||||
|
error!("transcode failed for {}: {}", item_id_owned, e);
|
||||||
|
}
|
||||||
|
let _ = tx.send(Some(status));
|
||||||
|
active_ref.lock().await.remove(&item_id_owned);
|
||||||
|
});
|
||||||
|
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
rx.changed().await.map_err(|_| {
|
||||||
|
DomainError::InfrastructureError(
|
||||||
|
"transcode task dropped unexpectedly".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if let Some(status) = &*rx.borrow() {
|
||||||
|
return match status {
|
||||||
|
TranscodeStatus::Ready => Ok(()),
|
||||||
|
TranscodeStatus::Failed(e) => Err(DomainError::InfrastructureError(
|
||||||
|
format!("transcode failed: {}", e),
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn clear_cache(&self) -> std::io::Result<()> {
|
||||||
|
if self.transcode_dir.exists() {
|
||||||
|
tokio::fs::remove_dir_all(&self.transcode_dir).await?;
|
||||||
|
}
|
||||||
|
tokio::fs::create_dir_all(&self.transcode_dir).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cache_stats(&self) -> (u64, usize) {
|
||||||
|
let mut total_bytes = 0u64;
|
||||||
|
let mut item_count = 0usize;
|
||||||
|
let Ok(mut entries) = tokio::fs::read_dir(&self.transcode_dir).await else {
|
||||||
|
return (0, 0);
|
||||||
|
};
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
if !entry.path().is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
item_count += 1;
|
||||||
|
if let Ok(mut sub) = tokio::fs::read_dir(entry.path()).await {
|
||||||
|
while let Ok(Some(f)) = sub.next_entry().await {
|
||||||
|
if let Ok(meta) = f.metadata().await {
|
||||||
|
total_bytes += meta.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(total_bytes, item_count)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_cleanup(&self) {
|
||||||
|
let ttl_hours = self.cleanup_ttl_hours.load(Ordering::Relaxed) as u64;
|
||||||
|
let ttl = Duration::from_secs(ttl_hours * SECS_PER_HOUR);
|
||||||
|
let now = std::time::SystemTime::now();
|
||||||
|
|
||||||
|
let Ok(mut entries) = tokio::fs::read_dir(&self.transcode_dir).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let playlist = path.join("playlist.m3u8");
|
||||||
|
if let Ok(meta) = tokio::fs::metadata(&playlist).await
|
||||||
|
&& let Ok(modified) = meta.modified()
|
||||||
|
&& let Ok(age) = now.duration_since(modified)
|
||||||
|
&& age > ttl
|
||||||
|
{
|
||||||
|
warn!("cleanup: removing stale transcode {:?}", path);
|
||||||
|
let _ = tokio::fs::remove_dir_all(&path).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_transcode(src: &Path, out_dir: &Path, playlist: &Path) -> TranscodeStatus {
|
||||||
|
let segment_pattern = out_dir.join("seg%05d.ts");
|
||||||
|
|
||||||
|
let mut child = match tokio::process::Command::new("ffmpeg")
|
||||||
|
.args([
|
||||||
|
"-i",
|
||||||
|
src.to_str().unwrap_or(""),
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
"fast",
|
||||||
|
"-crf",
|
||||||
|
FFMPEG_CRF,
|
||||||
|
"-c:a",
|
||||||
|
"aac",
|
||||||
|
"-b:a",
|
||||||
|
FFMPEG_AUDIO_BITRATE,
|
||||||
|
"-hls_time",
|
||||||
|
HLS_SEGMENT_SECS,
|
||||||
|
"-hls_list_size",
|
||||||
|
"0",
|
||||||
|
"-hls_flags",
|
||||||
|
"independent_segments",
|
||||||
|
"-hls_segment_filename",
|
||||||
|
segment_pattern.to_str().unwrap_or(""),
|
||||||
|
playlist.to_str().unwrap_or(""),
|
||||||
|
])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => return TranscodeStatus::Failed(format!("ffmpeg spawn error: {}", e)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let timeout = TRANSCODE_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
if playlist.exists() {
|
||||||
|
return TranscodeStatus::Ready;
|
||||||
|
}
|
||||||
|
if start.elapsed() > timeout {
|
||||||
|
let _ = child.kill().await;
|
||||||
|
return TranscodeStatus::Failed(
|
||||||
|
"timeout waiting for transcode to start".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(status)) => {
|
||||||
|
return if playlist.exists() {
|
||||||
|
TranscodeStatus::Ready
|
||||||
|
} else if status.success() {
|
||||||
|
TranscodeStatus::Failed(
|
||||||
|
"ffmpeg exited but produced no playlist".into(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
TranscodeStatus::Failed(
|
||||||
|
"ffmpeg exited with non-zero status".into(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Err(e) => return TranscodeStatus::Failed(e.to_string()),
|
||||||
|
Ok(None) => {}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(TRANSCODE_POLL_INTERVAL).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
crates/adapters/sqlite/Cargo.toml
Normal file
16
crates/adapters/sqlite/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "adapter-sqlite"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
adapter-common = { workspace = true }
|
||||||
|
infra-wiring = { workspace = true, features = ["sqlite"] }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
sqlx = { workspace = true, features = ["sqlite"] }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
83
crates/adapters/sqlite/src/activity.rs
Normal file
83
crates/adapters/sqlite/src/activity.rs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::Utc;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use adapter_common::{map_sqlx_error, parse_dt, parse_uuid};
|
||||||
|
use domain::{
|
||||||
|
ports::activity::{ActivityLogCommand, ActivityLogQuery},
|
||||||
|
ActivityEvent, ActivityEventId, ChannelId, DomainResult,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct SqliteActivityLog {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteActivityLog {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ActivityLogCommand for SqliteActivityLog {
|
||||||
|
async fn log(
|
||||||
|
&self,
|
||||||
|
event_type: &str,
|
||||||
|
detail: &str,
|
||||||
|
channel_id: Option<ChannelId>,
|
||||||
|
) -> DomainResult<()> {
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
let timestamp = Utc::now().to_rfc3339();
|
||||||
|
let channel_id_str = channel_id.map(|id| id.value().to_string());
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO activity_log (id, timestamp, event_type, detail, channel_id) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(×tamp)
|
||||||
|
.bind(event_type)
|
||||||
|
.bind(detail)
|
||||||
|
.bind(&channel_id_str)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ActivityLogQuery for SqliteActivityLog {
|
||||||
|
async fn recent(&self, limit: u32) -> DomainResult<Vec<ActivityEvent>> {
|
||||||
|
let rows: Vec<(String, String, String, String, Option<String>)> = sqlx::query_as(
|
||||||
|
"SELECT id, timestamp, event_type, detail, channel_id FROM activity_log ORDER BY timestamp DESC LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
let mut events = Vec::with_capacity(rows.len());
|
||||||
|
for (id_str, ts_str, event_type, detail, channel_id_str) in rows {
|
||||||
|
let Ok(id) = parse_uuid(&id_str, "activity id") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(timestamp) = parse_dt(&ts_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let channel_id = channel_id_str
|
||||||
|
.and_then(|s| Uuid::parse_str(&s).ok())
|
||||||
|
.map(ChannelId::from_uuid);
|
||||||
|
events.push(ActivityEvent::from_persistence(
|
||||||
|
ActivityEventId::from_uuid(id),
|
||||||
|
timestamp,
|
||||||
|
event_type,
|
||||||
|
detail,
|
||||||
|
channel_id,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
}
|
||||||
336
crates/adapters/sqlite/src/channel.rs
Normal file
336
crates/adapters/sqlite/src/channel.rs
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use adapter_common::{
|
||||||
|
map_sqlx_error, parse_dt, parse_enum_or_default, parse_recycle_policy, parse_schedule_config,
|
||||||
|
parse_uuid, serialize_enum_as_string,
|
||||||
|
};
|
||||||
|
use domain::{
|
||||||
|
ports::channel::{ChannelCommand, ChannelQuery},
|
||||||
|
AccessMode, Channel, ChannelConfigSnapshot, ChannelId, ChannelRow as DomainChannelRow,
|
||||||
|
DomainError, DomainResult, LogoPosition, ScheduleConfig, SnapshotId, UserId,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct SqliteChannelRepository {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteChannelRepository {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELECT_COLS: &str = "id, owner_id, name, description, timezone, schedule_config, 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";
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct ChannelRow {
|
||||||
|
id: String,
|
||||||
|
owner_id: String,
|
||||||
|
name: String,
|
||||||
|
description: Option<String>,
|
||||||
|
timezone: String,
|
||||||
|
schedule_config: String,
|
||||||
|
recycle_policy: String,
|
||||||
|
auto_schedule: i64,
|
||||||
|
access_mode: String,
|
||||||
|
access_password_hash: Option<String>,
|
||||||
|
logo: Option<String>,
|
||||||
|
logo_position: String,
|
||||||
|
logo_opacity: f32,
|
||||||
|
webhook_url: Option<String>,
|
||||||
|
webhook_poll_interval_secs: i64,
|
||||||
|
webhook_body_template: Option<String>,
|
||||||
|
webhook_headers: Option<String>,
|
||||||
|
created_at: String,
|
||||||
|
updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelRow {
|
||||||
|
fn into_channel(self) -> DomainResult<Channel> {
|
||||||
|
let id = ChannelId::from_uuid(parse_uuid(&self.id, "channel id")?);
|
||||||
|
let owner_id = UserId::from_uuid(parse_uuid(&self.owner_id, "owner id")?);
|
||||||
|
let schedule_config = parse_schedule_config(&self.schedule_config)?;
|
||||||
|
let recycle_policy = parse_recycle_policy(&self.recycle_policy)?;
|
||||||
|
let access_mode: AccessMode = parse_enum_or_default(self.access_mode);
|
||||||
|
let logo_position: LogoPosition = parse_enum_or_default(self.logo_position);
|
||||||
|
|
||||||
|
Ok(Channel::from_persistence(DomainChannelRow {
|
||||||
|
id,
|
||||||
|
owner_id,
|
||||||
|
name: self.name,
|
||||||
|
description: self.description,
|
||||||
|
timezone: self.timezone,
|
||||||
|
schedule_config,
|
||||||
|
recycle_policy,
|
||||||
|
auto_schedule: self.auto_schedule != 0,
|
||||||
|
access_mode,
|
||||||
|
access_password_hash: self.access_password_hash,
|
||||||
|
logo: self.logo,
|
||||||
|
logo_position,
|
||||||
|
logo_opacity: self.logo_opacity,
|
||||||
|
webhook_url: self.webhook_url,
|
||||||
|
webhook_poll_interval_secs: self.webhook_poll_interval_secs as u32,
|
||||||
|
webhook_body_template: self.webhook_body_template,
|
||||||
|
webhook_headers: self.webhook_headers,
|
||||||
|
created_at: parse_dt(&self.created_at)?,
|
||||||
|
updated_at: parse_dt(&self.updated_at)?,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_snapshot_row(
|
||||||
|
row: &sqlx::sqlite::SqliteRow,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
) -> DomainResult<ChannelConfigSnapshot> {
|
||||||
|
let id_str: String = row.get("id");
|
||||||
|
let id = SnapshotId::from_uuid(parse_uuid(&id_str, "snapshot id")?);
|
||||||
|
let config_json: String = row.get("config_json");
|
||||||
|
let config = parse_schedule_config(&config_json)?;
|
||||||
|
let version_num: i64 = row.get("version_num");
|
||||||
|
let label: Option<String> = row.get("label");
|
||||||
|
let created_at_str: String = row.get("created_at");
|
||||||
|
let created_at: DateTime<Utc> = parse_dt(&created_at_str)?;
|
||||||
|
|
||||||
|
Ok(ChannelConfigSnapshot::from_persistence(
|
||||||
|
id,
|
||||||
|
channel_id,
|
||||||
|
config,
|
||||||
|
version_num,
|
||||||
|
label,
|
||||||
|
created_at,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ChannelCommand for SqliteChannelRepository {
|
||||||
|
async fn save(&self, channel: &Channel) -> DomainResult<()> {
|
||||||
|
let schedule_config = serde_json::to_string(channel.schedule_config())
|
||||||
|
.map_err(|e| DomainError::RepositoryError(format!("serialize schedule_config: {e}")))?;
|
||||||
|
let recycle_policy = serde_json::to_string(channel.recycle_policy())
|
||||||
|
.map_err(|e| DomainError::RepositoryError(format!("serialize recycle_policy: {e}")))?;
|
||||||
|
let access_mode = serialize_enum_as_string(channel.access_mode(), "public");
|
||||||
|
let logo_position = serialize_enum_as_string(channel.logo_position(), "top_right");
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO channels
|
||||||
|
(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)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
name = excluded.name,
|
||||||
|
description = excluded.description,
|
||||||
|
timezone = excluded.timezone,
|
||||||
|
schedule_config = excluded.schedule_config,
|
||||||
|
recycle_policy = excluded.recycle_policy,
|
||||||
|
auto_schedule = excluded.auto_schedule,
|
||||||
|
access_mode = excluded.access_mode,
|
||||||
|
access_password_hash = excluded.access_password_hash,
|
||||||
|
logo = excluded.logo,
|
||||||
|
logo_position = excluded.logo_position,
|
||||||
|
logo_opacity = excluded.logo_opacity,
|
||||||
|
webhook_url = excluded.webhook_url,
|
||||||
|
webhook_poll_interval_secs = excluded.webhook_poll_interval_secs,
|
||||||
|
webhook_body_template = excluded.webhook_body_template,
|
||||||
|
webhook_headers = excluded.webhook_headers,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(channel.id().value().to_string())
|
||||||
|
.bind(channel.owner_id().value().to_string())
|
||||||
|
.bind(channel.name())
|
||||||
|
.bind(channel.description())
|
||||||
|
.bind(channel.timezone())
|
||||||
|
.bind(&schedule_config)
|
||||||
|
.bind(&recycle_policy)
|
||||||
|
.bind(channel.auto_schedule() as i64)
|
||||||
|
.bind(&access_mode)
|
||||||
|
.bind(channel.access_password_hash())
|
||||||
|
.bind(channel.logo())
|
||||||
|
.bind(&logo_position)
|
||||||
|
.bind(channel.logo_opacity())
|
||||||
|
.bind(channel.webhook_url())
|
||||||
|
.bind(channel.webhook_poll_interval_secs() as i64)
|
||||||
|
.bind(channel.webhook_body_template())
|
||||||
|
.bind(channel.webhook_headers())
|
||||||
|
.bind(channel.created_at().to_rfc3339())
|
||||||
|
.bind(channel.updated_at().to_rfc3339())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: ChannelId) -> DomainResult<()> {
|
||||||
|
sqlx::query("DELETE FROM channels WHERE id = ?")
|
||||||
|
.bind(id.value().to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_config_snapshot(
|
||||||
|
&self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
config: &ScheduleConfig,
|
||||||
|
label: Option<String>,
|
||||||
|
) -> DomainResult<ChannelConfigSnapshot> {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let now = Utc::now();
|
||||||
|
let config_json = serde_json::to_string(config)
|
||||||
|
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
let version_num: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COALESCE(MAX(version_num), 0) + 1 FROM channel_config_snapshots WHERE channel_id = ?",
|
||||||
|
)
|
||||||
|
.bind(channel_id.value().to_string())
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO channel_config_snapshots (id, channel_id, config_json, version_num, label, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(id.to_string())
|
||||||
|
.bind(channel_id.value().to_string())
|
||||||
|
.bind(&config_json)
|
||||||
|
.bind(version_num)
|
||||||
|
.bind(&label)
|
||||||
|
.bind(now.to_rfc3339())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
tx.commit().await.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
Ok(ChannelConfigSnapshot::from_persistence(
|
||||||
|
SnapshotId::from_uuid(id),
|
||||||
|
channel_id,
|
||||||
|
config.clone(),
|
||||||
|
version_num,
|
||||||
|
label,
|
||||||
|
now,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_config_snapshot_label(
|
||||||
|
&self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
snapshot_id: SnapshotId,
|
||||||
|
label: Option<String>,
|
||||||
|
) -> DomainResult<Option<ChannelConfigSnapshot>> {
|
||||||
|
let updated = sqlx::query(
|
||||||
|
"UPDATE channel_config_snapshots SET label = ? WHERE id = ? AND channel_id = ? RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(&label)
|
||||||
|
.bind(snapshot_id.value().to_string())
|
||||||
|
.bind(channel_id.value().to_string())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
if updated.is_none() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
self.get_config_snapshot(channel_id, snapshot_id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ChannelQuery for SqliteChannelRepository {
|
||||||
|
async fn find_by_id(&self, id: ChannelId) -> DomainResult<Option<Channel>> {
|
||||||
|
let sql = format!("SELECT {SELECT_COLS} FROM channels WHERE id = ?");
|
||||||
|
let row: Option<ChannelRow> = sqlx::query_as(&sql)
|
||||||
|
.bind(id.value().to_string())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
row.map(ChannelRow::into_channel).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_owner(&self, owner_id: UserId) -> DomainResult<Vec<Channel>> {
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT {SELECT_COLS} FROM channels WHERE owner_id = ? ORDER BY created_at ASC"
|
||||||
|
);
|
||||||
|
let rows: Vec<ChannelRow> = sqlx::query_as(&sql)
|
||||||
|
.bind(owner_id.value().to_string())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
rows.into_iter().map(ChannelRow::into_channel).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_all(&self) -> DomainResult<Vec<Channel>> {
|
||||||
|
let sql = format!("SELECT {SELECT_COLS} FROM channels ORDER BY created_at ASC");
|
||||||
|
let rows: Vec<ChannelRow> = sqlx::query_as(&sql)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
rows.into_iter().map(ChannelRow::into_channel).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_auto_schedule_enabled(&self) -> DomainResult<Vec<Channel>> {
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT {SELECT_COLS} FROM channels WHERE auto_schedule = 1 ORDER BY created_at ASC"
|
||||||
|
);
|
||||||
|
let rows: Vec<ChannelRow> = sqlx::query_as(&sql)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
rows.into_iter().map(ChannelRow::into_channel).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_config_snapshots(
|
||||||
|
&self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
) -> DomainResult<Vec<ChannelConfigSnapshot>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT id, config_json, version_num, label, created_at
|
||||||
|
FROM channel_config_snapshots WHERE channel_id = ?
|
||||||
|
ORDER BY version_num DESC",
|
||||||
|
)
|
||||||
|
.bind(channel_id.value().to_string())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
rows.iter()
|
||||||
|
.map(|row| map_snapshot_row(row, channel_id))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_config_snapshot(
|
||||||
|
&self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
snapshot_id: SnapshotId,
|
||||||
|
) -> DomainResult<Option<ChannelConfigSnapshot>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT id, config_json, version_num, label, created_at
|
||||||
|
FROM channel_config_snapshots WHERE id = ? AND channel_id = ?",
|
||||||
|
)
|
||||||
|
.bind(snapshot_id.value().to_string())
|
||||||
|
.bind(channel_id.value().to_string())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(row) => Ok(Some(map_snapshot_row(&row, channel_id)?)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
crates/adapters/sqlite/src/lib.rs
Normal file
11
crates/adapters/sqlite/src/lib.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
pub mod activity;
|
||||||
|
pub mod channel;
|
||||||
|
pub mod library;
|
||||||
|
pub mod provider_config;
|
||||||
|
pub mod schedule;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod transcode;
|
||||||
|
pub mod user;
|
||||||
|
pub mod wire;
|
||||||
|
|
||||||
|
pub use wire::{wire, SqliteWireOutput};
|
||||||
505
crates/adapters/sqlite/src/library.rs
Normal file
505
crates/adapters/sqlite/src/library.rs
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use adapter_common::{content_type_str, parse_content_type, parse_genres_blob};
|
||||||
|
use domain::{
|
||||||
|
ports::library::{LibraryCommand, LibraryQuery},
|
||||||
|
ContentType, DomainError, DomainResult, LibraryCollection, LibraryItem,
|
||||||
|
LibraryItemRow as DomainLibraryItemRow, LibrarySearchFilter, LibrarySyncLogEntry,
|
||||||
|
LibrarySyncResult, SeasonSummary, ShowSummary,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct SqliteLibraryRepository {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteLibraryRepository {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct LibraryItemRow {
|
||||||
|
id: String,
|
||||||
|
provider_id: String,
|
||||||
|
external_id: String,
|
||||||
|
title: String,
|
||||||
|
content_type: String,
|
||||||
|
duration_secs: i64,
|
||||||
|
series_name: Option<String>,
|
||||||
|
season_number: Option<i64>,
|
||||||
|
episode_number: Option<i64>,
|
||||||
|
year: Option<i64>,
|
||||||
|
genres: String,
|
||||||
|
tags: String,
|
||||||
|
collection_id: Option<String>,
|
||||||
|
collection_name: Option<String>,
|
||||||
|
collection_type: Option<String>,
|
||||||
|
thumbnail_url: Option<String>,
|
||||||
|
synced_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LibraryItemRow {
|
||||||
|
fn into_library_item(self) -> LibraryItem {
|
||||||
|
LibraryItem::from_persistence(DomainLibraryItemRow {
|
||||||
|
id: self.id,
|
||||||
|
provider_id: self.provider_id,
|
||||||
|
external_id: self.external_id,
|
||||||
|
title: self.title,
|
||||||
|
content_type: parse_content_type(&self.content_type),
|
||||||
|
duration_secs: self.duration_secs as u32,
|
||||||
|
series_name: self.series_name,
|
||||||
|
season_number: self.season_number.map(|n| n as u32),
|
||||||
|
episode_number: self.episode_number.map(|n| n as u32),
|
||||||
|
year: self.year.map(|n| n as u16),
|
||||||
|
genres: serde_json::from_str(&self.genres).unwrap_or_default(),
|
||||||
|
tags: serde_json::from_str(&self.tags).unwrap_or_default(),
|
||||||
|
collection_id: self.collection_id,
|
||||||
|
collection_name: self.collection_name,
|
||||||
|
collection_type: self.collection_type,
|
||||||
|
thumbnail_url: self.thumbnail_url,
|
||||||
|
synced_at: self.synced_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct SyncLogRow {
|
||||||
|
id: i64,
|
||||||
|
provider_id: String,
|
||||||
|
started_at: String,
|
||||||
|
finished_at: Option<String>,
|
||||||
|
items_found: i64,
|
||||||
|
status: String,
|
||||||
|
error_msg: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct ShowSummaryRow {
|
||||||
|
series_name: String,
|
||||||
|
episode_count: i64,
|
||||||
|
season_count: i64,
|
||||||
|
thumbnail_url: Option<String>,
|
||||||
|
genres_blob: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct SeasonSummaryRow {
|
||||||
|
season_number: i64,
|
||||||
|
episode_count: i64,
|
||||||
|
thumbnail_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LibraryCommand for SqliteLibraryRepository {
|
||||||
|
async fn upsert_items(&self, _provider_id: &str, items: Vec<LibraryItem>) -> DomainResult<()> {
|
||||||
|
let mut tx = self
|
||||||
|
.pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
for item in items {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR REPLACE INTO library_items
|
||||||
|
(id, provider_id, external_id, title, content_type, duration_secs,
|
||||||
|
series_name, season_number, episode_number, year, genres, tags,
|
||||||
|
collection_id, collection_name, collection_type, thumbnail_url, synced_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
)
|
||||||
|
.bind(item.id())
|
||||||
|
.bind(item.provider_id())
|
||||||
|
.bind(item.external_id())
|
||||||
|
.bind(item.title())
|
||||||
|
.bind(content_type_str(item.content_type()))
|
||||||
|
.bind(item.duration_secs() as i64)
|
||||||
|
.bind(item.series_name())
|
||||||
|
.bind(item.season_number().map(|n| n as i64))
|
||||||
|
.bind(item.episode_number().map(|n| n as i64))
|
||||||
|
.bind(item.year().map(|n| n as i64))
|
||||||
|
.bind(serde_json::to_string(item.genres()).unwrap_or_default())
|
||||||
|
.bind(serde_json::to_string(item.tags()).unwrap_or_default())
|
||||||
|
.bind(item.collection_id())
|
||||||
|
.bind(item.collection_name())
|
||||||
|
.bind(item.collection_type())
|
||||||
|
.bind(item.thumbnail_url())
|
||||||
|
.bind(item.synced_at())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear_provider(&self, provider_id: &str) -> DomainResult<()> {
|
||||||
|
sqlx::query("DELETE FROM library_items WHERE provider_id = ?")
|
||||||
|
.bind(provider_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn log_sync_start(&self, provider_id: &str) -> DomainResult<i64> {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let id = sqlx::query_scalar::<_, i64>(
|
||||||
|
"INSERT INTO library_sync_log (provider_id, started_at, status)
|
||||||
|
VALUES (?, ?, 'running') RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(provider_id)
|
||||||
|
.bind(&now)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()> {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let status = if result.error().is_none() {
|
||||||
|
"done"
|
||||||
|
} else {
|
||||||
|
"error"
|
||||||
|
};
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE library_sync_log
|
||||||
|
SET finished_at = ?, items_found = ?, status = ?, error_msg = ?
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(result.items_found() as i64)
|
||||||
|
.bind(status)
|
||||||
|
.bind(result.error())
|
||||||
|
.bind(log_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LibraryQuery for SqliteLibraryRepository {
|
||||||
|
async fn search(
|
||||||
|
&self,
|
||||||
|
filter: &LibrarySearchFilter,
|
||||||
|
) -> DomainResult<(Vec<LibraryItem>, u32)> {
|
||||||
|
let mut conditions: Vec<String> = vec![];
|
||||||
|
|
||||||
|
if let Some(p) = filter.provider_id() {
|
||||||
|
conditions.push(format!("provider_id = '{}'", p.replace('\'', "''")));
|
||||||
|
}
|
||||||
|
if let Some(ct) = filter.content_type() {
|
||||||
|
conditions.push(format!("content_type = '{}'", content_type_str(ct)));
|
||||||
|
}
|
||||||
|
if let Some(st) = filter.search_term() {
|
||||||
|
conditions.push(format!("title LIKE '%{}%'", st.replace('\'', "''")));
|
||||||
|
}
|
||||||
|
if let Some(cid) = filter.collection_id() {
|
||||||
|
conditions.push(format!("collection_id = '{}'", cid.replace('\'', "''")));
|
||||||
|
}
|
||||||
|
if let Some(decade) = filter.decade() {
|
||||||
|
let end = decade + 10;
|
||||||
|
conditions.push(format!("year >= {} AND year < {}", decade, end));
|
||||||
|
}
|
||||||
|
if let Some(min) = filter.min_duration_secs() {
|
||||||
|
conditions.push(format!("duration_secs >= {}", min));
|
||||||
|
}
|
||||||
|
if let Some(max) = filter.max_duration_secs() {
|
||||||
|
conditions.push(format!("duration_secs <= {}", max));
|
||||||
|
}
|
||||||
|
if !filter.series_names().is_empty() {
|
||||||
|
let quoted: Vec<String> = filter
|
||||||
|
.series_names()
|
||||||
|
.iter()
|
||||||
|
.map(|s| format!("'{}'", s.replace('\'', "''")))
|
||||||
|
.collect();
|
||||||
|
conditions.push(format!("series_name IN ({})", quoted.join(",")));
|
||||||
|
}
|
||||||
|
if !filter.genres().is_empty() {
|
||||||
|
let genre_conditions: Vec<String> = filter
|
||||||
|
.genres()
|
||||||
|
.iter()
|
||||||
|
.map(|g| {
|
||||||
|
format!(
|
||||||
|
"EXISTS (SELECT 1 FROM json_each(library_items.genres) WHERE value = '{}')",
|
||||||
|
g.replace('\'', "''")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
conditions.push(format!("({})", genre_conditions.join(" OR ")));
|
||||||
|
}
|
||||||
|
if let Some(sn) = filter.season_number() {
|
||||||
|
conditions.push(format!("season_number = {}", sn));
|
||||||
|
}
|
||||||
|
|
||||||
|
let where_clause = if conditions.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("WHERE {}", conditions.join(" AND "))
|
||||||
|
};
|
||||||
|
|
||||||
|
let count_sql = format!("SELECT COUNT(*) FROM library_items {}", where_clause);
|
||||||
|
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
let items_sql = format!(
|
||||||
|
"SELECT * FROM library_items {} ORDER BY title ASC LIMIT {} OFFSET {}",
|
||||||
|
where_clause,
|
||||||
|
filter.limit(),
|
||||||
|
filter.offset()
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, LibraryItemRow>(&items_sql)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
rows.into_iter()
|
||||||
|
.map(LibraryItemRow::into_library_item)
|
||||||
|
.collect(),
|
||||||
|
total as u32,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_id(&self, id: &str) -> DomainResult<Option<LibraryItem>> {
|
||||||
|
let row = sqlx::query_as::<_, LibraryItemRow>("SELECT * FROM library_items WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(row.map(LibraryItemRow::into_library_item))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_collections(
|
||||||
|
&self,
|
||||||
|
provider_id: Option<&str>,
|
||||||
|
) -> DomainResult<Vec<LibraryCollection>> {
|
||||||
|
let rows: Vec<(String, Option<String>, Option<String>)> = if let Some(p) = provider_id {
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT DISTINCT collection_id, collection_name, collection_type
|
||||||
|
FROM library_items WHERE collection_id IS NOT NULL AND provider_id = ?
|
||||||
|
ORDER BY collection_name ASC",
|
||||||
|
)
|
||||||
|
.bind(p)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT DISTINCT collection_id, collection_name, collection_type
|
||||||
|
FROM library_items WHERE collection_id IS NOT NULL
|
||||||
|
ORDER BY collection_name ASC",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, name, ct)| {
|
||||||
|
LibraryCollection::from_persistence(id, name.unwrap_or_default(), ct)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_series(&self, provider_id: Option<&str>) -> DomainResult<Vec<String>> {
|
||||||
|
let rows: Vec<(String,)> = if let Some(p) = provider_id {
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT DISTINCT series_name FROM library_items
|
||||||
|
WHERE series_name IS NOT NULL AND provider_id = ? ORDER BY series_name ASC",
|
||||||
|
)
|
||||||
|
.bind(p)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT DISTINCT series_name FROM library_items
|
||||||
|
WHERE series_name IS NOT NULL ORDER BY series_name ASC",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(rows.into_iter().map(|(s,)| s).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_genres(
|
||||||
|
&self,
|
||||||
|
content_type: Option<&ContentType>,
|
||||||
|
provider_id: Option<&str>,
|
||||||
|
) -> DomainResult<Vec<String>> {
|
||||||
|
let sql = match (content_type, provider_id) {
|
||||||
|
(Some(ct), Some(p)) => format!(
|
||||||
|
"SELECT DISTINCT je.value FROM library_items li, json_each(li.genres) je
|
||||||
|
WHERE li.content_type = '{}' AND li.provider_id = '{}' ORDER BY je.value ASC",
|
||||||
|
content_type_str(ct),
|
||||||
|
p.replace('\'', "''")
|
||||||
|
),
|
||||||
|
(Some(ct), None) => format!(
|
||||||
|
"SELECT DISTINCT je.value FROM library_items li, json_each(li.genres) je
|
||||||
|
WHERE li.content_type = '{}' ORDER BY je.value ASC",
|
||||||
|
content_type_str(ct)
|
||||||
|
),
|
||||||
|
(None, Some(p)) => format!(
|
||||||
|
"SELECT DISTINCT je.value FROM library_items li, json_each(li.genres) je
|
||||||
|
WHERE li.provider_id = '{}' ORDER BY je.value ASC",
|
||||||
|
p.replace('\'', "''")
|
||||||
|
),
|
||||||
|
(None, None) => {
|
||||||
|
"SELECT DISTINCT je.value FROM library_items li, json_each(li.genres) je ORDER BY je.value ASC"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let rows: Vec<(String,)> = sqlx::query_as(&sql)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(rows.into_iter().map(|(s,)| s).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn latest_sync_status(&self) -> DomainResult<Vec<LibrarySyncLogEntry>> {
|
||||||
|
let rows = sqlx::query_as::<_, SyncLogRow>(
|
||||||
|
"SELECT * FROM library_sync_log
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT MAX(id) FROM library_sync_log GROUP BY provider_id
|
||||||
|
)
|
||||||
|
ORDER BY started_at DESC",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
LibrarySyncLogEntry::from_persistence(
|
||||||
|
r.id,
|
||||||
|
r.provider_id,
|
||||||
|
r.started_at,
|
||||||
|
r.finished_at,
|
||||||
|
r.items_found as u32,
|
||||||
|
r.status,
|
||||||
|
r.error_msg,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_sync_running(&self, provider_id: &str) -> DomainResult<bool> {
|
||||||
|
let count: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM library_sync_log WHERE provider_id = ? AND status = 'running'",
|
||||||
|
)
|
||||||
|
.bind(provider_id)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_shows(
|
||||||
|
&self,
|
||||||
|
provider_id: Option<&str>,
|
||||||
|
search_term: Option<&str>,
|
||||||
|
genres: &[String],
|
||||||
|
) -> DomainResult<Vec<ShowSummary>> {
|
||||||
|
let mut conditions = vec![
|
||||||
|
"content_type = 'episode'".to_string(),
|
||||||
|
"series_name IS NOT NULL".to_string(),
|
||||||
|
];
|
||||||
|
if let Some(p) = provider_id {
|
||||||
|
conditions.push(format!("provider_id = '{}'", p.replace('\'', "''")));
|
||||||
|
}
|
||||||
|
if let Some(st) = search_term {
|
||||||
|
let escaped = st.replace('\'', "''");
|
||||||
|
conditions.push(format!(
|
||||||
|
"(title LIKE '%{escaped}%' OR series_name LIKE '%{escaped}%')"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !genres.is_empty() {
|
||||||
|
let genre_conditions: Vec<String> = genres
|
||||||
|
.iter()
|
||||||
|
.map(|g| {
|
||||||
|
format!(
|
||||||
|
"EXISTS (SELECT 1 FROM json_each(library_items.genres) WHERE value = '{}')",
|
||||||
|
g.replace('\'', "''")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
conditions.push(format!("({})", genre_conditions.join(" OR ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let where_clause = format!("WHERE {}", conditions.join(" AND "));
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT series_name, COUNT(*) AS episode_count, \
|
||||||
|
COUNT(DISTINCT season_number) AS season_count, \
|
||||||
|
MAX(thumbnail_url) AS thumbnail_url, \
|
||||||
|
GROUP_CONCAT(genres, ',') AS genres_blob \
|
||||||
|
FROM library_items {} GROUP BY series_name ORDER BY series_name ASC",
|
||||||
|
where_clause
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, ShowSummaryRow>(&sql)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
ShowSummary::from_persistence(
|
||||||
|
r.series_name,
|
||||||
|
r.episode_count as u32,
|
||||||
|
r.season_count as u32,
|
||||||
|
r.thumbnail_url,
|
||||||
|
parse_genres_blob(&r.genres_blob),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_seasons(
|
||||||
|
&self,
|
||||||
|
series_name: &str,
|
||||||
|
provider_id: Option<&str>,
|
||||||
|
) -> DomainResult<Vec<SeasonSummary>> {
|
||||||
|
let mut conditions = vec![
|
||||||
|
format!("series_name = '{}'", series_name.replace('\'', "''")),
|
||||||
|
"content_type = 'episode'".to_string(),
|
||||||
|
];
|
||||||
|
if let Some(p) = provider_id {
|
||||||
|
conditions.push(format!("provider_id = '{}'", p.replace('\'', "''")));
|
||||||
|
}
|
||||||
|
let where_clause = format!("WHERE {}", conditions.join(" AND "));
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT season_number, COUNT(*) AS episode_count, \
|
||||||
|
MAX(thumbnail_url) AS thumbnail_url \
|
||||||
|
FROM library_items {} GROUP BY season_number ORDER BY season_number ASC",
|
||||||
|
where_clause
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, SeasonSummaryRow>(&sql)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
SeasonSummary::from_persistence(
|
||||||
|
r.season_number as u32,
|
||||||
|
r.episode_count as u32,
|
||||||
|
r.thumbnail_url,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
90
crates/adapters/sqlite/src/provider_config.rs
Normal file
90
crates/adapters/sqlite/src/provider_config.rs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use adapter_common::map_sqlx_error;
|
||||||
|
use domain::{
|
||||||
|
ports::provider_config::{ProviderConfigCommand, ProviderConfigQuery},
|
||||||
|
DomainResult, ProviderConfigRow,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct SqliteProviderConfig {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteProviderConfig {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProviderConfigCommand for SqliteProviderConfig {
|
||||||
|
async fn upsert(&self, row: &ProviderConfigRow) -> DomainResult<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"INSERT INTO provider_configs (id, provider_type, config_json, enabled, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
provider_type = excluded.provider_type,
|
||||||
|
config_json = excluded.config_json,
|
||||||
|
enabled = excluded.enabled,
|
||||||
|
updated_at = excluded.updated_at"#,
|
||||||
|
)
|
||||||
|
.bind(row.id())
|
||||||
|
.bind(row.provider_type())
|
||||||
|
.bind(row.config_json())
|
||||||
|
.bind(row.enabled() as i64)
|
||||||
|
.bind(row.updated_at())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: &str) -> DomainResult<()> {
|
||||||
|
sqlx::query("DELETE FROM provider_configs WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProviderConfigQuery for SqliteProviderConfig {
|
||||||
|
async fn get_all(&self) -> DomainResult<Vec<ProviderConfigRow>> {
|
||||||
|
let rows: Vec<(String, String, String, i64, String)> = sqlx::query_as(
|
||||||
|
"SELECT id, provider_type, config_json, enabled, updated_at FROM provider_configs",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, provider_type, config_json, enabled, updated_at)| {
|
||||||
|
ProviderConfigRow::from_persistence(
|
||||||
|
id,
|
||||||
|
provider_type,
|
||||||
|
config_json,
|
||||||
|
enabled != 0,
|
||||||
|
updated_at,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_id(&self, id: &str) -> DomainResult<Option<ProviderConfigRow>> {
|
||||||
|
let row: Option<(String, String, String, i64, String)> = sqlx::query_as(
|
||||||
|
"SELECT id, provider_type, config_json, enabled, updated_at FROM provider_configs WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
Ok(row.map(|(id, provider_type, config_json, enabled, updated_at)| {
|
||||||
|
ProviderConfigRow::from_persistence(id, provider_type, config_json, enabled != 0, updated_at)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
348
crates/adapters/sqlite/src/schedule.rs
Normal file
348
crates/adapters/sqlite/src/schedule.rs
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use adapter_common::{map_sqlx_error, parse_dt, parse_json, parse_uuid};
|
||||||
|
use domain::{
|
||||||
|
ports::schedule::{ScheduleCommand, ScheduleQuery},
|
||||||
|
BlockId, ChannelId, DomainError, DomainResult, GeneratedSchedule, MediaItem, MediaItemId,
|
||||||
|
PlaybackRecord, PlaybackRecordId, ScheduleId, ScheduledSlot, SlotId,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct SqliteScheduleRepository {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteScheduleRepository {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct ScheduleRow {
|
||||||
|
id: String,
|
||||||
|
channel_id: String,
|
||||||
|
valid_from: String,
|
||||||
|
valid_until: String,
|
||||||
|
generation: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct SlotRow {
|
||||||
|
id: String,
|
||||||
|
_schedule_id: String,
|
||||||
|
start_at: String,
|
||||||
|
end_at: String,
|
||||||
|
item: String,
|
||||||
|
source_block_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct LastSlotRow {
|
||||||
|
source_block_id: String,
|
||||||
|
item: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct PlaybackRecordRow {
|
||||||
|
id: String,
|
||||||
|
channel_id: String,
|
||||||
|
item_id: String,
|
||||||
|
played_at: String,
|
||||||
|
generation: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_slot_row(row: SlotRow) -> DomainResult<ScheduledSlot> {
|
||||||
|
let id = SlotId::from_uuid(parse_uuid(&row.id, "slot id")?);
|
||||||
|
let source_block_id = BlockId::from_uuid(parse_uuid(&row.source_block_id, "block id")?);
|
||||||
|
let item: MediaItem = parse_json(&row.item, "slot item")?;
|
||||||
|
|
||||||
|
Ok(ScheduledSlot::from_persistence(
|
||||||
|
id,
|
||||||
|
parse_dt(&row.start_at)?,
|
||||||
|
parse_dt(&row.end_at)?,
|
||||||
|
item,
|
||||||
|
source_block_id,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_schedule(row: ScheduleRow, slot_rows: Vec<SlotRow>) -> DomainResult<GeneratedSchedule> {
|
||||||
|
let id = ScheduleId::from_uuid(parse_uuid(&row.id, "schedule id")?);
|
||||||
|
let channel_id = ChannelId::from_uuid(parse_uuid(&row.channel_id, "channel id")?);
|
||||||
|
let slots: Result<Vec<ScheduledSlot>, _> = slot_rows.into_iter().map(map_slot_row).collect();
|
||||||
|
|
||||||
|
Ok(GeneratedSchedule::from_persistence(
|
||||||
|
id,
|
||||||
|
channel_id,
|
||||||
|
parse_dt(&row.valid_from)?,
|
||||||
|
parse_dt(&row.valid_until)?,
|
||||||
|
row.generation as u32,
|
||||||
|
slots?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_playback_row(row: PlaybackRecordRow) -> DomainResult<PlaybackRecord> {
|
||||||
|
let id = PlaybackRecordId::from_uuid(parse_uuid(&row.id, "playback record id")?);
|
||||||
|
let channel_id = ChannelId::from_uuid(parse_uuid(&row.channel_id, "channel id")?);
|
||||||
|
|
||||||
|
Ok(PlaybackRecord::from_persistence(
|
||||||
|
id,
|
||||||
|
channel_id,
|
||||||
|
MediaItemId::new(row.item_id),
|
||||||
|
parse_dt(&row.played_at)?,
|
||||||
|
row.generation as u32,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteScheduleRepository {
|
||||||
|
async fn fetch_slots(&self, schedule_id: &str) -> DomainResult<Vec<SlotRow>> {
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT id, schedule_id, start_at, end_at, item, source_block_id \
|
||||||
|
FROM scheduled_slots WHERE schedule_id = ? ORDER BY start_at",
|
||||||
|
)
|
||||||
|
.bind(schedule_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ScheduleCommand for SqliteScheduleRepository {
|
||||||
|
async fn save(&self, schedule: &GeneratedSchedule) -> DomainResult<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO generated_schedules (id, channel_id, valid_from, valid_until, generation)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
valid_from = excluded.valid_from,
|
||||||
|
valid_until = excluded.valid_until,
|
||||||
|
generation = excluded.generation
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(schedule.id().value().to_string())
|
||||||
|
.bind(schedule.channel_id().value().to_string())
|
||||||
|
.bind(schedule.valid_from().to_rfc3339())
|
||||||
|
.bind(schedule.valid_until().to_rfc3339())
|
||||||
|
.bind(schedule.generation() as i64)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM scheduled_slots WHERE schedule_id = ?")
|
||||||
|
.bind(schedule.id().value().to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
for slot in schedule.slots() {
|
||||||
|
let item_json = serde_json::to_string(slot.item())
|
||||||
|
.map_err(|e| DomainError::RepositoryError(format!("serialize slot item: {e}")))?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO scheduled_slots (id, schedule_id, start_at, end_at, item, source_block_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(slot.id().value().to_string())
|
||||||
|
.bind(schedule.id().value().to_string())
|
||||||
|
.bind(slot.start_at().to_rfc3339())
|
||||||
|
.bind(slot.end_at().to_rfc3339())
|
||||||
|
.bind(&item_json)
|
||||||
|
.bind(slot.source_block_id().value().to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_playback_record(&self, record: &PlaybackRecord) -> DomainResult<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO playback_records (id, channel_id, item_id, played_at, generation)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO NOTHING
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(record.id().to_string())
|
||||||
|
.bind(record.channel_id().value().to_string())
|
||||||
|
.bind(record.item_id().value())
|
||||||
|
.bind(record.played_at().to_rfc3339())
|
||||||
|
.bind(record.generation() as i64)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_schedules_after(
|
||||||
|
&self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
target_generation: u32,
|
||||||
|
) -> DomainResult<()> {
|
||||||
|
let ch = channel_id.value().to_string();
|
||||||
|
let target_gen = target_generation as i64;
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM playback_records WHERE channel_id = ? AND generation > ?")
|
||||||
|
.bind(&ch)
|
||||||
|
.bind(target_gen)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM generated_schedules WHERE channel_id = ? AND generation > ?")
|
||||||
|
.bind(&ch)
|
||||||
|
.bind(target_gen)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ScheduleQuery for SqliteScheduleRepository {
|
||||||
|
async fn find_active(
|
||||||
|
&self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
at: chrono::DateTime<chrono::Utc>,
|
||||||
|
) -> DomainResult<Option<GeneratedSchedule>> {
|
||||||
|
let at_str = at.to_rfc3339();
|
||||||
|
let row: Option<ScheduleRow> = sqlx::query_as(
|
||||||
|
"SELECT id, channel_id, valid_from, valid_until, generation \
|
||||||
|
FROM generated_schedules \
|
||||||
|
WHERE channel_id = ? AND valid_from <= ? AND valid_until > ? \
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(channel_id.value().to_string())
|
||||||
|
.bind(&at_str)
|
||||||
|
.bind(&at_str)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(r) => {
|
||||||
|
let slots = self.fetch_slots(&r.id).await?;
|
||||||
|
Some(map_schedule(r, slots)).transpose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_latest(&self, channel_id: ChannelId) -> DomainResult<Option<GeneratedSchedule>> {
|
||||||
|
let row: Option<ScheduleRow> = sqlx::query_as(
|
||||||
|
"SELECT id, channel_id, valid_from, valid_until, generation \
|
||||||
|
FROM generated_schedules \
|
||||||
|
WHERE channel_id = ? ORDER BY valid_from DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(channel_id.value().to_string())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(r) => {
|
||||||
|
let slots = self.fetch_slots(&r.id).await?;
|
||||||
|
Some(map_schedule(r, slots)).transpose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_playback_history(
|
||||||
|
&self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
) -> DomainResult<Vec<PlaybackRecord>> {
|
||||||
|
let rows: Vec<PlaybackRecordRow> = sqlx::query_as(
|
||||||
|
"SELECT id, channel_id, item_id, played_at, generation \
|
||||||
|
FROM playback_records WHERE channel_id = ? ORDER BY played_at DESC",
|
||||||
|
)
|
||||||
|
.bind(channel_id.value().to_string())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
rows.into_iter().map(map_playback_row).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_last_slot_per_block(
|
||||||
|
&self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
) -> DomainResult<HashMap<BlockId, MediaItemId>> {
|
||||||
|
let channel_id_str = channel_id.value().to_string();
|
||||||
|
let rows: Vec<LastSlotRow> = sqlx::query_as(
|
||||||
|
"SELECT ss.source_block_id, ss.item \
|
||||||
|
FROM scheduled_slots ss \
|
||||||
|
INNER JOIN generated_schedules gs ON gs.id = ss.schedule_id \
|
||||||
|
WHERE gs.channel_id = ? \
|
||||||
|
AND ss.start_at = ( \
|
||||||
|
SELECT MAX(ss2.start_at) \
|
||||||
|
FROM scheduled_slots ss2 \
|
||||||
|
INNER JOIN generated_schedules gs2 ON gs2.id = ss2.schedule_id \
|
||||||
|
WHERE ss2.source_block_id = ss.source_block_id \
|
||||||
|
AND gs2.channel_id = ? \
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(&channel_id_str)
|
||||||
|
.bind(&channel_id_str)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
for row in rows {
|
||||||
|
let block_id =
|
||||||
|
BlockId::from_uuid(parse_uuid(&row.source_block_id, "block id")?);
|
||||||
|
let item: MediaItem = parse_json(&row.item, "slot item")?;
|
||||||
|
map.insert(block_id, item.id().clone());
|
||||||
|
}
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_schedule_history(
|
||||||
|
&self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
) -> DomainResult<Vec<GeneratedSchedule>> {
|
||||||
|
let rows: Vec<ScheduleRow> = sqlx::query_as(
|
||||||
|
"SELECT id, channel_id, valid_from, valid_until, generation \
|
||||||
|
FROM generated_schedules WHERE channel_id = ? ORDER BY generation DESC",
|
||||||
|
)
|
||||||
|
.bind(channel_id.value().to_string())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|r| map_schedule(r, vec![]))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_schedule_by_id(
|
||||||
|
&self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
schedule_id: ScheduleId,
|
||||||
|
) -> DomainResult<Option<GeneratedSchedule>> {
|
||||||
|
let row: Option<ScheduleRow> = sqlx::query_as(
|
||||||
|
"SELECT id, channel_id, valid_from, valid_until, generation \
|
||||||
|
FROM generated_schedules WHERE id = ? AND channel_id = ?",
|
||||||
|
)
|
||||||
|
.bind(schedule_id.value().to_string())
|
||||||
|
.bind(channel_id.value().to_string())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(r) => {
|
||||||
|
let slots = self.fetch_slots(&r.id).await?;
|
||||||
|
Some(map_schedule(r, slots)).transpose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
crates/adapters/sqlite/src/settings.rs
Normal file
45
crates/adapters/sqlite/src/settings.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use domain::{
|
||||||
|
ports::settings::AppSettingsRepository,
|
||||||
|
DomainError, DomainResult,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct SqliteAppSettings {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteAppSettings {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AppSettingsRepository for SqliteAppSettings {
|
||||||
|
async fn get(&self, key: &str) -> DomainResult<Option<String>> {
|
||||||
|
sqlx::query_scalar::<_, String>("SELECT value FROM app_settings WHERE key = ?")
|
||||||
|
.bind(key)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set(&self, key: &str, value: &str) -> DomainResult<()> {
|
||||||
|
sqlx::query("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)")
|
||||||
|
.bind(key)
|
||||||
|
.bind(value)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_all(&self) -> DomainResult<Vec<(String, String)>> {
|
||||||
|
sqlx::query_as::<_, (String, String)>("SELECT key, value FROM app_settings ORDER BY key")
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
38
crates/adapters/sqlite/src/transcode.rs
Normal file
38
crates/adapters/sqlite/src/transcode.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use domain::{
|
||||||
|
ports::transcode::TranscodeSettingsRepository,
|
||||||
|
DomainError, DomainResult,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct SqliteTranscodeSettings {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteTranscodeSettings {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TranscodeSettingsRepository for SqliteTranscodeSettings {
|
||||||
|
async fn load_cleanup_ttl(&self) -> DomainResult<Option<u32>> {
|
||||||
|
let row: Option<(i64,)> =
|
||||||
|
sqlx::query_as("SELECT cleanup_ttl_hours FROM transcode_settings WHERE id = 1")
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(row.map(|(h,)| h as u32))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_cleanup_ttl(&self, hours: u32) -> DomainResult<()> {
|
||||||
|
sqlx::query("UPDATE transcode_settings SET cleanup_ttl_hours = ? WHERE id = 1")
|
||||||
|
.bind(hours as i64)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
140
crates/adapters/sqlite/src/user.rs
Normal file
140
crates/adapters/sqlite/src/user.rs
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use adapter_common::{map_sqlx_error, parse_dt, parse_uuid};
|
||||||
|
use domain::{
|
||||||
|
ports::user::{UserCommand, UserQuery},
|
||||||
|
DomainError, DomainResult, Email, User, UserId,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct SqliteUserRepository {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteUserRepository {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct UserRow {
|
||||||
|
id: String,
|
||||||
|
subject: String,
|
||||||
|
email: String,
|
||||||
|
password_hash: Option<String>,
|
||||||
|
is_admin: i64,
|
||||||
|
created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserRow {
|
||||||
|
fn into_user(self) -> DomainResult<User> {
|
||||||
|
let id = UserId::from_uuid(parse_uuid(&self.id, "user id")?);
|
||||||
|
let email = Email::new(&self.email)
|
||||||
|
.map_err(|e| DomainError::RepositoryError(format!("Invalid email: {e}")))?;
|
||||||
|
let created_at = parse_dt(&self.created_at)?;
|
||||||
|
|
||||||
|
Ok(User::from_persistence(
|
||||||
|
id,
|
||||||
|
self.subject,
|
||||||
|
email,
|
||||||
|
self.password_hash,
|
||||||
|
self.is_admin != 0,
|
||||||
|
created_at,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserCommand for SqliteUserRepository {
|
||||||
|
async fn save(&self, user: &User) -> DomainResult<()> {
|
||||||
|
let id = user.id().value().to_string();
|
||||||
|
let created_at = user.created_at().to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO users (id, subject, email, password_hash, is_admin, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
subject = excluded.subject,
|
||||||
|
email = excluded.email,
|
||||||
|
password_hash = excluded.password_hash,
|
||||||
|
is_admin = excluded.is_admin
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(user.subject())
|
||||||
|
.bind(user.email().as_ref())
|
||||||
|
.bind(user.password_hash())
|
||||||
|
.bind(user.is_admin() as i64)
|
||||||
|
.bind(&created_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.contains("UNIQUE constraint failed") || msg.contains("unique constraint") {
|
||||||
|
DomainError::UserAlreadyExists(user.email().as_ref().to_string())
|
||||||
|
} else {
|
||||||
|
map_sqlx_error(e)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: UserId) -> DomainResult<()> {
|
||||||
|
sqlx::query("DELETE FROM users WHERE id = ?")
|
||||||
|
.bind(id.value().to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserQuery for SqliteUserRepository {
|
||||||
|
async fn find_by_id(&self, id: UserId) -> DomainResult<Option<User>> {
|
||||||
|
let row: Option<UserRow> = sqlx::query_as(
|
||||||
|
"SELECT id, subject, email, password_hash, is_admin, created_at FROM users WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(id.value().to_string())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
row.map(UserRow::into_user).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>> {
|
||||||
|
let row: Option<UserRow> = sqlx::query_as(
|
||||||
|
"SELECT id, subject, email, password_hash, is_admin, created_at FROM users WHERE subject = ?",
|
||||||
|
)
|
||||||
|
.bind(subject)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
row.map(UserRow::into_user).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>> {
|
||||||
|
let row: Option<UserRow> = sqlx::query_as(
|
||||||
|
"SELECT id, subject, email, password_hash, is_admin, created_at FROM users WHERE email = ?",
|
||||||
|
)
|
||||||
|
.bind(email)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
row.map(UserRow::into_user).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_users(&self) -> DomainResult<u64> {
|
||||||
|
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
Ok(count as u64)
|
||||||
|
}
|
||||||
|
}
|
||||||
70
crates/adapters/sqlite/src/wire.rs
Normal file
70
crates/adapters/sqlite/src/wire.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use domain::ports::{
|
||||||
|
activity::{ActivityLogCommand, ActivityLogQuery},
|
||||||
|
channel::{ChannelCommand, ChannelQuery},
|
||||||
|
library::{LibraryCommand, LibraryQuery},
|
||||||
|
provider_config::{ProviderConfigCommand, ProviderConfigQuery},
|
||||||
|
schedule::{ScheduleCommand, ScheduleQuery},
|
||||||
|
settings::AppSettingsRepository,
|
||||||
|
transcode::TranscodeSettingsRepository,
|
||||||
|
user::{UserCommand, UserQuery},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
activity::SqliteActivityLog,
|
||||||
|
channel::SqliteChannelRepository,
|
||||||
|
library::SqliteLibraryRepository,
|
||||||
|
provider_config::SqliteProviderConfig,
|
||||||
|
schedule::SqliteScheduleRepository,
|
||||||
|
settings::SqliteAppSettings,
|
||||||
|
transcode::SqliteTranscodeSettings,
|
||||||
|
user::SqliteUserRepository,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct SqliteWireOutput {
|
||||||
|
pub user_command: Arc<dyn UserCommand>,
|
||||||
|
pub user_query: Arc<dyn UserQuery>,
|
||||||
|
pub channel_command: Arc<dyn ChannelCommand>,
|
||||||
|
pub channel_query: Arc<dyn ChannelQuery>,
|
||||||
|
pub schedule_command: Arc<dyn ScheduleCommand>,
|
||||||
|
pub schedule_query: Arc<dyn ScheduleQuery>,
|
||||||
|
pub library_command: Arc<dyn LibraryCommand>,
|
||||||
|
pub library_query: Arc<dyn LibraryQuery>,
|
||||||
|
pub activity_command: Arc<dyn ActivityLogCommand>,
|
||||||
|
pub activity_query: Arc<dyn ActivityLogQuery>,
|
||||||
|
pub settings: Arc<dyn AppSettingsRepository>,
|
||||||
|
pub provider_config_command: Arc<dyn ProviderConfigCommand>,
|
||||||
|
pub provider_config_query: Arc<dyn ProviderConfigQuery>,
|
||||||
|
pub transcode_settings: Arc<dyn TranscodeSettingsRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wire(pool: SqlitePool) -> SqliteWireOutput {
|
||||||
|
let user = Arc::new(SqliteUserRepository::new(pool.clone()));
|
||||||
|
let channel = Arc::new(SqliteChannelRepository::new(pool.clone()));
|
||||||
|
let schedule = Arc::new(SqliteScheduleRepository::new(pool.clone()));
|
||||||
|
let library = Arc::new(SqliteLibraryRepository::new(pool.clone()));
|
||||||
|
let activity = Arc::new(SqliteActivityLog::new(pool.clone()));
|
||||||
|
let settings = Arc::new(SqliteAppSettings::new(pool.clone()));
|
||||||
|
let provider_config = Arc::new(SqliteProviderConfig::new(pool.clone()));
|
||||||
|
let transcode_settings = Arc::new(SqliteTranscodeSettings::new(pool));
|
||||||
|
|
||||||
|
SqliteWireOutput {
|
||||||
|
user_command: user.clone(),
|
||||||
|
user_query: user,
|
||||||
|
channel_command: channel.clone(),
|
||||||
|
channel_query: channel,
|
||||||
|
schedule_command: schedule.clone(),
|
||||||
|
schedule_query: schedule,
|
||||||
|
library_command: library.clone(),
|
||||||
|
library_query: library,
|
||||||
|
activity_command: activity.clone(),
|
||||||
|
activity_query: activity,
|
||||||
|
settings,
|
||||||
|
provider_config_command: provider_config.clone(),
|
||||||
|
provider_config_query: provider_config,
|
||||||
|
transcode_settings,
|
||||||
|
}
|
||||||
|
}
|
||||||
12
crates/api-types/Cargo.toml
Normal file
12
crates/api-types/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "api-types"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
utoipa = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
35
crates/api-types/src/admin.rs
Normal file
35
crates/api-types/src/admin.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct SettingsResponse {
|
||||||
|
pub settings: std::collections::HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct ActivityEventResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
pub event_type: String,
|
||||||
|
pub detail: String,
|
||||||
|
pub channel_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct ActivityLogParams {
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<domain::ActivityEvent> for ActivityEventResponse {
|
||||||
|
fn from(e: domain::ActivityEvent) -> Self {
|
||||||
|
Self {
|
||||||
|
id: e.id().value(),
|
||||||
|
timestamp: e.timestamp(),
|
||||||
|
event_type: e.event_type().to_string(),
|
||||||
|
detail: e.detail().to_string(),
|
||||||
|
channel_id: e.channel_id().map(|id| id.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
crates/api-types/src/auth.rs
Normal file
51
crates/api-types/src/auth.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub remember_me: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct RegisterRequest {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct RefreshRequest {
|
||||||
|
pub refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct TokenResponse {
|
||||||
|
pub access_token: String,
|
||||||
|
pub token_type: String,
|
||||||
|
pub expires_in: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub refresh_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct UserResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub email: String,
|
||||||
|
pub is_admin: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<domain::User> for UserResponse {
|
||||||
|
fn from(user: domain::User) -> Self {
|
||||||
|
Self {
|
||||||
|
id: user.id().value(),
|
||||||
|
email: user.email().to_string(),
|
||||||
|
is_admin: user.is_admin(),
|
||||||
|
created_at: user.created_at(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
111
crates/api-types/src/channels.rs
Normal file
111
crates/api-types/src/channels.rs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::common::enum_to_string;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct CreateChannelRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub timezone: String,
|
||||||
|
pub access_mode: Option<String>,
|
||||||
|
pub access_password: Option<String>,
|
||||||
|
pub webhook_url: Option<String>,
|
||||||
|
pub webhook_poll_interval_secs: Option<u32>,
|
||||||
|
pub webhook_body_template: Option<String>,
|
||||||
|
pub webhook_headers: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateChannelRequest {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub timezone: Option<String>,
|
||||||
|
#[schema(value_type = Option<Object>)]
|
||||||
|
pub schedule_config: Option<domain::models::ScheduleConfigCompat>,
|
||||||
|
#[schema(value_type = Option<Object>)]
|
||||||
|
pub recycle_policy: Option<domain::RecyclePolicy>,
|
||||||
|
pub auto_schedule: Option<bool>,
|
||||||
|
pub access_mode: Option<String>,
|
||||||
|
pub access_password: Option<String>,
|
||||||
|
pub logo: Option<Option<String>>,
|
||||||
|
pub logo_position: Option<String>,
|
||||||
|
pub logo_opacity: Option<f32>,
|
||||||
|
pub webhook_url: Option<Option<String>>,
|
||||||
|
pub webhook_poll_interval_secs: Option<u32>,
|
||||||
|
pub webhook_body_template: Option<Option<String>>,
|
||||||
|
pub webhook_headers: Option<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct ChannelResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub owner_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub timezone: String,
|
||||||
|
pub schedule_config: serde_json::Value,
|
||||||
|
pub recycle_policy: serde_json::Value,
|
||||||
|
pub auto_schedule: bool,
|
||||||
|
pub access_mode: String,
|
||||||
|
pub logo: Option<String>,
|
||||||
|
pub logo_position: String,
|
||||||
|
pub logo_opacity: f32,
|
||||||
|
pub webhook_url: Option<String>,
|
||||||
|
pub webhook_poll_interval_secs: u32,
|
||||||
|
pub webhook_body_template: Option<String>,
|
||||||
|
pub webhook_headers: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<domain::Channel> for ChannelResponse {
|
||||||
|
fn from(c: domain::Channel) -> Self {
|
||||||
|
Self {
|
||||||
|
id: c.id().value(),
|
||||||
|
owner_id: c.owner_id().value(),
|
||||||
|
name: c.name().to_string(),
|
||||||
|
description: c.description().map(|s| s.to_string()),
|
||||||
|
timezone: c.timezone().to_string(),
|
||||||
|
schedule_config: serde_json::to_value(c.schedule_config()).unwrap_or_default(),
|
||||||
|
recycle_policy: serde_json::to_value(c.recycle_policy()).unwrap_or_default(),
|
||||||
|
auto_schedule: c.auto_schedule(),
|
||||||
|
access_mode: enum_to_string(c.access_mode()),
|
||||||
|
logo: c.logo().map(|s| s.to_string()),
|
||||||
|
logo_position: enum_to_string(c.logo_position()),
|
||||||
|
logo_opacity: c.logo_opacity(),
|
||||||
|
webhook_url: c.webhook_url().map(|s| s.to_string()),
|
||||||
|
webhook_poll_interval_secs: c.webhook_poll_interval_secs(),
|
||||||
|
webhook_body_template: c.webhook_body_template().map(|s| s.to_string()),
|
||||||
|
webhook_headers: c.webhook_headers().map(|s| s.to_string()),
|
||||||
|
created_at: c.created_at(),
|
||||||
|
updated_at: c.updated_at(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct ConfigSnapshotResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub version_num: i64,
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<domain::ChannelConfigSnapshot> for ConfigSnapshotResponse {
|
||||||
|
fn from(s: domain::ChannelConfigSnapshot) -> Self {
|
||||||
|
Self {
|
||||||
|
id: s.id().value(),
|
||||||
|
version_num: s.version_num(),
|
||||||
|
label: s.label().map(|s| s.to_string()),
|
||||||
|
created_at: s.created_at(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct PatchSnapshotRequest {
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
47
crates/api-types/src/common.rs
Normal file
47
crates/api-types/src/common.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct PaginatedResponse<T: ToSchema> {
|
||||||
|
pub items: Vec<T>,
|
||||||
|
pub total: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: ToSchema> PaginatedResponse<T> {
|
||||||
|
pub fn new(items: Vec<T>, total: u64) -> Self {
|
||||||
|
Self { items, total }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub error: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub details: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ErrorResponse {
|
||||||
|
pub fn new(error: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
error: error.into(),
|
||||||
|
details: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_details(error: impl Into<String>, details: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
error: error.into(),
|
||||||
|
details: Some(details.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn enum_to_string<T: Serialize>(val: &T) -> String {
|
||||||
|
serde_json::to_value(val)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| match v {
|
||||||
|
serde_json::Value::String(s) => Some(s),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
47
crates/api-types/src/config.rs
Normal file
47
crates/api-types/src/config.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::common::enum_to_string;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct ProviderCapabilitiesResponse {
|
||||||
|
pub collections: bool,
|
||||||
|
pub series: bool,
|
||||||
|
pub genres: bool,
|
||||||
|
pub tags: bool,
|
||||||
|
pub decade: bool,
|
||||||
|
pub search: bool,
|
||||||
|
pub streaming_protocol: String,
|
||||||
|
pub rescan: bool,
|
||||||
|
pub transcode: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<domain::ports::ProviderCapabilities> for ProviderCapabilitiesResponse {
|
||||||
|
fn from(c: domain::ports::ProviderCapabilities) -> Self {
|
||||||
|
Self {
|
||||||
|
collections: c.collections,
|
||||||
|
series: c.series,
|
||||||
|
genres: c.genres,
|
||||||
|
tags: c.tags,
|
||||||
|
decade: c.decade,
|
||||||
|
search: c.search,
|
||||||
|
streaming_protocol: enum_to_string(&c.streaming_protocol),
|
||||||
|
rescan: c.rescan,
|
||||||
|
transcode: c.transcode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct ProviderInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub capabilities: ProviderCapabilitiesResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct ConfigResponse {
|
||||||
|
pub allow_registration: bool,
|
||||||
|
pub providers: Vec<ProviderInfo>,
|
||||||
|
pub provider_capabilities: ProviderCapabilitiesResponse,
|
||||||
|
pub available_provider_types: Vec<String>,
|
||||||
|
}
|
||||||
7
crates/api-types/src/iptv.rs
Normal file
7
crates/api-types/src/iptv.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct IptvParams {
|
||||||
|
pub token: Option<String>,
|
||||||
|
}
|
||||||
32
crates/api-types/src/lib.rs
Normal file
32
crates/api-types/src/lib.rs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
pub mod admin;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod channels;
|
||||||
|
pub mod common;
|
||||||
|
pub mod config;
|
||||||
|
pub mod iptv;
|
||||||
|
pub mod library;
|
||||||
|
pub mod providers;
|
||||||
|
pub mod schedule;
|
||||||
|
pub mod transcode;
|
||||||
|
|
||||||
|
pub use admin::{ActivityEventResponse, ActivityLogParams, SettingsResponse};
|
||||||
|
pub use auth::{LoginRequest, RefreshRequest, RegisterRequest, TokenResponse, UserResponse};
|
||||||
|
pub use channels::{
|
||||||
|
ChannelResponse, ConfigSnapshotResponse, CreateChannelRequest, PatchSnapshotRequest,
|
||||||
|
UpdateChannelRequest,
|
||||||
|
};
|
||||||
|
pub use common::{ErrorResponse, PaginatedResponse};
|
||||||
|
pub use config::{ConfigResponse, ProviderCapabilitiesResponse, ProviderInfo};
|
||||||
|
pub use iptv::IptvParams;
|
||||||
|
pub use library::{
|
||||||
|
CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, ProviderParam,
|
||||||
|
SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry,
|
||||||
|
};
|
||||||
|
pub use providers::{ProviderConfigRequest, ProviderConfigResponse};
|
||||||
|
pub use schedule::{
|
||||||
|
CurrentBroadcastResponse, MediaItemResponse, ScheduleHistoryEntry, ScheduleResponse,
|
||||||
|
SlotResponse,
|
||||||
|
};
|
||||||
|
pub use transcode::{
|
||||||
|
TranscodeSettingsResponse, TranscodeStatsResponse, UpdateTranscodeSettingsRequest,
|
||||||
|
};
|
||||||
168
crates/api-types/src/library.rs
Normal file
168
crates/api-types/src/library.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::common::enum_to_string;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct LibraryItemResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub provider_id: String,
|
||||||
|
pub external_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub content_type: String,
|
||||||
|
pub duration_secs: u32,
|
||||||
|
pub series_name: Option<String>,
|
||||||
|
pub season_number: Option<u32>,
|
||||||
|
pub episode_number: Option<u32>,
|
||||||
|
pub year: Option<u16>,
|
||||||
|
pub genres: Vec<String>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub collection_id: Option<String>,
|
||||||
|
pub collection_name: Option<String>,
|
||||||
|
pub collection_type: Option<String>,
|
||||||
|
pub thumbnail_url: Option<String>,
|
||||||
|
pub synced_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<domain::LibraryItem> for LibraryItemResponse {
|
||||||
|
fn from(i: domain::LibraryItem) -> Self {
|
||||||
|
Self {
|
||||||
|
id: i.id().to_string(),
|
||||||
|
provider_id: i.provider_id().to_string(),
|
||||||
|
external_id: i.external_id().to_string(),
|
||||||
|
title: i.title().to_string(),
|
||||||
|
content_type: enum_to_string(i.content_type()),
|
||||||
|
duration_secs: i.duration_secs(),
|
||||||
|
series_name: i.series_name().map(|s| s.to_string()),
|
||||||
|
season_number: i.season_number(),
|
||||||
|
episode_number: i.episode_number(),
|
||||||
|
year: i.year(),
|
||||||
|
genres: i.genres().to_vec(),
|
||||||
|
tags: i.tags().to_vec(),
|
||||||
|
collection_id: i.collection_id().map(|s| s.to_string()),
|
||||||
|
collection_name: i.collection_name().map(|s| s.to_string()),
|
||||||
|
collection_type: i.collection_type().map(|s| s.to_string()),
|
||||||
|
thumbnail_url: i.thumbnail_url().map(|s| s.to_string()),
|
||||||
|
synced_at: i.synced_at().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct CollectionResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub collection_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<domain::LibraryCollection> for CollectionResponse {
|
||||||
|
fn from(c: domain::LibraryCollection) -> Self {
|
||||||
|
Self {
|
||||||
|
id: c.id().to_string(),
|
||||||
|
name: c.name().to_string(),
|
||||||
|
collection_type: c.collection_type().map(|s| s.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct ShowResponse {
|
||||||
|
pub series_name: String,
|
||||||
|
pub episode_count: u32,
|
||||||
|
pub season_count: u32,
|
||||||
|
pub thumbnail_url: Option<String>,
|
||||||
|
pub genres: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<domain::ShowSummary> for ShowResponse {
|
||||||
|
fn from(s: domain::ShowSummary) -> Self {
|
||||||
|
Self {
|
||||||
|
series_name: s.series_name().to_string(),
|
||||||
|
episode_count: s.episode_count(),
|
||||||
|
season_count: s.season_count(),
|
||||||
|
thumbnail_url: s.thumbnail_url().map(|s| s.to_string()),
|
||||||
|
genres: s.genres().to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct SeasonResponse {
|
||||||
|
pub season_number: u32,
|
||||||
|
pub episode_count: u32,
|
||||||
|
pub thumbnail_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<domain::SeasonSummary> for SeasonResponse {
|
||||||
|
fn from(s: domain::SeasonSummary) -> Self {
|
||||||
|
Self {
|
||||||
|
season_number: s.season_number(),
|
||||||
|
episode_count: s.episode_count(),
|
||||||
|
thumbnail_url: s.thumbnail_url().map(|s| s.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct SyncStatusEntry {
|
||||||
|
pub provider_id: String,
|
||||||
|
pub started_at: String,
|
||||||
|
pub finished_at: String,
|
||||||
|
pub items_found: u32,
|
||||||
|
pub status: String,
|
||||||
|
pub error_msg: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<domain::LibrarySyncLogEntry> for SyncStatusEntry {
|
||||||
|
fn from(e: domain::LibrarySyncLogEntry) -> Self {
|
||||||
|
Self {
|
||||||
|
provider_id: e.provider_id().to_string(),
|
||||||
|
started_at: e.started_at().to_string(),
|
||||||
|
finished_at: e.finished_at().unwrap_or("").to_string(),
|
||||||
|
items_found: e.items_found(),
|
||||||
|
status: e.status().to_string(),
|
||||||
|
error_msg: e.error_msg().unwrap_or("").to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct LibrarySearchParams {
|
||||||
|
pub provider: Option<String>,
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
#[serde(default, rename = "genres[]")]
|
||||||
|
pub genres: Vec<String>,
|
||||||
|
pub search_term: Option<String>,
|
||||||
|
pub collection_id: Option<String>,
|
||||||
|
#[serde(default, rename = "series_names[]")]
|
||||||
|
pub series_names: Vec<String>,
|
||||||
|
pub season_number: Option<u32>,
|
||||||
|
pub decade: Option<u16>,
|
||||||
|
pub offset: Option<u32>,
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct ProviderParam {
|
||||||
|
pub provider: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct ShowsParams {
|
||||||
|
pub provider: Option<String>,
|
||||||
|
pub search_term: Option<String>,
|
||||||
|
#[serde(default, rename = "genres[]")]
|
||||||
|
pub genres: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct SeasonsParams {
|
||||||
|
pub series_name: String,
|
||||||
|
pub provider: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct GenresParams {
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
pub provider: Option<String>,
|
||||||
|
}
|
||||||
37
crates/api-types/src/providers.rs
Normal file
37
crates/api-types/src/providers.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct ProviderConfigRequest {
|
||||||
|
pub provider_type: String,
|
||||||
|
pub config: serde_json::Value,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct ProviderConfigResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub provider_type: String,
|
||||||
|
pub config: serde_json::Value,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<domain::ProviderConfigRow> for ProviderConfigResponse {
|
||||||
|
fn from(r: domain::ProviderConfigRow) -> Self {
|
||||||
|
let config = serde_json::from_str(r.config_json())
|
||||||
|
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||||
|
Self {
|
||||||
|
id: r.id().to_string(),
|
||||||
|
provider_type: r.provider_type().to_string(),
|
||||||
|
config,
|
||||||
|
enabled: r.enabled(),
|
||||||
|
updated_at: r.updated_at().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
141
crates/api-types/src/schedule.rs
Normal file
141
crates/api-types/src/schedule.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::common::enum_to_string;
|
||||||
|
|
||||||
|
const DEFAULT_ACCESS_MODE: &str = "public";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct MediaItemResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub content_type: String,
|
||||||
|
pub duration_secs: u32,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub genres: Vec<String>,
|
||||||
|
pub year: Option<u16>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub series_name: Option<String>,
|
||||||
|
pub season_number: Option<u32>,
|
||||||
|
pub episode_number: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<domain::MediaItem> for MediaItemResponse {
|
||||||
|
fn from(i: domain::MediaItem) -> Self {
|
||||||
|
Self {
|
||||||
|
id: i.id().value().to_string(),
|
||||||
|
title: i.title().to_string(),
|
||||||
|
content_type: enum_to_string(i.content_type()),
|
||||||
|
duration_secs: i.duration_secs(),
|
||||||
|
description: i.description().map(|s| s.to_string()),
|
||||||
|
genres: i.genres().to_vec(),
|
||||||
|
year: i.year(),
|
||||||
|
tags: i.tags().to_vec(),
|
||||||
|
series_name: i.series_name().map(|s| s.to_string()),
|
||||||
|
season_number: i.season_number(),
|
||||||
|
episode_number: i.episode_number(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct SlotResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub start_at: DateTime<Utc>,
|
||||||
|
pub end_at: DateTime<Utc>,
|
||||||
|
pub item: MediaItemResponse,
|
||||||
|
pub source_block_id: Uuid,
|
||||||
|
#[serde(default)]
|
||||||
|
pub block_access_mode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<domain::ScheduledSlot> for SlotResponse {
|
||||||
|
fn from(s: domain::ScheduledSlot) -> Self {
|
||||||
|
Self {
|
||||||
|
id: s.id().value(),
|
||||||
|
start_at: s.start_at(),
|
||||||
|
end_at: s.end_at(),
|
||||||
|
item: s.item().clone().into(),
|
||||||
|
source_block_id: s.source_block_id().value(),
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct CurrentBroadcastResponse {
|
||||||
|
pub slot: SlotResponse,
|
||||||
|
pub offset_secs: u32,
|
||||||
|
pub block_access_mode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct ScheduleResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub channel_id: Uuid,
|
||||||
|
pub valid_from: DateTime<Utc>,
|
||||||
|
pub valid_until: DateTime<Utc>,
|
||||||
|
pub generation: u32,
|
||||||
|
pub slots: Vec<SlotResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<domain::GeneratedSchedule> for ScheduleResponse {
|
||||||
|
fn from(s: domain::GeneratedSchedule) -> Self {
|
||||||
|
let id = s.id().value();
|
||||||
|
let channel_id = s.channel_id().value();
|
||||||
|
let valid_from = s.valid_from();
|
||||||
|
let valid_until = s.valid_until();
|
||||||
|
let generation = s.generation();
|
||||||
|
let slots = s.into_slots().into_iter().map(Into::into).collect();
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
channel_id,
|
||||||
|
valid_from,
|
||||||
|
valid_until,
|
||||||
|
generation,
|
||||||
|
slots,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct ScheduleHistoryEntry {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub generation: u32,
|
||||||
|
pub valid_from: DateTime<Utc>,
|
||||||
|
pub valid_until: DateTime<Utc>,
|
||||||
|
pub slot_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<domain::GeneratedSchedule> for ScheduleHistoryEntry {
|
||||||
|
fn from(s: domain::GeneratedSchedule) -> Self {
|
||||||
|
Self {
|
||||||
|
id: s.id().value(),
|
||||||
|
generation: s.generation(),
|
||||||
|
valid_from: s.valid_from(),
|
||||||
|
valid_until: s.valid_until(),
|
||||||
|
slot_count: s.slots().len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
crates/api-types/src/transcode.rs
Normal file
18
crates/api-types/src/transcode.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct TranscodeSettingsResponse {
|
||||||
|
pub cleanup_ttl_hours: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateTranscodeSettingsRequest {
|
||||||
|
pub cleanup_ttl_hours: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct TranscodeStatsResponse {
|
||||||
|
pub cache_size_bytes: u64,
|
||||||
|
pub item_count: usize,
|
||||||
|
}
|
||||||
15
crates/application/Cargo.toml
Normal file
15
crates/application/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "application"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
domain = { workspace = true, features = ["test-helpers"] }
|
||||||
|
tokio = { workspace = true }
|
||||||
12
crates/application/src/admin/activity_log.rs
Normal file
12
crates/application/src/admin/activity_log.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
3
crates/application/src/admin/commands.rs
Normal file
3
crates/application/src/admin/commands.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub struct UpdateSettingsCommand {
|
||||||
|
pub settings: Vec<(String, String)>,
|
||||||
|
}
|
||||||
8
crates/application/src/admin/deps.rs
Normal file
8
crates/application/src/admin/deps.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::{ActivityLogQuery, AppSettingsRepository};
|
||||||
|
|
||||||
|
pub struct AdminDeps {
|
||||||
|
pub settings_repo: Arc<dyn AppSettingsRepository>,
|
||||||
|
pub activity_query: Arc<dyn ActivityLogQuery>,
|
||||||
|
}
|
||||||
15
crates/application/src/admin/get_settings.rs
Normal file
15
crates/application/src/admin/get_settings.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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;
|
||||||
10
crates/application/src/admin/mod.rs
Normal file
10
crates/application/src/admin/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
pub mod activity_log;
|
||||||
|
pub mod commands;
|
||||||
|
pub mod deps;
|
||||||
|
pub mod get_settings;
|
||||||
|
pub mod queries;
|
||||||
|
pub mod update_settings;
|
||||||
|
|
||||||
|
pub use commands::UpdateSettingsCommand;
|
||||||
|
pub use deps::AdminDeps;
|
||||||
|
pub use queries::{GetActivityLogQuery, GetSettingsQuery};
|
||||||
5
crates/application/src/admin/queries.rs
Normal file
5
crates/application/src/admin/queries.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub struct GetSettingsQuery;
|
||||||
|
|
||||||
|
pub struct GetActivityLogQuery {
|
||||||
|
pub limit: u32,
|
||||||
|
}
|
||||||
46
crates/application/src/admin/tests/get_settings.rs
Normal file
46
crates/application/src/admin/tests/get_settings.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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"));
|
||||||
|
}
|
||||||
63
crates/application/src/admin/tests/update_settings.rs
Normal file
63
crates/application/src/admin/tests/update_settings.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::testing::{InMemoryActivityLog, InMemoryAppSettings};
|
||||||
|
|
||||||
|
use crate::admin::commands::UpdateSettingsCommand;
|
||||||
|
use crate::admin::deps::AdminDeps;
|
||||||
|
use crate::admin::update_settings;
|
||||||
|
|
||||||
|
fn make_deps() -> AdminDeps {
|
||||||
|
AdminDeps {
|
||||||
|
settings_repo: Arc::new(InMemoryAppSettings::new()),
|
||||||
|
activity_query: Arc::new(InMemoryActivityLog::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_stores_settings() {
|
||||||
|
let deps = make_deps();
|
||||||
|
|
||||||
|
update_settings::execute(
|
||||||
|
&deps,
|
||||||
|
UpdateSettingsCommand {
|
||||||
|
settings: vec![
|
||||||
|
("library_sync_interval_hours".into(), "12".into()),
|
||||||
|
("theme".into(), "dark".into()),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let val = deps.settings_repo.get("library_sync_interval_hours").await.unwrap();
|
||||||
|
assert_eq!(val, Some("12".into()));
|
||||||
|
|
||||||
|
let val2 = deps.settings_repo.get("theme").await.unwrap();
|
||||||
|
assert_eq!(val2, Some("dark".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_overwrites_existing() {
|
||||||
|
let deps = make_deps();
|
||||||
|
|
||||||
|
update_settings::execute(
|
||||||
|
&deps,
|
||||||
|
UpdateSettingsCommand {
|
||||||
|
settings: vec![("key".into(), "old".into())],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
update_settings::execute(
|
||||||
|
&deps,
|
||||||
|
UpdateSettingsCommand {
|
||||||
|
settings: vec![("key".into(), "new".into())],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let val = deps.settings_repo.get("key").await.unwrap();
|
||||||
|
assert_eq!(val, Some("new".into()));
|
||||||
|
}
|
||||||
15
crates/application/src/admin/update_settings.rs
Normal file
15
crates/application/src/admin/update_settings.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
use domain::DomainResult;
|
||||||
|
|
||||||
|
use super::commands::UpdateSettingsCommand;
|
||||||
|
use super::deps::AdminDeps;
|
||||||
|
|
||||||
|
pub async fn execute(deps: &AdminDeps, cmd: UpdateSettingsCommand) -> DomainResult<Vec<(String, String)>> {
|
||||||
|
for (key, value) in &cmd.settings {
|
||||||
|
deps.settings_repo.set(key, value).await?;
|
||||||
|
}
|
||||||
|
deps.settings_repo.get_all().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/update_settings.rs"]
|
||||||
|
mod tests;
|
||||||
10
crates/application/src/auth/commands.rs
Normal file
10
crates/application/src/auth/commands.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
pub struct RegisterCommand {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LoginCommand {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
pub remember_me: bool,
|
||||||
|
}
|
||||||
11
crates/application/src/auth/deps.rs
Normal file
11
crates/application/src/auth/deps.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::{AuthService, EventPublisher, TokenService, UserCommand, UserQuery};
|
||||||
|
|
||||||
|
pub struct AuthDeps {
|
||||||
|
pub user_command: Arc<dyn UserCommand>,
|
||||||
|
pub user_query: Arc<dyn UserQuery>,
|
||||||
|
pub auth_service: Arc<dyn AuthService>,
|
||||||
|
pub token_service: Arc<dyn TokenService>,
|
||||||
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
|
}
|
||||||
44
crates/application/src/auth/login.rs
Normal file
44
crates/application/src/auth/login.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
use domain::{DomainError, DomainResult, Email};
|
||||||
|
|
||||||
|
use super::commands::LoginCommand;
|
||||||
|
use super::deps::AuthDeps;
|
||||||
|
use super::results::LoginResult;
|
||||||
|
|
||||||
|
const INVALID_CREDENTIALS: &str = "Invalid credentials";
|
||||||
|
|
||||||
|
pub async fn execute(deps: &AuthDeps, cmd: LoginCommand) -> DomainResult<LoginResult> {
|
||||||
|
let email = Email::new(&cmd.email)?;
|
||||||
|
|
||||||
|
let user = deps
|
||||||
|
.user_query
|
||||||
|
.find_by_email(email.as_ref())
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DomainError::unauthenticated(INVALID_CREDENTIALS))?;
|
||||||
|
|
||||||
|
let hash = user
|
||||||
|
.password_hash()
|
||||||
|
.ok_or_else(|| DomainError::unauthenticated(INVALID_CREDENTIALS))?;
|
||||||
|
|
||||||
|
let valid = deps.auth_service.verify_password(&cmd.password, hash)?;
|
||||||
|
if !valid {
|
||||||
|
return Err(DomainError::unauthenticated(INVALID_CREDENTIALS));
|
||||||
|
}
|
||||||
|
|
||||||
|
let access_token = deps.token_service.create_access_token(&user)?;
|
||||||
|
let refresh_token = if cmd.remember_me {
|
||||||
|
Some(deps.token_service.create_refresh_token(&user)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let expires_in = deps.token_service.token_expiry_secs();
|
||||||
|
|
||||||
|
Ok(LoginResult {
|
||||||
|
access_token,
|
||||||
|
refresh_token,
|
||||||
|
expires_in,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/login.rs"]
|
||||||
|
mod tests;
|
||||||
11
crates/application/src/auth/mod.rs
Normal file
11
crates/application/src/auth/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
pub mod commands;
|
||||||
|
pub mod deps;
|
||||||
|
pub mod login;
|
||||||
|
pub mod queries;
|
||||||
|
pub mod refresh;
|
||||||
|
pub mod register;
|
||||||
|
pub mod results;
|
||||||
|
|
||||||
|
pub use commands::{LoginCommand, RegisterCommand};
|
||||||
|
pub use deps::AuthDeps;
|
||||||
|
pub use results::LoginResult;
|
||||||
0
crates/application/src/auth/queries.rs
Normal file
0
crates/application/src/auth/queries.rs
Normal file
24
crates/application/src/auth/refresh.rs
Normal file
24
crates/application/src/auth/refresh.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use domain::{DomainError, DomainResult};
|
||||||
|
|
||||||
|
use super::deps::AuthDeps;
|
||||||
|
use super::results::LoginResult;
|
||||||
|
|
||||||
|
pub async fn execute(deps: &AuthDeps, refresh_token: String) -> DomainResult<LoginResult> {
|
||||||
|
let user_id = deps.token_service.validate_refresh_token(&refresh_token)?;
|
||||||
|
|
||||||
|
let user = deps
|
||||||
|
.user_query
|
||||||
|
.find_by_id(user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DomainError::Unauthenticated("User not found".to_string()))?;
|
||||||
|
|
||||||
|
let access_token = deps.token_service.create_access_token(&user)?;
|
||||||
|
let new_refresh = Some(deps.token_service.create_refresh_token(&user)?);
|
||||||
|
let expires_in = deps.token_service.token_expiry_secs();
|
||||||
|
|
||||||
|
Ok(LoginResult {
|
||||||
|
access_token,
|
||||||
|
refresh_token: new_refresh,
|
||||||
|
expires_in,
|
||||||
|
})
|
||||||
|
}
|
||||||
36
crates/application/src/auth/register.rs
Normal file
36
crates/application/src/auth/register.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
use domain::events::DomainEvent;
|
||||||
|
use domain::models::User;
|
||||||
|
use domain::{DomainResult, Email, Password};
|
||||||
|
|
||||||
|
use super::commands::RegisterCommand;
|
||||||
|
use super::deps::AuthDeps;
|
||||||
|
|
||||||
|
pub async fn execute(deps: &AuthDeps, cmd: RegisterCommand) -> DomainResult<User> {
|
||||||
|
let email = Email::new(&cmd.email)?;
|
||||||
|
let password = Password::new(&cmd.password)?;
|
||||||
|
|
||||||
|
if deps.user_query.find_by_email(email.as_ref()).await?.is_some() {
|
||||||
|
return Err(domain::DomainError::UserAlreadyExists(cmd.email));
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = deps.auth_service.hash_password(password.as_ref())?;
|
||||||
|
|
||||||
|
let mut user = User::new_local(email, hash);
|
||||||
|
if deps.user_query.count_users().await? == 0 {
|
||||||
|
user.promote_to_admin();
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.user_command.save(&user).await?;
|
||||||
|
|
||||||
|
deps.event_publisher
|
||||||
|
.publish(DomainEvent::UserRegistered {
|
||||||
|
user_id: user.id(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/register.rs"]
|
||||||
|
mod tests;
|
||||||
6
crates/application/src/auth/results.rs
Normal file
6
crates/application/src/auth/results.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#[derive(Debug)]
|
||||||
|
pub struct LoginResult {
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: Option<String>,
|
||||||
|
pub expires_in: u64,
|
||||||
|
}
|
||||||
181
crates/application/src/auth/tests/login.rs
Normal file
181
crates/application/src/auth/tests/login.rs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::errors::DomainResult;
|
||||||
|
use domain::ports::AuthService;
|
||||||
|
use domain::testing::{InMemoryUserRepository, NoopEventPublisher, NoopTokenService};
|
||||||
|
use domain::{DomainError, Email};
|
||||||
|
|
||||||
|
use crate::auth::commands::LoginCommand;
|
||||||
|
use crate::auth::deps::AuthDeps;
|
||||||
|
use crate::auth::login;
|
||||||
|
|
||||||
|
struct FakeAuthService;
|
||||||
|
|
||||||
|
impl AuthService for FakeAuthService {
|
||||||
|
fn hash_password(&self, password: &str) -> DomainResult<String> {
|
||||||
|
Ok(format!("hashed:{}", password))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_password(&self, password: &str, hash: &str) -> DomainResult<bool> {
|
||||||
|
Ok(hash == format!("hashed:{}", password))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_deps_with_user(
|
||||||
|
email: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> (AuthDeps, Arc<InMemoryUserRepository>) {
|
||||||
|
let repo = Arc::new(InMemoryUserRepository::new());
|
||||||
|
let e = Email::new(email).unwrap();
|
||||||
|
let user = domain::models::User::new_local(e, password_hash);
|
||||||
|
repo.store.lock().unwrap().insert(user.id(), user);
|
||||||
|
|
||||||
|
let deps = AuthDeps {
|
||||||
|
user_command: repo.clone(),
|
||||||
|
user_query: repo.clone(),
|
||||||
|
auth_service: Arc::new(FakeAuthService),
|
||||||
|
token_service: Arc::new(NoopTokenService::new()),
|
||||||
|
event_publisher: Arc::new(NoopEventPublisher::new()),
|
||||||
|
};
|
||||||
|
(deps, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_succeeds_with_correct_credentials() {
|
||||||
|
let (deps, _) = make_deps_with_user("alice@example.com", "hashed:password123");
|
||||||
|
|
||||||
|
let result = login::execute(
|
||||||
|
&deps,
|
||||||
|
LoginCommand {
|
||||||
|
email: "alice@example.com".into(),
|
||||||
|
password: "password123".into(),
|
||||||
|
remember_me: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!result.access_token.is_empty());
|
||||||
|
assert!(result.refresh_token.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_with_remember_me_returns_refresh_token() {
|
||||||
|
let (deps, _) = make_deps_with_user("alice@example.com", "hashed:password123");
|
||||||
|
|
||||||
|
let result = login::execute(
|
||||||
|
&deps,
|
||||||
|
LoginCommand {
|
||||||
|
email: "alice@example.com".into(),
|
||||||
|
password: "password123".into(),
|
||||||
|
remember_me: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!result.access_token.is_empty());
|
||||||
|
assert!(result.refresh_token.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_fails_with_wrong_password() {
|
||||||
|
let (deps, _) = make_deps_with_user("alice@example.com", "hashed:correct");
|
||||||
|
|
||||||
|
let result = login::execute(
|
||||||
|
&deps,
|
||||||
|
LoginCommand {
|
||||||
|
email: "alice@example.com".into(),
|
||||||
|
password: "wrong".into(),
|
||||||
|
remember_me: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
DomainError::Unauthenticated(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_fails_for_unknown_email() {
|
||||||
|
let (deps, _) = make_deps_with_user("alice@example.com", "hashed:pw");
|
||||||
|
|
||||||
|
let result = login::execute(
|
||||||
|
&deps,
|
||||||
|
LoginCommand {
|
||||||
|
email: "nobody@example.com".into(),
|
||||||
|
password: "password123".into(),
|
||||||
|
remember_me: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
DomainError::Unauthenticated(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_fails_for_oidc_only_user() {
|
||||||
|
let repo = Arc::new(InMemoryUserRepository::new());
|
||||||
|
let email = Email::new("oidc@example.com").unwrap();
|
||||||
|
let user = domain::models::User::new("oidc|subject", email);
|
||||||
|
repo.store.lock().unwrap().insert(user.id(), user);
|
||||||
|
|
||||||
|
let deps = AuthDeps {
|
||||||
|
user_command: repo.clone(),
|
||||||
|
user_query: repo.clone(),
|
||||||
|
auth_service: Arc::new(FakeAuthService),
|
||||||
|
token_service: Arc::new(NoopTokenService::new()),
|
||||||
|
event_publisher: Arc::new(NoopEventPublisher::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = login::execute(
|
||||||
|
&deps,
|
||||||
|
LoginCommand {
|
||||||
|
email: "oidc@example.com".into(),
|
||||||
|
password: "password123".into(),
|
||||||
|
remember_me: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
DomainError::Unauthenticated(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_rejects_invalid_email() {
|
||||||
|
let repo = Arc::new(InMemoryUserRepository::new());
|
||||||
|
let deps = AuthDeps {
|
||||||
|
user_command: repo.clone(),
|
||||||
|
user_query: repo.clone(),
|
||||||
|
auth_service: Arc::new(FakeAuthService),
|
||||||
|
token_service: Arc::new(NoopTokenService::new()),
|
||||||
|
event_publisher: Arc::new(NoopEventPublisher::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = login::execute(
|
||||||
|
&deps,
|
||||||
|
LoginCommand {
|
||||||
|
email: "not-an-email".into(),
|
||||||
|
password: "password123".into(),
|
||||||
|
remember_me: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
DomainError::ValidationError(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
165
crates/application/src/auth/tests/register.rs
Normal file
165
crates/application/src/auth/tests/register.rs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::errors::DomainResult;
|
||||||
|
use domain::ports::AuthService;
|
||||||
|
use domain::testing::{InMemoryUserRepository, NoopEventPublisher, NoopTokenService};
|
||||||
|
use domain::{DomainError, Email};
|
||||||
|
|
||||||
|
use crate::auth::commands::RegisterCommand;
|
||||||
|
use crate::auth::deps::AuthDeps;
|
||||||
|
use crate::auth::register;
|
||||||
|
|
||||||
|
/// Fake auth service: prefixes "hashed:" for hashing, verifies by checking prefix.
|
||||||
|
struct FakeAuthService;
|
||||||
|
|
||||||
|
impl AuthService for FakeAuthService {
|
||||||
|
fn hash_password(&self, password: &str) -> DomainResult<String> {
|
||||||
|
Ok(format!("hashed:{}", password))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_password(&self, password: &str, hash: &str) -> DomainResult<bool> {
|
||||||
|
Ok(hash == format!("hashed:{}", password))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_deps() -> (AuthDeps, Arc<InMemoryUserRepository>) {
|
||||||
|
let repo = Arc::new(InMemoryUserRepository::new());
|
||||||
|
let deps = AuthDeps {
|
||||||
|
user_command: repo.clone(),
|
||||||
|
user_query: repo.clone(),
|
||||||
|
auth_service: Arc::new(FakeAuthService),
|
||||||
|
token_service: Arc::new(NoopTokenService::new()),
|
||||||
|
event_publisher: Arc::new(NoopEventPublisher::new()),
|
||||||
|
};
|
||||||
|
(deps, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn registers_new_user() {
|
||||||
|
let (deps, repo) = make_deps();
|
||||||
|
|
||||||
|
let user = register::execute(
|
||||||
|
&deps,
|
||||||
|
RegisterCommand {
|
||||||
|
email: "alice@example.com".into(),
|
||||||
|
password: "password123".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(user.email().as_ref(), "alice@example.com");
|
||||||
|
assert!(user.password_hash().unwrap().starts_with("hashed:"));
|
||||||
|
// First user gets admin
|
||||||
|
assert!(user.is_admin());
|
||||||
|
|
||||||
|
// Verify persisted
|
||||||
|
let stored = repo
|
||||||
|
.store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.cloned()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored.id(), user.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn second_user_is_not_admin() {
|
||||||
|
let (deps, _) = make_deps();
|
||||||
|
|
||||||
|
// First user
|
||||||
|
register::execute(
|
||||||
|
&deps,
|
||||||
|
RegisterCommand {
|
||||||
|
email: "first@example.com".into(),
|
||||||
|
password: "password123".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Second user
|
||||||
|
let user = register::execute(
|
||||||
|
&deps,
|
||||||
|
RegisterCommand {
|
||||||
|
email: "second@example.com".into(),
|
||||||
|
password: "password123".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!user.is_admin());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_fails_for_duplicate_email() {
|
||||||
|
let (deps, repo) = make_deps();
|
||||||
|
|
||||||
|
// Pre-populate with existing user
|
||||||
|
let email = Email::new("taken@example.com").unwrap();
|
||||||
|
let existing = domain::models::User::new_local(email, "existing_hash");
|
||||||
|
repo.store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(existing.id(), existing);
|
||||||
|
|
||||||
|
let result = register::execute(
|
||||||
|
&deps,
|
||||||
|
RegisterCommand {
|
||||||
|
email: "taken@example.com".into(),
|
||||||
|
password: "password123".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
match result.unwrap_err() {
|
||||||
|
DomainError::UserAlreadyExists(email) => {
|
||||||
|
assert_eq!(email, "taken@example.com");
|
||||||
|
}
|
||||||
|
other => panic!("expected UserAlreadyExists, got: {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_rejects_invalid_email() {
|
||||||
|
let (deps, _) = make_deps();
|
||||||
|
|
||||||
|
let result = register::execute(
|
||||||
|
&deps,
|
||||||
|
RegisterCommand {
|
||||||
|
email: "not-an-email".into(),
|
||||||
|
password: "password123".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
DomainError::ValidationError(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_rejects_short_password() {
|
||||||
|
let (deps, _) = make_deps();
|
||||||
|
|
||||||
|
let result = register::execute(
|
||||||
|
&deps,
|
||||||
|
RegisterCommand {
|
||||||
|
email: "valid@example.com".into(),
|
||||||
|
password: "short".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
DomainError::ValidationError(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
24
crates/application/src/channels/commands.rs
Normal file
24
crates/application/src/channels/commands.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use domain::models::ScheduleConfig;
|
||||||
|
use domain::value_objects::{ChannelId, RecyclePolicy, UserId};
|
||||||
|
|
||||||
|
pub struct CreateChannelCommand {
|
||||||
|
pub owner_id: UserId,
|
||||||
|
pub name: String,
|
||||||
|
pub timezone: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UpdateChannelCommand {
|
||||||
|
pub channel_id: ChannelId,
|
||||||
|
pub owner_id: UserId,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub description: Option<Option<String>>,
|
||||||
|
pub timezone: Option<String>,
|
||||||
|
pub schedule_config: Option<ScheduleConfig>,
|
||||||
|
pub recycle_policy: Option<RecyclePolicy>,
|
||||||
|
pub auto_schedule: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DeleteChannelCommand {
|
||||||
|
pub channel_id: ChannelId,
|
||||||
|
pub owner_id: UserId,
|
||||||
|
}
|
||||||
24
crates/application/src/channels/create.rs
Normal file
24
crates/application/src/channels/create.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use domain::events::DomainEvent;
|
||||||
|
use domain::models::Channel;
|
||||||
|
use domain::DomainResult;
|
||||||
|
|
||||||
|
use super::commands::CreateChannelCommand;
|
||||||
|
use super::deps::ChannelCommandDeps;
|
||||||
|
|
||||||
|
pub async fn execute(deps: &ChannelCommandDeps, cmd: CreateChannelCommand) -> DomainResult<Channel> {
|
||||||
|
let channel = Channel::new(cmd.owner_id, cmd.name, cmd.timezone);
|
||||||
|
|
||||||
|
deps.channel_command.save(&channel).await?;
|
||||||
|
|
||||||
|
deps.event_publisher
|
||||||
|
.publish(DomainEvent::ChannelCreated {
|
||||||
|
channel_id: channel.id(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/create.rs"]
|
||||||
|
mod tests;
|
||||||
22
crates/application/src/channels/delete.rs
Normal file
22
crates/application/src/channels/delete.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
use domain::events::DomainEvent;
|
||||||
|
use domain::DomainResult;
|
||||||
|
|
||||||
|
use super::commands::DeleteChannelCommand;
|
||||||
|
use super::deps::ChannelCommandDeps;
|
||||||
|
use super::find_owned_channel;
|
||||||
|
|
||||||
|
pub async fn execute(deps: &ChannelCommandDeps, cmd: DeleteChannelCommand) -> DomainResult<()> {
|
||||||
|
find_owned_channel(deps.channel_query.as_ref(), cmd.channel_id, cmd.owner_id).await?;
|
||||||
|
|
||||||
|
deps.channel_command.delete(cmd.channel_id).await?;
|
||||||
|
|
||||||
|
deps.event_publisher
|
||||||
|
.publish(DomainEvent::ChannelDeleted { channel_id: cmd.channel_id })
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/delete.rs"]
|
||||||
|
mod tests;
|
||||||
13
crates/application/src/channels/deps.rs
Normal file
13
crates/application/src/channels/deps.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::{ChannelCommand, ChannelQuery, EventPublisher};
|
||||||
|
|
||||||
|
pub struct ChannelCommandDeps {
|
||||||
|
pub channel_command: Arc<dyn ChannelCommand>,
|
||||||
|
pub channel_query: Arc<dyn ChannelQuery>,
|
||||||
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ChannelQueryDeps {
|
||||||
|
pub channel_query: Arc<dyn ChannelQuery>,
|
||||||
|
}
|
||||||
13
crates/application/src/channels/get.rs
Normal file
13
crates/application/src/channels/get.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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;
|
||||||
13
crates/application/src/channels/list.rs
Normal file
13
crates/application/src/channels/list.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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;
|
||||||
13
crates/application/src/channels/list_by_owner.rs
Normal file
13
crates/application/src/channels/list_by_owner.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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;
|
||||||
36
crates/application/src/channels/mod.rs
Normal file
36
crates/application/src/channels/mod.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
pub mod commands;
|
||||||
|
pub mod create;
|
||||||
|
pub mod delete;
|
||||||
|
pub mod deps;
|
||||||
|
pub mod get;
|
||||||
|
pub mod list;
|
||||||
|
pub mod list_by_owner;
|
||||||
|
pub mod queries;
|
||||||
|
pub mod update;
|
||||||
|
|
||||||
|
pub use commands::{CreateChannelCommand, DeleteChannelCommand, UpdateChannelCommand};
|
||||||
|
pub use deps::{ChannelCommandDeps, ChannelQueryDeps};
|
||||||
|
pub use queries::{GetChannelQuery, ListByOwnerQuery, ListChannelsQuery};
|
||||||
|
|
||||||
|
use domain::models::Channel;
|
||||||
|
use domain::value_objects::{ChannelId, UserId};
|
||||||
|
use domain::{DomainError, DomainResult};
|
||||||
|
|
||||||
|
const OWNERSHIP_DENIED: &str = "You don't own this channel";
|
||||||
|
|
||||||
|
pub(crate) async fn find_owned_channel(
|
||||||
|
query: &dyn domain::ports::ChannelQuery,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
owner_id: UserId,
|
||||||
|
) -> DomainResult<Channel> {
|
||||||
|
let channel = query
|
||||||
|
.find_by_id(channel_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(DomainError::ChannelNotFound(channel_id))?;
|
||||||
|
|
||||||
|
if channel.owner_id() != owner_id {
|
||||||
|
return Err(DomainError::forbidden(OWNERSHIP_DENIED));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(channel)
|
||||||
|
}
|
||||||
11
crates/application/src/channels/queries.rs
Normal file
11
crates/application/src/channels/queries.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
use domain::value_objects::{ChannelId, UserId};
|
||||||
|
|
||||||
|
pub struct GetChannelQuery {
|
||||||
|
pub channel_id: ChannelId,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ListChannelsQuery;
|
||||||
|
|
||||||
|
pub struct ListByOwnerQuery {
|
||||||
|
pub owner_id: UserId,
|
||||||
|
}
|
||||||
65
crates/application/src/channels/tests/create.rs
Normal file
65
crates/application/src/channels/tests/create.rs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::testing::{InMemoryChannelRepository, NoopEventPublisher};
|
||||||
|
use domain::value_objects::UserId;
|
||||||
|
|
||||||
|
use crate::channels::commands::CreateChannelCommand;
|
||||||
|
use crate::channels::create;
|
||||||
|
use crate::channels::deps::ChannelCommandDeps;
|
||||||
|
|
||||||
|
fn make_deps() -> (ChannelCommandDeps, Arc<InMemoryChannelRepository>) {
|
||||||
|
let repo = Arc::new(InMemoryChannelRepository::new());
|
||||||
|
let deps = ChannelCommandDeps {
|
||||||
|
channel_command: repo.clone(),
|
||||||
|
channel_query: repo.clone(),
|
||||||
|
event_publisher: Arc::new(NoopEventPublisher::new()),
|
||||||
|
};
|
||||||
|
(deps, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn creates_channel_successfully() {
|
||||||
|
let (deps, repo) = make_deps();
|
||||||
|
let owner = UserId::generate();
|
||||||
|
|
||||||
|
let channel = create::execute(
|
||||||
|
&deps,
|
||||||
|
CreateChannelCommand {
|
||||||
|
owner_id: owner,
|
||||||
|
name: "Movie Night".into(),
|
||||||
|
timezone: "America/New_York".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(channel.name(), "Movie Night");
|
||||||
|
assert_eq!(channel.timezone(), "America/New_York");
|
||||||
|
assert_eq!(channel.owner_id(), owner);
|
||||||
|
|
||||||
|
// Verify persisted
|
||||||
|
let stored = repo.channels.lock().unwrap();
|
||||||
|
assert_eq!(stored.len(), 1);
|
||||||
|
let persisted = stored.values().next().unwrap();
|
||||||
|
assert_eq!(persisted.id(), channel.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_returns_default_config() {
|
||||||
|
let (deps, _) = make_deps();
|
||||||
|
|
||||||
|
let channel = create::execute(
|
||||||
|
&deps,
|
||||||
|
CreateChannelCommand {
|
||||||
|
owner_id: UserId::generate(),
|
||||||
|
name: "Defaults".into(),
|
||||||
|
timezone: "UTC".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(channel.description().is_none());
|
||||||
|
assert!(!channel.auto_schedule());
|
||||||
|
assert!(channel.schedule_config().day_blocks().is_empty());
|
||||||
|
}
|
||||||
101
crates/application/src/channels/tests/delete.rs
Normal file
101
crates/application/src/channels/tests/delete.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::testing::{InMemoryChannelRepository, NoopEventPublisher};
|
||||||
|
use domain::value_objects::{ChannelId, UserId};
|
||||||
|
use domain::DomainError;
|
||||||
|
|
||||||
|
use crate::channels::commands::{CreateChannelCommand, DeleteChannelCommand};
|
||||||
|
use crate::channels::deps::ChannelCommandDeps;
|
||||||
|
use crate::channels::{create, delete};
|
||||||
|
|
||||||
|
fn make_deps() -> (ChannelCommandDeps, Arc<InMemoryChannelRepository>) {
|
||||||
|
let repo = Arc::new(InMemoryChannelRepository::new());
|
||||||
|
let deps = ChannelCommandDeps {
|
||||||
|
channel_command: repo.clone(),
|
||||||
|
channel_query: repo.clone(),
|
||||||
|
event_publisher: Arc::new(NoopEventPublisher::new()),
|
||||||
|
};
|
||||||
|
(deps, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn deletes_channel_by_owner() {
|
||||||
|
let (deps, repo) = make_deps();
|
||||||
|
let owner = UserId::generate();
|
||||||
|
|
||||||
|
let channel = create::execute(
|
||||||
|
&deps,
|
||||||
|
CreateChannelCommand {
|
||||||
|
owner_id: owner,
|
||||||
|
name: "Doomed".into(),
|
||||||
|
timezone: "UTC".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
delete::execute(
|
||||||
|
&deps,
|
||||||
|
DeleteChannelCommand {
|
||||||
|
channel_id: channel.id(),
|
||||||
|
owner_id: owner,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(repo.channels.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_fails_if_not_owner() {
|
||||||
|
let (deps, _) = make_deps();
|
||||||
|
let owner = UserId::generate();
|
||||||
|
let stranger = UserId::generate();
|
||||||
|
|
||||||
|
let channel = create::execute(
|
||||||
|
&deps,
|
||||||
|
CreateChannelCommand {
|
||||||
|
owner_id: owner,
|
||||||
|
name: "Protected".into(),
|
||||||
|
timezone: "UTC".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = delete::execute(
|
||||||
|
&deps,
|
||||||
|
DeleteChannelCommand {
|
||||||
|
channel_id: channel.id(),
|
||||||
|
owner_id: stranger,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
match result.unwrap_err() {
|
||||||
|
DomainError::Forbidden(_) => {}
|
||||||
|
other => panic!("expected Forbidden, got: {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_nonexistent_channel_returns_not_found() {
|
||||||
|
let (deps, _) = make_deps();
|
||||||
|
|
||||||
|
let result = delete::execute(
|
||||||
|
&deps,
|
||||||
|
DeleteChannelCommand {
|
||||||
|
channel_id: ChannelId::generate(),
|
||||||
|
owner_id: UserId::generate(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
DomainError::ChannelNotFound(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
66
crates/application/src/channels/tests/get.rs
Normal file
66
crates/application/src/channels/tests/get.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
51
crates/application/src/channels/tests/list.rs
Normal file
51
crates/application/src/channels/tests/list.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
83
crates/application/src/channels/tests/list_by_owner.rs
Normal file
83
crates/application/src/channels/tests/list_by_owner.rs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
251
crates/application/src/channels/tests/update.rs
Normal file
251
crates/application/src/channels/tests/update.rs
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::testing::{InMemoryChannelRepository, NoopEventPublisher};
|
||||||
|
use domain::value_objects::{ChannelId, UserId};
|
||||||
|
use domain::DomainError;
|
||||||
|
|
||||||
|
use crate::channels::commands::{CreateChannelCommand, UpdateChannelCommand};
|
||||||
|
use crate::channels::deps::ChannelCommandDeps;
|
||||||
|
use crate::channels::{create, update};
|
||||||
|
|
||||||
|
fn make_deps() -> (ChannelCommandDeps, Arc<InMemoryChannelRepository>) {
|
||||||
|
let repo = Arc::new(InMemoryChannelRepository::new());
|
||||||
|
let deps = ChannelCommandDeps {
|
||||||
|
channel_command: repo.clone(),
|
||||||
|
channel_query: repo.clone(),
|
||||||
|
event_publisher: Arc::new(NoopEventPublisher::new()),
|
||||||
|
};
|
||||||
|
(deps, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn updates_channel_name() {
|
||||||
|
let (deps, _) = make_deps();
|
||||||
|
let owner = UserId::generate();
|
||||||
|
|
||||||
|
let channel = create::execute(
|
||||||
|
&deps,
|
||||||
|
CreateChannelCommand {
|
||||||
|
owner_id: owner,
|
||||||
|
name: "Original".into(),
|
||||||
|
timezone: "UTC".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let updated = update::execute(
|
||||||
|
&deps,
|
||||||
|
UpdateChannelCommand {
|
||||||
|
channel_id: channel.id(),
|
||||||
|
owner_id: owner,
|
||||||
|
name: Some("Renamed".into()),
|
||||||
|
description: None,
|
||||||
|
timezone: None,
|
||||||
|
schedule_config: None,
|
||||||
|
recycle_policy: None,
|
||||||
|
auto_schedule: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(updated.name(), "Renamed");
|
||||||
|
assert_eq!(updated.timezone(), "UTC"); // unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_fails_if_not_owner() {
|
||||||
|
let (deps, _) = make_deps();
|
||||||
|
let owner = UserId::generate();
|
||||||
|
let stranger = UserId::generate();
|
||||||
|
|
||||||
|
let channel = create::execute(
|
||||||
|
&deps,
|
||||||
|
CreateChannelCommand {
|
||||||
|
owner_id: owner,
|
||||||
|
name: "Protected".into(),
|
||||||
|
timezone: "UTC".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = update::execute(
|
||||||
|
&deps,
|
||||||
|
UpdateChannelCommand {
|
||||||
|
channel_id: channel.id(),
|
||||||
|
owner_id: stranger,
|
||||||
|
name: Some("Hacked".into()),
|
||||||
|
description: None,
|
||||||
|
timezone: None,
|
||||||
|
schedule_config: None,
|
||||||
|
recycle_policy: None,
|
||||||
|
auto_schedule: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
match result.unwrap_err() {
|
||||||
|
DomainError::Forbidden(_) => {}
|
||||||
|
other => panic!("expected Forbidden, got: {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_nonexistent_channel_returns_not_found() {
|
||||||
|
let (deps, _) = make_deps();
|
||||||
|
|
||||||
|
let result = update::execute(
|
||||||
|
&deps,
|
||||||
|
UpdateChannelCommand {
|
||||||
|
channel_id: ChannelId::generate(),
|
||||||
|
owner_id: UserId::generate(),
|
||||||
|
name: Some("Ghost".into()),
|
||||||
|
description: None,
|
||||||
|
timezone: None,
|
||||||
|
schedule_config: None,
|
||||||
|
recycle_policy: None,
|
||||||
|
auto_schedule: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
DomainError::ChannelNotFound(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_config_creates_snapshot() {
|
||||||
|
let (deps, repo) = make_deps();
|
||||||
|
let owner = UserId::generate();
|
||||||
|
|
||||||
|
let channel = create::execute(
|
||||||
|
&deps,
|
||||||
|
CreateChannelCommand {
|
||||||
|
owner_id: owner,
|
||||||
|
name: "Snapshotted".into(),
|
||||||
|
timezone: "UTC".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Update with new schedule_config
|
||||||
|
let new_config = domain::models::ScheduleConfig::default();
|
||||||
|
update::execute(
|
||||||
|
&deps,
|
||||||
|
UpdateChannelCommand {
|
||||||
|
channel_id: channel.id(),
|
||||||
|
owner_id: owner,
|
||||||
|
name: None,
|
||||||
|
description: None,
|
||||||
|
timezone: None,
|
||||||
|
schedule_config: Some(new_config),
|
||||||
|
recycle_policy: None,
|
||||||
|
auto_schedule: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Verify a config snapshot was created
|
||||||
|
let snapshots = repo.snapshots.lock().unwrap();
|
||||||
|
assert_eq!(snapshots.len(), 1);
|
||||||
|
assert_eq!(snapshots[0].channel_id(), channel.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_without_config_skips_snapshot() {
|
||||||
|
let (deps, repo) = make_deps();
|
||||||
|
let owner = UserId::generate();
|
||||||
|
|
||||||
|
let channel = create::execute(
|
||||||
|
&deps,
|
||||||
|
CreateChannelCommand {
|
||||||
|
owner_id: owner,
|
||||||
|
name: "NoSnapshot".into(),
|
||||||
|
timezone: "UTC".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Update name only — no config change
|
||||||
|
update::execute(
|
||||||
|
&deps,
|
||||||
|
UpdateChannelCommand {
|
||||||
|
channel_id: channel.id(),
|
||||||
|
owner_id: owner,
|
||||||
|
name: Some("Renamed".into()),
|
||||||
|
description: None,
|
||||||
|
timezone: None,
|
||||||
|
schedule_config: None,
|
||||||
|
recycle_policy: None,
|
||||||
|
auto_schedule: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// No snapshot should exist
|
||||||
|
let snapshots = repo.snapshots.lock().unwrap();
|
||||||
|
assert!(snapshots.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_description_clear() {
|
||||||
|
let (deps, _) = make_deps();
|
||||||
|
let owner = UserId::generate();
|
||||||
|
|
||||||
|
let channel = create::execute(
|
||||||
|
&deps,
|
||||||
|
CreateChannelCommand {
|
||||||
|
owner_id: owner,
|
||||||
|
name: "Desc Test".into(),
|
||||||
|
timezone: "UTC".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Set description
|
||||||
|
let updated = update::execute(
|
||||||
|
&deps,
|
||||||
|
UpdateChannelCommand {
|
||||||
|
channel_id: channel.id(),
|
||||||
|
owner_id: owner,
|
||||||
|
name: None,
|
||||||
|
description: Some(Some("A description".into())),
|
||||||
|
timezone: None,
|
||||||
|
schedule_config: None,
|
||||||
|
recycle_policy: None,
|
||||||
|
auto_schedule: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(updated.description(), Some("A description"));
|
||||||
|
|
||||||
|
// Clear description with Some(None)
|
||||||
|
let cleared = update::execute(
|
||||||
|
&deps,
|
||||||
|
UpdateChannelCommand {
|
||||||
|
channel_id: channel.id(),
|
||||||
|
owner_id: owner,
|
||||||
|
name: None,
|
||||||
|
description: Some(None),
|
||||||
|
timezone: None,
|
||||||
|
schedule_config: None,
|
||||||
|
recycle_policy: None,
|
||||||
|
auto_schedule: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(cleared.description().is_none());
|
||||||
|
}
|
||||||
52
crates/application/src/channels/update.rs
Normal file
52
crates/application/src/channels/update.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use domain::events::DomainEvent;
|
||||||
|
use domain::models::Channel;
|
||||||
|
use domain::DomainResult;
|
||||||
|
|
||||||
|
use super::commands::UpdateChannelCommand;
|
||||||
|
use super::deps::ChannelCommandDeps;
|
||||||
|
use super::find_owned_channel;
|
||||||
|
|
||||||
|
pub async fn execute(deps: &ChannelCommandDeps, cmd: UpdateChannelCommand) -> DomainResult<Channel> {
|
||||||
|
let mut channel =
|
||||||
|
find_owned_channel(deps.channel_query.as_ref(), cmd.channel_id, cmd.owner_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if cmd.schedule_config.is_some() {
|
||||||
|
deps.channel_command
|
||||||
|
.save_config_snapshot(cmd.channel_id, channel.schedule_config(), None)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(name) = cmd.name {
|
||||||
|
channel.set_name(name);
|
||||||
|
}
|
||||||
|
if let Some(description) = cmd.description {
|
||||||
|
channel.set_description(description);
|
||||||
|
}
|
||||||
|
if let Some(timezone) = cmd.timezone {
|
||||||
|
channel.set_timezone(timezone);
|
||||||
|
}
|
||||||
|
if let Some(config) = cmd.schedule_config {
|
||||||
|
channel.set_schedule_config(config);
|
||||||
|
}
|
||||||
|
if let Some(policy) = cmd.recycle_policy {
|
||||||
|
channel.set_recycle_policy(policy);
|
||||||
|
}
|
||||||
|
if let Some(auto) = cmd.auto_schedule {
|
||||||
|
channel.set_auto_schedule(auto);
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.channel_command.save(&channel).await?;
|
||||||
|
|
||||||
|
deps.event_publisher
|
||||||
|
.publish(DomainEvent::ChannelUpdated {
|
||||||
|
channel_id: channel.id(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/update.rs"]
|
||||||
|
mod tests;
|
||||||
9
crates/application/src/config/deps.rs
Normal file
9
crates/application/src/config/deps.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::IProviderRegistry;
|
||||||
|
|
||||||
|
pub struct ConfigDeps {
|
||||||
|
pub provider_registry: Arc<dyn IProviderRegistry>,
|
||||||
|
pub allow_registration: bool,
|
||||||
|
pub available_provider_types: Vec<String>,
|
||||||
|
}
|
||||||
55
crates/application/src/config/get_config.rs
Normal file
55
crates/application/src/config/get_config.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
use domain::ports::ProviderCapabilities;
|
||||||
|
|
||||||
|
use super::deps::ConfigDeps;
|
||||||
|
use super::queries::GetConfigQuery;
|
||||||
|
|
||||||
|
pub struct ProviderInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub capabilities: ProviderCapabilities,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SystemConfig {
|
||||||
|
pub allow_registration: bool,
|
||||||
|
pub providers: Vec<ProviderInfo>,
|
||||||
|
pub primary_capabilities: ProviderCapabilities,
|
||||||
|
pub available_provider_types: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute(deps: &ConfigDeps, _query: GetConfigQuery) -> SystemConfig {
|
||||||
|
let provider_ids = deps.provider_registry.provider_ids();
|
||||||
|
let primary_id = deps.provider_registry.primary_id().to_string();
|
||||||
|
|
||||||
|
let providers: Vec<ProviderInfo> = provider_ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| {
|
||||||
|
deps.provider_registry
|
||||||
|
.capabilities(id)
|
||||||
|
.map(|caps| ProviderInfo {
|
||||||
|
id: id.clone(),
|
||||||
|
capabilities: caps,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let primary_capabilities = deps
|
||||||
|
.provider_registry
|
||||||
|
.capabilities(&primary_id)
|
||||||
|
.unwrap_or(ProviderCapabilities {
|
||||||
|
collections: false,
|
||||||
|
series: false,
|
||||||
|
genres: false,
|
||||||
|
tags: false,
|
||||||
|
decade: false,
|
||||||
|
search: false,
|
||||||
|
streaming_protocol: domain::ports::StreamingProtocol::DirectFile,
|
||||||
|
rescan: false,
|
||||||
|
transcode: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
SystemConfig {
|
||||||
|
allow_registration: deps.allow_registration,
|
||||||
|
providers,
|
||||||
|
primary_capabilities,
|
||||||
|
available_provider_types: deps.available_provider_types.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
6
crates/application/src/config/mod.rs
Normal file
6
crates/application/src/config/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod deps;
|
||||||
|
pub mod get_config;
|
||||||
|
pub mod queries;
|
||||||
|
|
||||||
|
pub use deps::ConfigDeps;
|
||||||
|
pub use queries::GetConfigQuery;
|
||||||
1
crates/application/src/config/queries.rs
Normal file
1
crates/application/src/config/queries.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub struct GetConfigQuery;
|
||||||
17
crates/application/src/config_snapshots/commands.rs
Normal file
17
crates/application/src/config_snapshots/commands.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use domain::value_objects::{ChannelId, SnapshotId};
|
||||||
|
|
||||||
|
pub struct SaveSnapshotCommand {
|
||||||
|
pub channel_id: ChannelId,
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PatchLabelCommand {
|
||||||
|
pub channel_id: ChannelId,
|
||||||
|
pub snapshot_id: SnapshotId,
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RestoreSnapshotCommand {
|
||||||
|
pub channel_id: ChannelId,
|
||||||
|
pub snapshot_id: SnapshotId,
|
||||||
|
}
|
||||||
8
crates/application/src/config_snapshots/deps.rs
Normal file
8
crates/application/src/config_snapshots/deps.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::{ChannelCommand, ChannelQuery};
|
||||||
|
|
||||||
|
pub struct ConfigSnapshotDeps {
|
||||||
|
pub channel_command: Arc<dyn ChannelCommand>,
|
||||||
|
pub channel_query: Arc<dyn ChannelQuery>,
|
||||||
|
}
|
||||||
14
crates/application/src/config_snapshots/get.rs
Normal file
14
crates/application/src/config_snapshots/get.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
16
crates/application/src/config_snapshots/list.rs
Normal file
16
crates/application/src/config_snapshots/list.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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;
|
||||||
12
crates/application/src/config_snapshots/mod.rs
Normal file
12
crates/application/src/config_snapshots/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
pub mod commands;
|
||||||
|
pub mod deps;
|
||||||
|
pub mod get;
|
||||||
|
pub mod list;
|
||||||
|
pub mod patch_label;
|
||||||
|
pub mod queries;
|
||||||
|
pub mod restore;
|
||||||
|
pub mod save;
|
||||||
|
|
||||||
|
pub use commands::{PatchLabelCommand, RestoreSnapshotCommand, SaveSnapshotCommand};
|
||||||
|
pub use deps::ConfigSnapshotDeps;
|
||||||
|
pub use queries::{GetSnapshotQuery, ListSnapshotsQuery};
|
||||||
14
crates/application/src/config_snapshots/patch_label.rs
Normal file
14
crates/application/src/config_snapshots/patch_label.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
10
crates/application/src/config_snapshots/queries.rs
Normal file
10
crates/application/src/config_snapshots/queries.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
use domain::value_objects::{ChannelId, SnapshotId};
|
||||||
|
|
||||||
|
pub struct ListSnapshotsQuery {
|
||||||
|
pub channel_id: ChannelId,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GetSnapshotQuery {
|
||||||
|
pub channel_id: ChannelId,
|
||||||
|
pub snapshot_id: SnapshotId,
|
||||||
|
}
|
||||||
34
crates/application/src/config_snapshots/restore.rs
Normal file
34
crates/application/src/config_snapshots/restore.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use domain::models::Channel;
|
||||||
|
use domain::{DomainError, DomainResult};
|
||||||
|
|
||||||
|
use super::commands::RestoreSnapshotCommand;
|
||||||
|
use super::deps::ConfigSnapshotDeps;
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &ConfigSnapshotDeps,
|
||||||
|
cmd: RestoreSnapshotCommand,
|
||||||
|
) -> DomainResult<Channel> {
|
||||||
|
let snapshot = deps
|
||||||
|
.channel_query
|
||||||
|
.get_config_snapshot(cmd.channel_id, cmd.snapshot_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(DomainError::ValidationError(format!(
|
||||||
|
"Snapshot {} not found",
|
||||||
|
cmd.snapshot_id
|
||||||
|
)))?;
|
||||||
|
|
||||||
|
let mut channel = deps
|
||||||
|
.channel_query
|
||||||
|
.find_by_id(cmd.channel_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(DomainError::ChannelNotFound(cmd.channel_id))?;
|
||||||
|
|
||||||
|
deps.channel_command
|
||||||
|
.save_config_snapshot(cmd.channel_id, channel.schedule_config(), None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
channel.set_schedule_config(snapshot.config().clone());
|
||||||
|
deps.channel_command.save(&channel).await?;
|
||||||
|
|
||||||
|
Ok(channel)
|
||||||
|
}
|
||||||
24
crates/application/src/config_snapshots/save.rs
Normal file
24
crates/application/src/config_snapshots/save.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use domain::models::ChannelConfigSnapshot;
|
||||||
|
use domain::{DomainError, DomainResult};
|
||||||
|
|
||||||
|
use super::commands::SaveSnapshotCommand;
|
||||||
|
use super::deps::ConfigSnapshotDeps;
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &ConfigSnapshotDeps,
|
||||||
|
cmd: SaveSnapshotCommand,
|
||||||
|
) -> DomainResult<ChannelConfigSnapshot> {
|
||||||
|
let channel = deps
|
||||||
|
.channel_query
|
||||||
|
.find_by_id(cmd.channel_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(DomainError::ChannelNotFound(cmd.channel_id))?;
|
||||||
|
|
||||||
|
deps.channel_command
|
||||||
|
.save_config_snapshot(cmd.channel_id, channel.schedule_config(), cmd.label)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/save.rs"]
|
||||||
|
mod tests;
|
||||||
77
crates/application/src/config_snapshots/tests/list.rs
Normal file
77
crates/application/src/config_snapshots/tests/list.rs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
75
crates/application/src/config_snapshots/tests/save.rs
Normal file
75
crates/application/src/config_snapshots/tests/save.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::models::Channel;
|
||||||
|
use domain::testing::InMemoryChannelRepository;
|
||||||
|
use domain::value_objects::UserId;
|
||||||
|
|
||||||
|
use crate::config_snapshots::commands::SaveSnapshotCommand;
|
||||||
|
use crate::config_snapshots::deps::ConfigSnapshotDeps;
|
||||||
|
use crate::config_snapshots::save;
|
||||||
|
|
||||||
|
fn make_deps() -> (ConfigSnapshotDeps, Arc<InMemoryChannelRepository>) {
|
||||||
|
let repo = Arc::new(InMemoryChannelRepository::new());
|
||||||
|
let deps = ConfigSnapshotDeps {
|
||||||
|
channel_command: repo.clone(),
|
||||||
|
channel_query: repo.clone(),
|
||||||
|
};
|
||||||
|
(deps, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_channel(repo: &InMemoryChannelRepository) -> Channel {
|
||||||
|
let channel = Channel::new(UserId::generate(), "Test Channel", "UTC");
|
||||||
|
repo.channels
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(channel.id(), channel.clone());
|
||||||
|
channel
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_creates_snapshot() {
|
||||||
|
let (deps, repo) = make_deps();
|
||||||
|
let channel = seed_channel(&repo).await;
|
||||||
|
|
||||||
|
let snap = save::execute(
|
||||||
|
&deps,
|
||||||
|
SaveSnapshotCommand {
|
||||||
|
channel_id: channel.id(),
|
||||||
|
label: Some("v1".into()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(snap.channel_id(), channel.id());
|
||||||
|
assert_eq!(snap.label(), Some("v1"));
|
||||||
|
assert_eq!(snap.version_num(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_increments_version() {
|
||||||
|
let (deps, repo) = make_deps();
|
||||||
|
let channel = seed_channel(&repo).await;
|
||||||
|
|
||||||
|
save::execute(
|
||||||
|
&deps,
|
||||||
|
SaveSnapshotCommand {
|
||||||
|
channel_id: channel.id(),
|
||||||
|
label: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let snap2 = save::execute(
|
||||||
|
&deps,
|
||||||
|
SaveSnapshotCommand {
|
||||||
|
channel_id: channel.id(),
|
||||||
|
label: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(snap2.version_num(), 2);
|
||||||
|
}
|
||||||
8
crates/application/src/iptv/deps.rs
Normal file
8
crates/application/src/iptv/deps.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::{ChannelQuery, ScheduleQuery};
|
||||||
|
|
||||||
|
pub struct IptvDeps {
|
||||||
|
pub channel_query: Arc<dyn ChannelQuery>,
|
||||||
|
pub schedule_query: Arc<dyn ScheduleQuery>,
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user