1387 lines
46 KiB
Markdown
1387 lines
46 KiB
Markdown
# K-TV Backend Restructure Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Restructure k-tv-backend from 3-crate layout (domain/infra/api) to proper DDD hexagonal architecture with `crates/` directory, matching movies-diary reference project.
|
|
|
|
**Architecture:** Bottom-up migration — build new crates under `/mnt/drive/dev/k-tv/crates/`, layer by layer: domain → application → api-types → infra-wiring → adapters → presentation → mcp. Old `k-tv-backend/` stays untouched as reference. Each task must compile before moving to the next.
|
|
|
|
**Tech Stack:** Rust (edition 2024), Axum 0.8, SQLx, Tokio, Serde, chrono-tz, utoipa (OpenAPI), async-trait, thiserror, uuid
|
|
|
|
## Global Constraints
|
|
|
|
- Rust edition 2024 for all crates
|
|
- No `k-core` dependency — inline what's needed
|
|
- Domain crate: zero I/O deps (no sqlx, no reqwest, no tokio runtime)
|
|
- All entity fields private with `new()`, `from_persistence()`, getters
|
|
- ID types are newtypes via `uuid_id!` macro (not type aliases)
|
|
- Ports split CQRS: separate Command (write) and Query (read) traits
|
|
- Application use cases: free `async fn execute(deps, cmd)` functions
|
|
- Test doubles (InMemory + Noops) in domain behind `test-helpers` feature
|
|
- Tests in sibling `tests/` directories via `#[cfg(test)] #[path = "tests/foo.rs"] mod tests;`
|
|
- API types use `utoipa::ToSchema` for OpenAPI generation
|
|
- Reference code lives at `/mnt/drive/dev/k-tv/k-tv-backend/` — read it, don't modify it
|
|
- Reference architecture lives at `/mnt/drive/dev/movies-diary/` — follow its patterns
|
|
- When creating a new crate, add it to `[workspace] members` in root `Cargo.toml`
|
|
- Old `k-tv-backend/` has its own `Cargo.toml` workspace — do NOT conflict with the new root workspace
|
|
|
|
---
|
|
|
|
### Task 1: Workspace scaffold + domain foundation (errors, uuid_id macro, ID types)
|
|
|
|
**Files:**
|
|
- Create: `Cargo.toml` (workspace root at `/mnt/drive/dev/k-tv/Cargo.toml`)
|
|
- Create: `crates/domain/Cargo.toml`
|
|
- Create: `crates/domain/src/lib.rs`
|
|
- Create: `crates/domain/src/errors/mod.rs`
|
|
- Create: `crates/domain/src/value_objects/mod.rs`
|
|
- Create: `crates/domain/src/value_objects/ids.rs`
|
|
|
|
**Interfaces:**
|
|
- Produces: `DomainError`, `DomainResult<T>`, `uuid_id!` macro, `UserId`, `ChannelId`, `SlotId`, `BlockId`, `ScheduleId`, `MediaItemId`
|
|
|
|
- [ ] **Step 1: Create workspace root Cargo.toml**
|
|
|
|
Create `/mnt/drive/dev/k-tv/Cargo.toml`. Note: start with only `crates/domain` as a member. Add other members as their crates are created in later tasks. The glob `crates/adapters/*` only works once adapter dirs exist.
|
|
|
|
```toml
|
|
[workspace]
|
|
members = ["crates/domain"]
|
|
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-postgres = { path = "crates/adapters/postgres" }
|
|
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" }
|
|
```
|
|
|
|
- [ ] **Step 2: Create domain crate Cargo.toml**
|
|
|
|
Create `crates/domain/Cargo.toml`:
|
|
|
|
```toml
|
|
[package]
|
|
name = "domain"
|
|
version = "0.1.0"
|
|
edition = "2024"
|
|
|
|
[features]
|
|
test-helpers = []
|
|
|
|
[dependencies]
|
|
async-trait = { workspace = true }
|
|
chrono = { workspace = true }
|
|
chrono-tz = { workspace = true }
|
|
email_address = { workspace = true }
|
|
rand = { workspace = true }
|
|
serde = { workspace = true }
|
|
serde_json = { workspace = true }
|
|
thiserror = { workspace = true }
|
|
url = { workspace = true }
|
|
uuid = { workspace = true }
|
|
|
|
[dev-dependencies]
|
|
tokio = { workspace = true }
|
|
```
|
|
|
|
- [ ] **Step 3: Create errors module**
|
|
|
|
Create `crates/domain/src/errors/mod.rs` — copy from `k-tv-backend/domain/src/errors.rs` but change `Uuid` params to use the new ID newtypes where applicable. For now, keep `Uuid` since IDs aren't defined yet:
|
|
|
|
```rust
|
|
use thiserror::Error;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Debug, Error)]
|
|
#[non_exhaustive]
|
|
pub enum DomainError {
|
|
#[error("User not found: {0}")]
|
|
UserNotFound(Uuid),
|
|
|
|
#[error("User already exists: {0}")]
|
|
UserAlreadyExists(String),
|
|
|
|
#[error("Channel not found: {0}")]
|
|
ChannelNotFound(Uuid),
|
|
|
|
#[error("No active schedule for channel: {0}")]
|
|
NoActiveSchedule(Uuid),
|
|
|
|
#[error("Validation error: {0}")]
|
|
ValidationError(String),
|
|
|
|
#[error("Invalid timezone: {0}")]
|
|
TimezoneError(String),
|
|
|
|
#[error("Unauthenticated: {0}")]
|
|
Unauthenticated(String),
|
|
|
|
#[error("Forbidden: {0}")]
|
|
Forbidden(String),
|
|
|
|
#[error("Repository error: {0}")]
|
|
RepositoryError(String),
|
|
|
|
#[error("Infrastructure error: {0}")]
|
|
InfrastructureError(String),
|
|
}
|
|
|
|
impl DomainError {
|
|
pub fn validation(message: impl Into<String>) -> Self {
|
|
Self::ValidationError(message.into())
|
|
}
|
|
|
|
pub fn unauthenticated(message: impl Into<String>) -> Self {
|
|
Self::Unauthenticated(message.into())
|
|
}
|
|
|
|
pub fn forbidden(message: impl Into<String>) -> Self {
|
|
Self::Forbidden(message.into())
|
|
}
|
|
|
|
pub fn is_not_found(&self) -> bool {
|
|
matches!(self, DomainError::UserNotFound(_) | DomainError::ChannelNotFound(_))
|
|
}
|
|
|
|
pub fn is_conflict(&self) -> bool {
|
|
matches!(self, DomainError::UserAlreadyExists(_))
|
|
}
|
|
}
|
|
|
|
pub type DomainResult<T> = Result<T, DomainError>;
|
|
```
|
|
|
|
- [ ] **Step 4: Create uuid_id macro and ID types**
|
|
|
|
Create `crates/domain/src/value_objects/ids.rs`:
|
|
|
|
```rust
|
|
use uuid::Uuid;
|
|
|
|
macro_rules! uuid_id {
|
|
($name:ident) => {
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
|
pub struct $name(Uuid);
|
|
|
|
impl $name {
|
|
pub fn generate() -> Self {
|
|
Self(Uuid::new_v4())
|
|
}
|
|
|
|
pub fn from_uuid(uuid: Uuid) -> Self {
|
|
Self(uuid)
|
|
}
|
|
|
|
pub fn value(&self) -> Uuid {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for $name {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.0)
|
|
}
|
|
}
|
|
|
|
impl std::str::FromStr for $name {
|
|
type Err = uuid::Error;
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
Ok(Self(s.parse()?))
|
|
}
|
|
}
|
|
|
|
impl From<Uuid> for $name {
|
|
fn from(uuid: Uuid) -> Self {
|
|
Self(uuid)
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
pub(crate) use uuid_id;
|
|
|
|
uuid_id!(UserId);
|
|
uuid_id!(ChannelId);
|
|
uuid_id!(SlotId);
|
|
uuid_id!(BlockId);
|
|
uuid_id!(ScheduleId);
|
|
|
|
/// Opaque media item identifier — format is provider-specific.
|
|
/// The domain never inspects the string; it just passes it back to the provider.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
|
pub struct MediaItemId(String);
|
|
|
|
impl MediaItemId {
|
|
pub fn new(value: impl Into<String>) -> Self {
|
|
Self(value.into())
|
|
}
|
|
|
|
pub fn into_inner(self) -> String {
|
|
self.0
|
|
}
|
|
|
|
pub fn value(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl AsRef<str> for MediaItemId {
|
|
fn as_ref(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for MediaItemId {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.0)
|
|
}
|
|
}
|
|
|
|
impl From<String> for MediaItemId {
|
|
fn from(s: String) -> Self {
|
|
Self(s)
|
|
}
|
|
}
|
|
|
|
impl From<&str> for MediaItemId {
|
|
fn from(s: &str) -> Self {
|
|
Self(s.to_string())
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Create value_objects mod.rs**
|
|
|
|
Create `crates/domain/src/value_objects/mod.rs`:
|
|
|
|
```rust
|
|
pub mod ids;
|
|
|
|
pub use ids::*;
|
|
```
|
|
|
|
- [ ] **Step 6: Create lib.rs**
|
|
|
|
Create `crates/domain/src/lib.rs`:
|
|
|
|
```rust
|
|
pub mod errors;
|
|
pub mod value_objects;
|
|
|
|
pub use errors::{DomainError, DomainResult};
|
|
pub use value_objects::*;
|
|
```
|
|
|
|
- [ ] **Step 7: Verify it compiles**
|
|
|
|
Run: `cargo check -p domain`
|
|
Expected: compiles with no errors
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add Cargo.toml crates/domain/
|
|
git commit -m "scaffold workspace + domain foundation (errors, ids, uuid_id macro)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Domain value objects (auth, scheduling, channel, oidc, search)
|
|
|
|
**Files:**
|
|
- Create: `crates/domain/src/value_objects/auth.rs`
|
|
- Create: `crates/domain/src/value_objects/scheduling.rs`
|
|
- Create: `crates/domain/src/value_objects/channel.rs`
|
|
- Create: `crates/domain/src/value_objects/oidc.rs`
|
|
- Create: `crates/domain/src/value_objects/search.rs`
|
|
- Modify: `crates/domain/src/value_objects/mod.rs`
|
|
- Modify: `crates/domain/src/errors/mod.rs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `DomainError`, `DomainResult`, ID types from Task 1
|
|
- Produces: `Email`, `Password`, `ValidationError`, `ContentType`, `MediaFilter`, `FillStrategy`, `RecyclePolicy`, `Weekday`, `AccessMode`, `LogoPosition`, OIDC newtypes, `LibrarySearchFilter`
|
|
|
|
- [ ] **Step 1: Copy and adapt auth.rs**
|
|
|
|
Copy from `k-tv-backend/domain/src/value_objects/auth.rs` into `crates/domain/src/value_objects/auth.rs`. Keep identical — `ValidationError`, `Email`, `Password` with all impls and tests.
|
|
|
|
- [ ] **Step 2: Add `From<ValidationError>` to errors**
|
|
|
|
Add to `crates/domain/src/errors/mod.rs`:
|
|
|
|
```rust
|
|
impl From<crate::value_objects::auth::ValidationError> for DomainError {
|
|
fn from(error: crate::value_objects::auth::ValidationError) -> Self {
|
|
DomainError::ValidationError(error.to_string())
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Copy and adapt scheduling.rs**
|
|
|
|
Copy from `k-tv-backend/domain/src/value_objects/scheduling.rs`. Move `MediaItemId`, `AccessMode`, and `LogoPosition` OUT (they have their own files now). Keep: `ContentType`, `MediaFilter`, `FillStrategy`, `RecyclePolicy`, `Weekday` with all impls and tests.
|
|
|
|
- [ ] **Step 4: Create channel.rs**
|
|
|
|
Create `crates/domain/src/value_objects/channel.rs` with `AccessMode` and `LogoPosition` (moved from scheduling.rs in old code):
|
|
|
|
```rust
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum AccessMode {
|
|
#[default]
|
|
Public,
|
|
PasswordProtected,
|
|
AccountRequired,
|
|
OwnerOnly,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum LogoPosition {
|
|
TopLeft,
|
|
#[default]
|
|
TopRight,
|
|
BottomLeft,
|
|
BottomRight,
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Copy and adapt oidc.rs**
|
|
|
|
Copy from `k-tv-backend/domain/src/value_objects/oidc.rs` into `crates/domain/src/value_objects/oidc.rs`. Keep identical — all OIDC newtypes plus `JwtSecret`.
|
|
|
|
- [ ] **Step 6: Create search.rs**
|
|
|
|
Create `crates/domain/src/value_objects/search.rs` with `LibrarySearchFilter` (moved from `library.rs` in old code since it's a value object):
|
|
|
|
```rust
|
|
use crate::value_objects::ContentType;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct LibrarySearchFilter {
|
|
provider_id: Option<String>,
|
|
content_type: Option<ContentType>,
|
|
series_names: Vec<String>,
|
|
collection_id: Option<String>,
|
|
genres: Vec<String>,
|
|
decade: Option<u16>,
|
|
min_duration_secs: Option<u32>,
|
|
max_duration_secs: Option<u32>,
|
|
search_term: Option<String>,
|
|
season_number: Option<u32>,
|
|
offset: u32,
|
|
limit: u32,
|
|
}
|
|
|
|
impl LibrarySearchFilter {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
// Builder methods
|
|
pub fn with_provider_id(mut self, id: impl Into<String>) -> Self { self.provider_id = Some(id.into()); self }
|
|
pub fn with_content_type(mut self, ct: ContentType) -> Self { self.content_type = Some(ct); self }
|
|
pub fn with_series_names(mut self, names: Vec<String>) -> Self { self.series_names = names; self }
|
|
pub fn with_collection_id(mut self, id: impl Into<String>) -> Self { self.collection_id = Some(id.into()); self }
|
|
pub fn with_genres(mut self, genres: Vec<String>) -> Self { self.genres = genres; self }
|
|
pub fn with_decade(mut self, decade: u16) -> Self { self.decade = Some(decade); self }
|
|
pub fn with_min_duration_secs(mut self, secs: u32) -> Self { self.min_duration_secs = Some(secs); self }
|
|
pub fn with_max_duration_secs(mut self, secs: u32) -> Self { self.max_duration_secs = Some(secs); self }
|
|
pub fn with_search_term(mut self, term: impl Into<String>) -> Self { self.search_term = Some(term.into()); self }
|
|
pub fn with_season_number(mut self, n: u32) -> Self { self.season_number = Some(n); self }
|
|
pub fn with_offset(mut self, offset: u32) -> Self { self.offset = offset; self }
|
|
pub fn with_limit(mut self, limit: u32) -> Self { self.limit = limit; self }
|
|
|
|
// Getters
|
|
pub fn provider_id(&self) -> Option<&str> { self.provider_id.as_deref() }
|
|
pub fn content_type(&self) -> Option<&ContentType> { self.content_type.as_ref() }
|
|
pub fn series_names(&self) -> &[String] { &self.series_names }
|
|
pub fn collection_id(&self) -> Option<&str> { self.collection_id.as_deref() }
|
|
pub fn genres(&self) -> &[String] { &self.genres }
|
|
pub fn decade(&self) -> Option<u16> { self.decade }
|
|
pub fn min_duration_secs(&self) -> Option<u32> { self.min_duration_secs }
|
|
pub fn max_duration_secs(&self) -> Option<u32> { self.max_duration_secs }
|
|
pub fn search_term(&self) -> Option<&str> { self.search_term.as_deref() }
|
|
pub fn season_number(&self) -> Option<u32> { self.season_number }
|
|
pub fn offset(&self) -> u32 { self.offset }
|
|
pub fn limit(&self) -> u32 { self.limit }
|
|
}
|
|
|
|
impl Default for LibrarySearchFilter {
|
|
fn default() -> Self {
|
|
Self {
|
|
provider_id: None,
|
|
content_type: None,
|
|
series_names: vec![],
|
|
collection_id: None,
|
|
genres: vec![],
|
|
decade: None,
|
|
min_duration_secs: None,
|
|
max_duration_secs: None,
|
|
search_term: None,
|
|
season_number: None,
|
|
offset: 0,
|
|
limit: 50,
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: Update value_objects/mod.rs**
|
|
|
|
```rust
|
|
pub mod auth;
|
|
pub mod channel;
|
|
pub mod ids;
|
|
pub mod oidc;
|
|
pub mod scheduling;
|
|
pub mod search;
|
|
|
|
pub use auth::*;
|
|
pub use channel::*;
|
|
pub use ids::*;
|
|
pub use oidc::*;
|
|
pub use scheduling::*;
|
|
pub use search::*;
|
|
```
|
|
|
|
- [ ] **Step 8: Update lib.rs re-exports**
|
|
|
|
Update `crates/domain/src/lib.rs` to re-export the new modules.
|
|
|
|
- [ ] **Step 9: Verify and commit**
|
|
|
|
Run: `cargo check -p domain`
|
|
Expected: compiles
|
|
|
|
```bash
|
|
git add crates/domain/
|
|
git commit -m "domain value objects: auth, scheduling, channel, oidc, search"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Domain models — User, Channel, ScheduleConfig, ProgrammingBlock, BlockContent, MediaItem
|
|
|
|
**Files:**
|
|
- Create: `crates/domain/src/models/mod.rs`
|
|
- Create: `crates/domain/src/models/user.rs`
|
|
- Create: `crates/domain/src/models/channel.rs`
|
|
- Create: `crates/domain/src/models/media.rs`
|
|
- Create: `crates/domain/src/models/collections.rs`
|
|
- Modify: `crates/domain/src/lib.rs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: All value objects from Tasks 1-2
|
|
- Produces: `User`, `Channel`, `ScheduleConfig`, `ScheduleConfigCompat`, `OldScheduleConfig`, `ProgrammingBlock`, `BlockContent`, `MediaItem`, `PlaybackRecord`, `PageParams`, `Paginated<T>`
|
|
|
|
Key transformation: ALL fields become private. Each entity gets `new()` for creation, `from_persistence()` for DB hydration, and getter methods. Read the old `k-tv-backend/domain/src/entities.rs` for the complete field lists. Use new ID newtypes (`UserId`, `ChannelId`, etc.) instead of bare `Uuid`.
|
|
|
|
For `ScheduleConfig`, `ProgrammingBlock`, `BlockContent`: these are Serde-serialized as JSON in the DB. They need `Serialize`/`Deserialize` but fields should still be private with getters. Use `#[serde(into = "...", from = "...")]` or keep serde derives and add getters alongside. Since these are complex nested structures frequently serialized, keep `Serialize`/`Deserialize` derives and add getters.
|
|
|
|
For `MediaItem`: this is embedded in `ScheduledSlot` as JSON. Same approach — derives + getters.
|
|
|
|
Refer to `k-tv-backend/domain/src/entities.rs` for the complete type definitions. Migrate all types and tests.
|
|
|
|
- [ ] **Step 1: Create user.rs with encapsulated User**
|
|
- [ ] **Step 2: Create channel.rs with Channel, ScheduleConfig, ProgrammingBlock, BlockContent, ScheduleConfigCompat**
|
|
- [ ] **Step 3: Create media.rs with MediaItem, PlaybackRecord**
|
|
- [ ] **Step 4: Create collections.rs with PageParams, Paginated<T>**
|
|
- [ ] **Step 5: Create mod.rs with re-exports**
|
|
- [ ] **Step 6: Update lib.rs**
|
|
- [ ] **Step 7: Verify and commit**
|
|
|
|
Run: `cargo check -p domain`
|
|
|
|
```bash
|
|
git add crates/domain/
|
|
git commit -m "domain models: User, Channel, ScheduleConfig, MediaItem, PlaybackRecord"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Domain models — Schedule, Library, ConfigSnapshot, Activity, ProviderConfig
|
|
|
|
**Files:**
|
|
- Create: `crates/domain/src/models/schedule.rs`
|
|
- Create: `crates/domain/src/models/library.rs`
|
|
- Create: `crates/domain/src/models/config_snapshot.rs`
|
|
- Create: `crates/domain/src/models/activity.rs`
|
|
- Create: `crates/domain/src/models/provider_config.rs`
|
|
- Modify: `crates/domain/src/models/mod.rs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: All value objects, `MediaItem`, `ScheduleConfig` from Tasks 1-3
|
|
- Produces: `GeneratedSchedule`, `ScheduledSlot`, `CurrentBroadcast`, `LibraryItem`, `LibraryCollection`, `LibrarySyncResult`, `LibrarySyncLogEntry`, `ShowSummary`, `SeasonSummary`, `ChannelConfigSnapshot`, `ActivityEvent`, `ProviderConfigRow`
|
|
|
|
Read old code from:
|
|
- `k-tv-backend/domain/src/entities.rs` (GeneratedSchedule, ScheduledSlot, CurrentBroadcast, ChannelConfigSnapshot)
|
|
- `k-tv-backend/domain/src/library.rs` (LibraryItem, LibraryCollection, etc.)
|
|
- `k-tv-backend/domain/src/repositories.rs` (ActivityEvent, ProviderConfigRow)
|
|
|
|
All fields private with `new()`, `from_persistence()`, getters.
|
|
|
|
- [ ] **Step 1: Create schedule.rs** — GeneratedSchedule, ScheduledSlot, CurrentBroadcast
|
|
- [ ] **Step 2: Create library.rs** — LibraryItem, LibraryCollection, LibrarySyncResult, LibrarySyncLogEntry, ShowSummary, SeasonSummary
|
|
- [ ] **Step 3: Create config_snapshot.rs** — ChannelConfigSnapshot
|
|
- [ ] **Step 4: Create activity.rs** — ActivityEvent
|
|
- [ ] **Step 5: Create provider_config.rs** — ProviderConfigRow
|
|
- [ ] **Step 6: Update mod.rs re-exports**
|
|
- [ ] **Step 7: Verify and commit**
|
|
|
|
Run: `cargo check -p domain`
|
|
|
|
```bash
|
|
git add crates/domain/
|
|
git commit -m "domain models: schedule, library, config_snapshot, activity, provider_config"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Domain ports — all CQRS-split trait definitions
|
|
|
|
**Files:**
|
|
- Create: `crates/domain/src/ports/mod.rs`
|
|
- Create: `crates/domain/src/ports/auth.rs`
|
|
- Create: `crates/domain/src/ports/user.rs`
|
|
- Create: `crates/domain/src/ports/channel.rs`
|
|
- Create: `crates/domain/src/ports/schedule.rs`
|
|
- Create: `crates/domain/src/ports/library.rs`
|
|
- Create: `crates/domain/src/ports/media.rs`
|
|
- Create: `crates/domain/src/ports/events.rs`
|
|
- Create: `crates/domain/src/ports/settings.rs`
|
|
- Create: `crates/domain/src/ports/activity.rs`
|
|
- Create: `crates/domain/src/ports/provider_config.rs`
|
|
- Create: `crates/domain/src/ports/transcode.rs`
|
|
- Modify: `crates/domain/src/lib.rs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: All models and value objects from Tasks 1-4
|
|
- Produces: `AuthService`, `UserCommand`, `UserQuery`, `ChannelCommand`, `ChannelQuery`, `ScheduleCommand`, `ScheduleQuery`, `LibraryCommand`, `LibraryQuery`, `LibrarySyncAdapter`, `IMediaProvider`, `IProviderRegistry`, `ProviderCapabilities`, `StreamingProtocol`, `StreamQuality`, `Collection`, `SeriesSummary`, `EventPublisher`, `EventConsumer`, `EventHandler`, `AppSettingsRepository`, `ActivityLogRepository`, `ProviderConfigRepository`, `TranscodeSettingsRepository`
|
|
|
|
Read old code from:
|
|
- `k-tv-backend/domain/src/repositories.rs` — split each old trait into Command + Query
|
|
- `k-tv-backend/domain/src/ports.rs` — `IMediaProvider`, `IProviderRegistry`, `ProviderCapabilities`, etc.
|
|
- `k-tv-backend/domain/src/library.rs` — `ILibraryRepository` → split into `LibraryCommand` + `LibraryQuery`, `LibrarySyncAdapter`
|
|
|
|
CQRS split example for `UserRepository`:
|
|
```rust
|
|
// ports/user.rs
|
|
#[async_trait]
|
|
pub trait UserCommand: Send + Sync {
|
|
async fn save(&self, user: &User) -> DomainResult<()>;
|
|
async fn delete(&self, id: UserId) -> DomainResult<()>;
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait UserQuery: Send + Sync {
|
|
async fn find_by_id(&self, id: UserId) -> DomainResult<Option<User>>;
|
|
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>>;
|
|
async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>>;
|
|
async fn count_users(&self) -> DomainResult<u64>;
|
|
}
|
|
```
|
|
|
|
New `AuthService` port (password hashing + verification, abstracted from infra):
|
|
```rust
|
|
// ports/auth.rs
|
|
#[async_trait]
|
|
pub trait AuthService: Send + Sync {
|
|
fn hash_password(&self, password: &str) -> DomainResult<String>;
|
|
fn verify_password(&self, password: &str, hash: &str) -> DomainResult<bool>;
|
|
}
|
|
```
|
|
|
|
New event ports:
|
|
```rust
|
|
// ports/events.rs
|
|
#[async_trait]
|
|
pub trait EventPublisher: Send + Sync {
|
|
async fn publish(&self, event: DomainEvent) -> DomainResult<()>;
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait EventConsumer: Send + Sync {
|
|
async fn subscribe(&self) -> DomainResult<Box<dyn EventStream>>;
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait EventHandler: Send + Sync {
|
|
async fn handle(&self, event: &DomainEvent) -> DomainResult<()>;
|
|
}
|
|
```
|
|
|
|
`media.rs` — copy `IMediaProvider`, `IProviderRegistry`, `ProviderCapabilities`, `StreamingProtocol`, `StreamQuality`, `Collection`, `SeriesSummary` from `k-tv-backend/domain/src/ports.rs`.
|
|
|
|
- [ ] **Step 1: Create auth.rs port**
|
|
- [ ] **Step 2: Create user.rs with UserCommand + UserQuery**
|
|
- [ ] **Step 3: Create channel.rs with ChannelCommand + ChannelQuery**
|
|
- [ ] **Step 4: Create schedule.rs with ScheduleCommand + ScheduleQuery**
|
|
- [ ] **Step 5: Create library.rs with LibraryCommand + LibraryQuery + LibrarySyncAdapter**
|
|
- [ ] **Step 6: Create media.rs with IMediaProvider, IProviderRegistry, etc.**
|
|
- [ ] **Step 7: Create events.rs with EventPublisher, EventConsumer, EventHandler**
|
|
- [ ] **Step 8: Create settings.rs, activity.rs, provider_config.rs, transcode.rs**
|
|
- [ ] **Step 9: Create mod.rs with re-exports**
|
|
- [ ] **Step 10: Update lib.rs**
|
|
- [ ] **Step 11: Verify and commit**
|
|
|
|
Run: `cargo check -p domain`
|
|
|
|
```bash
|
|
git add crates/domain/
|
|
git commit -m "domain ports: CQRS-split traits for all bounded contexts"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Domain events
|
|
|
|
**Files:**
|
|
- Create: `crates/domain/src/events/mod.rs`
|
|
- Modify: `crates/domain/src/lib.rs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: ID types, models from prior tasks
|
|
- Produces: `DomainEvent` enum
|
|
|
|
Read from `k-tv-backend/domain/src/events.rs`. Adapt to use new ID newtypes. The events should reference IDs, not full entities (keeps Clone cheap):
|
|
|
|
```rust
|
|
#[derive(Clone, Debug)]
|
|
pub enum DomainEvent {
|
|
BroadcastTransition { channel_id: ChannelId, slot_id: SlotId },
|
|
NoSignal { channel_id: ChannelId },
|
|
ScheduleGenerated { channel_id: ChannelId, schedule_id: ScheduleId },
|
|
ChannelCreated { channel_id: ChannelId },
|
|
ChannelUpdated { channel_id: ChannelId },
|
|
ChannelDeleted { channel_id: ChannelId },
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 1: Create events/mod.rs**
|
|
- [ ] **Step 2: Update lib.rs**
|
|
- [ ] **Step 3: Verify and commit**
|
|
|
|
```bash
|
|
git add crates/domain/
|
|
git commit -m "domain events: DomainEvent enum"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Domain services — ScheduleEngineService, fill strategies, IPTV
|
|
|
|
**Files:**
|
|
- Create: `crates/domain/src/services/mod.rs`
|
|
- Create: `crates/domain/src/services/schedule/mod.rs`
|
|
- Create: `crates/domain/src/services/schedule/fill.rs`
|
|
- Create: `crates/domain/src/services/schedule/recycle.rs`
|
|
- Create: `crates/domain/src/services/iptv.rs`
|
|
- Modify: `crates/domain/src/lib.rs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: All models, ports, value objects
|
|
- Produces: `ScheduleEngineService`, `fill_block()`, `apply_recycle_policy()`, `generate_m3u()`, `generate_xmltv()`
|
|
|
|
Read from:
|
|
- `k-tv-backend/domain/src/services/schedule/mod.rs` (392 lines)
|
|
- `k-tv-backend/domain/src/services/schedule/fill.rs` (151 lines)
|
|
- `k-tv-backend/domain/src/services/schedule/recycle.rs` (55 lines)
|
|
- `k-tv-backend/domain/src/iptv.rs` (93 lines)
|
|
|
|
Note: The old `UserService` and `ChannelService` are NOT migrated here — their logic moves to application use cases. Only `ScheduleEngineService` stays in domain because it contains pure scheduling algorithms.
|
|
|
|
Adapt all code to use new newtypes (getters instead of field access, `UserId` instead of `Uuid`, etc.).
|
|
|
|
- [ ] **Step 1: Create schedule/fill.rs** — adapt fill_block, fill_best_fit, fill_sequential
|
|
- [ ] **Step 2: Create schedule/recycle.rs** — adapt apply_recycle_policy
|
|
- [ ] **Step 3: Create schedule/mod.rs** — adapt ScheduleEngineService
|
|
- [ ] **Step 4: Create iptv.rs** — adapt generate_m3u, generate_xmltv
|
|
- [ ] **Step 5: Create services/mod.rs**
|
|
- [ ] **Step 6: Update lib.rs**
|
|
- [ ] **Step 7: Verify and commit**
|
|
|
|
Run: `cargo check -p domain`
|
|
|
|
```bash
|
|
git add crates/domain/
|
|
git commit -m "domain services: schedule engine, fill strategies, IPTV"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Domain testing — InMemory repos + Noops
|
|
|
|
**Files:**
|
|
- Create: `crates/domain/src/testing/mod.rs`
|
|
- Create: `crates/domain/src/testing/in_memory.rs`
|
|
- Create: `crates/domain/src/testing/noops.rs`
|
|
- Modify: `crates/domain/src/lib.rs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: All port traits from Task 5
|
|
- Produces: `InMemoryUserRepository`, `InMemoryChannelRepository`, `InMemoryScheduleRepository`, `InMemoryLibraryRepository`, `NoopEventPublisher`, `NoopMediaProvider`, `NoopActivityLog`, etc.
|
|
|
|
InMemory repos: `Arc<Mutex<HashMap<Id, Entity>>>` implementing both Command and Query traits. One struct implements both traits (since InMemory doesn't need separate read/write stores).
|
|
|
|
Noops: return `Ok(())` for writes, `Ok(None)` / `Ok(vec![])` for reads.
|
|
|
|
Reference: `movies-diary/crates/domain/src/testing/` for pattern.
|
|
|
|
Gated behind `#[cfg(feature = "test-helpers")]` in lib.rs:
|
|
```rust
|
|
#[cfg(feature = "test-helpers")]
|
|
pub mod testing;
|
|
```
|
|
|
|
- [ ] **Step 1: Create in_memory.rs** — InMemory implementations for all ports
|
|
- [ ] **Step 2: Create noops.rs** — Noop implementations for all ports
|
|
- [ ] **Step 3: Create testing/mod.rs** — re-exports
|
|
- [ ] **Step 4: Update lib.rs** — add cfg-gated `pub mod testing`
|
|
- [ ] **Step 5: Verify and commit**
|
|
|
|
Run: `cargo check -p domain --features test-helpers`
|
|
|
|
```bash
|
|
git add crates/domain/
|
|
git commit -m "domain testing: InMemory repos + Noops behind test-helpers feature"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Application crate — auth bounded context
|
|
|
|
**Files:**
|
|
- Create: `crates/application/Cargo.toml`
|
|
- Create: `crates/application/src/lib.rs`
|
|
- Create: `crates/application/src/auth/mod.rs`
|
|
- Create: `crates/application/src/auth/deps.rs`
|
|
- Create: `crates/application/src/auth/commands.rs`
|
|
- Create: `crates/application/src/auth/queries.rs`
|
|
- Create: `crates/application/src/auth/register.rs`
|
|
- Create: `crates/application/src/auth/login.rs`
|
|
- Create: `crates/application/src/auth/tests/register.rs`
|
|
- Create: `crates/application/src/auth/tests/login.rs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `domain::*` (ports, models, value objects, testing)
|
|
- Produces: `AuthDeps`, `RegisterCommand`, `LoginCommand`, `auth::register::execute()`, `auth::login::execute()`
|
|
|
|
```toml
|
|
# crates/application/Cargo.toml
|
|
[package]
|
|
name = "application"
|
|
version = "0.1.0"
|
|
edition = "2024"
|
|
|
|
[dependencies]
|
|
domain = { workspace = true, features = ["test-helpers"] }
|
|
async-trait = { workspace = true }
|
|
uuid = { workspace = true }
|
|
|
|
[dev-dependencies]
|
|
tokio = { workspace = true }
|
|
```
|
|
|
|
Wait — `test-helpers` should only be enabled in dev-dependencies. Fix:
|
|
|
|
```toml
|
|
[dependencies]
|
|
domain = { workspace = true }
|
|
async-trait = { workspace = true }
|
|
uuid = { workspace = true }
|
|
|
|
[dev-dependencies]
|
|
domain = { workspace = true, features = ["test-helpers"] }
|
|
tokio = { workspace = true }
|
|
```
|
|
|
|
Pattern for each use case file:
|
|
```rust
|
|
// auth/register.rs
|
|
use domain::*;
|
|
use super::deps::AuthDeps;
|
|
use super::commands::RegisterCommand;
|
|
|
|
pub async fn execute(deps: &AuthDeps, cmd: RegisterCommand) -> DomainResult<User> {
|
|
// validate, create entity, persist, publish event
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "tests/register.rs"]
|
|
mod tests;
|
|
```
|
|
|
|
Read old logic from:
|
|
- `k-tv-backend/domain/src/services/user.rs` (UserService methods become use cases)
|
|
- `k-tv-backend/api/src/routes/auth/local.rs` (registration/login handler logic)
|
|
|
|
- [ ] **Step 1: Create application Cargo.toml**
|
|
- [ ] **Step 2: Create deps.rs** — AuthDeps struct
|
|
- [ ] **Step 3: Create commands.rs and queries.rs**
|
|
- [ ] **Step 4: Write register test** — `tests/register.rs`
|
|
- [ ] **Step 5: Implement register.rs**
|
|
- [ ] **Step 6: Write login test**
|
|
- [ ] **Step 7: Implement login.rs**
|
|
- [ ] **Step 8: Create mod.rs and lib.rs**
|
|
- [ ] **Step 9: Verify and commit**
|
|
|
|
Run: `cargo test -p application`
|
|
|
|
```bash
|
|
git add crates/application/
|
|
git commit -m "application: auth bounded context (register, login)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Application crate — channels bounded context
|
|
|
|
**Files:**
|
|
- Create: `crates/application/src/channels/mod.rs`
|
|
- Create: `crates/application/src/channels/deps.rs`
|
|
- Create: `crates/application/src/channels/commands.rs`
|
|
- Create: `crates/application/src/channels/queries.rs`
|
|
- Create: `crates/application/src/channels/create.rs`
|
|
- Create: `crates/application/src/channels/update.rs`
|
|
- Create: `crates/application/src/channels/delete.rs`
|
|
- Create: `crates/application/src/channels/get.rs`
|
|
- Create: `crates/application/src/channels/list.rs`
|
|
- Create: `crates/application/src/channels/list_by_owner.rs`
|
|
- Create: `crates/application/src/channels/tests/`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `domain::*`
|
|
- Produces: `ChannelCommandDeps`, `ChannelQueryDeps`, `CreateChannelCommand`, `UpdateChannelCommand`, use case `execute()` functions
|
|
|
|
Read old logic from `k-tv-backend/domain/src/services/channel.rs` (ChannelService methods → use cases).
|
|
|
|
- [ ] **Step 1: Create deps.rs**
|
|
- [ ] **Step 2: Create commands.rs and queries.rs**
|
|
- [ ] **Step 3: Write and implement create.rs with tests**
|
|
- [ ] **Step 4: Write and implement update.rs with tests**
|
|
- [ ] **Step 5: Write and implement delete.rs with tests**
|
|
- [ ] **Step 6: Write and implement get.rs, list.rs, list_by_owner.rs with tests**
|
|
- [ ] **Step 7: Create mod.rs, update lib.rs**
|
|
- [ ] **Step 8: Verify and commit**
|
|
|
|
Run: `cargo test -p application`
|
|
|
|
```bash
|
|
git add crates/application/
|
|
git commit -m "application: channels bounded context"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Application crate — schedule bounded context
|
|
|
|
**Files:**
|
|
- Create: `crates/application/src/schedule/mod.rs`, `deps.rs`, `commands.rs`, `queries.rs`
|
|
- Create: `crates/application/src/schedule/generate.rs`, `get_active.rs`, `get_current_broadcast.rs`, `get_epg.rs`, `get_stream_url.rs`, `list_history.rs`
|
|
- Create: `crates/application/src/schedule/tests/`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `domain::*`, `ScheduleEngineService`
|
|
- Produces: `ScheduleDeps`, `GenerateScheduleCommand`, schedule query functions
|
|
|
|
The schedule use cases orchestrate calling `ScheduleEngineService` (which stays in domain). Read old logic from `k-tv-backend/domain/src/services/schedule/mod.rs`.
|
|
|
|
- [ ] **Step 1-6: Create deps, commands, queries, implement all use cases with tests**
|
|
- [ ] **Step 7: Verify and commit**
|
|
|
|
```bash
|
|
git add crates/application/
|
|
git commit -m "application: schedule bounded context"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Application crate — library bounded context
|
|
|
|
**Files:**
|
|
- Create: `crates/application/src/library/mod.rs`, `deps.rs`, `commands.rs`, `queries.rs`
|
|
- Create: `crates/application/src/library/sync.rs`, `search.rs`, `list_collections.rs`, `list_shows.rs`, `list_seasons.rs`
|
|
- Create: `crates/application/src/library/tests/`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `domain::*`
|
|
- Produces: `LibraryCommandDeps`, `LibraryQueryDeps`, `TriggerSyncCommand`, library query functions
|
|
|
|
Read old logic from `k-tv-backend/api/src/routes/library.rs` (handler logic → use cases).
|
|
|
|
- [ ] **Step 1-6: Create deps, commands, queries, implement all use cases with tests**
|
|
- [ ] **Step 7: Verify and commit**
|
|
|
|
```bash
|
|
git add crates/application/
|
|
git commit -m "application: library bounded context"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 13: Application crate — remaining bounded contexts
|
|
|
|
**Files:**
|
|
- Create: `crates/application/src/config_snapshots/` — save, restore, list, patch_label
|
|
- Create: `crates/application/src/admin/` — update_settings, get_settings, activity_log
|
|
- Create: `crates/application/src/providers/` — upsert, delete, list
|
|
- Create: `crates/application/src/iptv/` — m3u, xmltv
|
|
|
|
**Interfaces:**
|
|
- Consumes: `domain::*`
|
|
- Produces: All remaining use case functions
|
|
|
|
Follow the same pattern as Tasks 9-12 for each bounded context.
|
|
|
|
- [ ] **Step 1: config_snapshots — deps, commands, queries, use cases, tests**
|
|
- [ ] **Step 2: admin — deps, commands, queries, use cases, tests**
|
|
- [ ] **Step 3: providers — deps, commands, queries, use cases, tests**
|
|
- [ ] **Step 4: iptv — deps, queries, use cases, tests**
|
|
- [ ] **Step 5: Verify and commit**
|
|
|
|
Run: `cargo test -p application`
|
|
|
|
```bash
|
|
git add crates/application/
|
|
git commit -m "application: config_snapshots, admin, providers, iptv"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 14: API types crate
|
|
|
|
**Files:**
|
|
- Create: `crates/api-types/Cargo.toml`
|
|
- Create: `crates/api-types/src/lib.rs`
|
|
- Create: `crates/api-types/src/auth.rs`, `channels.rs`, `schedule.rs`, `library.rs`, `admin.rs`, `config.rs`, `providers.rs`, `transcode.rs`, `common.rs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `domain` (for types referenced in responses)
|
|
- Produces: All `*Request`, `*Response`, `*Dto` types with `utoipa::ToSchema`
|
|
|
|
```toml
|
|
# crates/api-types/Cargo.toml
|
|
[package]
|
|
name = "api-types"
|
|
version = "0.1.0"
|
|
edition = "2024"
|
|
|
|
[dependencies]
|
|
domain = { workspace = true }
|
|
serde = { workspace = true }
|
|
utoipa = { workspace = true }
|
|
chrono = { workspace = true }
|
|
uuid = { workspace = true }
|
|
```
|
|
|
|
Read old DTOs from `k-tv-backend/api/src/dto.rs`. Each DTO gets `#[derive(Serialize, Deserialize, utoipa::ToSchema)]`.
|
|
|
|
- [ ] **Step 1: Create Cargo.toml**
|
|
- [ ] **Step 2: Create common.rs** — PaginatedResponse<T>, ErrorResponse
|
|
- [ ] **Step 3: Create auth.rs** — LoginRequest, RegisterRequest, TokenResponse, UserResponse
|
|
- [ ] **Step 4: Create channels.rs** — CreateChannelRequest, UpdateChannelRequest, ChannelResponse
|
|
- [ ] **Step 5: Create schedule.rs, library.rs, admin.rs, config.rs, providers.rs, transcode.rs**
|
|
- [ ] **Step 6: Create lib.rs**
|
|
- [ ] **Step 7: Verify and commit**
|
|
|
|
Run: `cargo check -p api-types`
|
|
|
|
```bash
|
|
git add crates/api-types/
|
|
git commit -m "api-types: HTTP DTOs with utoipa OpenAPI derives"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 15: Infra-wiring crate
|
|
|
|
**Files:**
|
|
- Create: `crates/infra-wiring/Cargo.toml`
|
|
- Create: `crates/infra-wiring/src/lib.rs`
|
|
|
|
**Interfaces:**
|
|
- Produces: `DbPool` enum, `Config` struct
|
|
|
|
```toml
|
|
# crates/infra-wiring/Cargo.toml
|
|
[package]
|
|
name = "infra-wiring"
|
|
version = "0.1.0"
|
|
edition = "2024"
|
|
|
|
[features]
|
|
default = ["sqlite"]
|
|
sqlite = ["sqlx/sqlite"]
|
|
postgres = ["sqlx/postgres"]
|
|
|
|
[dependencies]
|
|
sqlx = { workspace = true }
|
|
serde = { workspace = true }
|
|
thiserror = { workspace = true }
|
|
tracing = { workspace = true }
|
|
url = { workspace = true }
|
|
```
|
|
|
|
Read old config from `k-tv-backend/api/src/config.rs`. Migrate `Config` struct and `DbPool` enum.
|
|
|
|
- [ ] **Step 1: Create Cargo.toml**
|
|
- [ ] **Step 2: Create lib.rs** — DbPool enum, Config struct, Config::from_env()
|
|
- [ ] **Step 3: Verify and commit**
|
|
|
|
Run: `cargo check -p infra-wiring`
|
|
|
|
```bash
|
|
git add crates/infra-wiring/
|
|
git commit -m "infra-wiring: DbPool enum + Config"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 16: Adapter — adapter-common
|
|
|
|
**Files:**
|
|
- Create: `crates/adapters/adapter-common/Cargo.toml`
|
|
- Create: `crates/adapters/adapter-common/src/lib.rs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `domain`
|
|
- Produces: `map_sqlx_error()`, shared row-to-domain conversion helpers
|
|
|
|
Read old mapping code from `k-tv-backend/infra/src/channel_repository/mapping.rs`, `user_repository/mapping.rs`, `schedule_repository/mapping.rs`.
|
|
|
|
- [ ] **Step 1: Create Cargo.toml**
|
|
- [ ] **Step 2: Create lib.rs** — map_sqlx_error, row mapping helpers
|
|
- [ ] **Step 3: Verify and commit**
|
|
|
|
```bash
|
|
git add crates/adapters/adapter-common/
|
|
git commit -m "adapter-common: shared DB mapping helpers"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 17: Adapter — SQLite
|
|
|
|
**Files:**
|
|
- Create: `crates/adapters/sqlite/Cargo.toml`
|
|
- Create: `crates/adapters/sqlite/src/lib.rs`
|
|
- Create: `crates/adapters/sqlite/src/wire.rs`
|
|
- Create: `crates/adapters/sqlite/src/user.rs`, `channel.rs`, `schedule.rs`, `library.rs`, `activity.rs`, `settings.rs`, `provider_config.rs`, `transcode.rs`
|
|
- Copy: `k-tv-backend/migrations_sqlite/` → top-level `migrations_sqlite/`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `domain`, `adapter-common`, `infra-wiring`
|
|
- Produces: `SqliteWireOutput`, all `Sqlite*Repository` types
|
|
|
|
Read old implementations from `k-tv-backend/infra/src/*_repository/sqlite.rs` files. Each repo struct wraps `SqlitePool` and implements both Command + Query traits.
|
|
|
|
`wire()` function creates pool, runs migrations, returns `SqliteWireOutput` with `Arc<dyn Port>` for each.
|
|
|
|
- [ ] **Step 1: Create Cargo.toml**
|
|
- [ ] **Step 2: Copy migrations**
|
|
- [ ] **Step 3: Implement user.rs** — SqliteUserRepository (UserCommand + UserQuery)
|
|
- [ ] **Step 4: Implement channel.rs** — SqliteChannelRepository
|
|
- [ ] **Step 5: Implement schedule.rs** — SqliteScheduleRepository
|
|
- [ ] **Step 6: Implement library.rs** — SqliteLibraryRepository
|
|
- [ ] **Step 7: Implement activity.rs, settings.rs, provider_config.rs, transcode.rs**
|
|
- [ ] **Step 8: Create wire.rs** — SqliteWireOutput, wire() function
|
|
- [ ] **Step 9: Create lib.rs**
|
|
- [ ] **Step 10: Verify and commit**
|
|
|
|
Run: `cargo check -p adapter-sqlite`
|
|
|
|
```bash
|
|
git add crates/adapters/sqlite/ migrations_sqlite/
|
|
git commit -m "adapter-sqlite: all repository implementations + wire function"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 18: Adapter — PostgreSQL
|
|
|
|
**Files:**
|
|
- Create: `crates/adapters/postgres/Cargo.toml`
|
|
- Create: `crates/adapters/postgres/src/` — same structure as sqlite
|
|
|
|
**Interfaces:**
|
|
- Consumes: `domain`, `adapter-common`, `infra-wiring`
|
|
- Produces: `PostgresWireOutput`, all `Postgres*Repository` types
|
|
|
|
Read old code from `k-tv-backend/infra/src/*_repository/postgres.rs`. Note: not all repos have Postgres implementations in the old code — implement what exists, stub the rest.
|
|
|
|
- [ ] **Step 1-8: Mirror SQLite adapter for PostgreSQL**
|
|
- [ ] **Step 9: Verify and commit**
|
|
|
|
```bash
|
|
git add crates/adapters/postgres/
|
|
git commit -m "adapter-postgres: repository implementations"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 19: Adapter — auth (JWT + OIDC)
|
|
|
|
**Files:**
|
|
- Create: `crates/adapters/auth/Cargo.toml`
|
|
- Create: `crates/adapters/auth/src/lib.rs`
|
|
- Create: `crates/adapters/auth/src/jwt.rs`
|
|
- Create: `crates/adapters/auth/src/oidc.rs`
|
|
- Create: `crates/adapters/auth/src/password.rs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `domain` (AuthService port)
|
|
- Produces: `JwtAuthService`, `OidcService`, `PasswordAuthService`
|
|
|
|
Read from `k-tv-backend/infra/src/auth/`. Feature-gate OIDC behind `auth-oidc`.
|
|
|
|
- [ ] **Step 1-5: Create adapter with JWT, OIDC, password hashing**
|
|
- [ ] **Step 6: Verify and commit**
|
|
|
|
```bash
|
|
git add crates/adapters/auth/
|
|
git commit -m "adapter-auth: JWT, OIDC, password hashing"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 20: Adapter — Jellyfin
|
|
|
|
**Files:**
|
|
- Create: `crates/adapters/jellyfin/Cargo.toml`
|
|
- Create: `crates/adapters/jellyfin/src/lib.rs`, `config.rs`, `mapping.rs`, `models.rs`, `provider.rs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `domain` (IMediaProvider port)
|
|
- Produces: `JellyfinMediaProvider`
|
|
|
|
Read from `k-tv-backend/infra/src/jellyfin/`.
|
|
|
|
- [ ] **Step 1-5: Create Jellyfin adapter**
|
|
- [ ] **Step 6: Verify and commit**
|
|
|
|
```bash
|
|
git add crates/adapters/jellyfin/
|
|
git commit -m "adapter-jellyfin: media provider"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 21: Adapter — local-files
|
|
|
|
**Files:**
|
|
- Create: `crates/adapters/local-files/Cargo.toml`
|
|
- Create: `crates/adapters/local-files/src/` — lib.rs, config.rs, index.rs, provider.rs, scanner.rs, transcoder.rs
|
|
|
|
**Interfaces:**
|
|
- Consumes: `domain`, `infra-wiring`
|
|
- Produces: `LocalFilesProvider`, `LocalIndex`, `TranscodeManager`, `LocalFilesBundle`
|
|
|
|
Read from `k-tv-backend/infra/src/local_files/`.
|
|
|
|
- [ ] **Step 1-6: Create local-files adapter**
|
|
- [ ] **Step 7: Verify and commit**
|
|
|
|
```bash
|
|
git add crates/adapters/local-files/
|
|
git commit -m "adapter-local-files: media provider, index, transcoder"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 22: Adapter — event-publisher
|
|
|
|
**Files:**
|
|
- Create: `crates/adapters/event-publisher/Cargo.toml`
|
|
- Create: `crates/adapters/event-publisher/src/lib.rs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `domain` (EventPublisher, EventConsumer ports)
|
|
- Produces: `ChannelEventBus` (tokio broadcast-based)
|
|
|
|
```rust
|
|
pub struct ChannelEventBus {
|
|
tx: broadcast::Sender<DomainEvent>,
|
|
}
|
|
|
|
impl ChannelEventBus {
|
|
pub fn new(capacity: usize) -> Self { ... }
|
|
pub fn subscriber(&self) -> broadcast::Receiver<DomainEvent> { ... }
|
|
}
|
|
|
|
#[async_trait]
|
|
impl EventPublisher for ChannelEventBus {
|
|
async fn publish(&self, event: DomainEvent) -> DomainResult<()> {
|
|
let _ = self.tx.send(event);
|
|
Ok(())
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 1-3: Create event-publisher adapter**
|
|
- [ ] **Step 4: Verify and commit**
|
|
|
|
```bash
|
|
git add crates/adapters/event-publisher/
|
|
git commit -m "adapter-event-publisher: broadcast channel bus"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 23: Presentation crate
|
|
|
|
**Files:**
|
|
- Create: `crates/presentation/Cargo.toml`
|
|
- Create: `crates/presentation/src/main.rs`
|
|
- Create: `crates/presentation/src/state.rs`
|
|
- Create: `crates/presentation/src/context.rs`
|
|
- Create: `crates/presentation/src/factory.rs`
|
|
- Create: `crates/presentation/src/routes.rs`
|
|
- Create: `crates/presentation/src/errors.rs`
|
|
- Create: `crates/presentation/src/extractors.rs`
|
|
- Create: `crates/presentation/src/openapi/` — mod.rs + per-module files
|
|
- Create: `crates/presentation/src/handlers/` — auth.rs, channels.rs, schedule.rs, library.rs, admin.rs, config.rs, files.rs, iptv.rs
|
|
- Create: `crates/presentation/src/mappers/` — mod.rs + per-module files
|
|
- Create: `crates/presentation/src/background/` — mod.rs, library_sync.rs, auto_scheduler.rs, broadcast_poller.rs, webhook_consumer.rs
|
|
|
|
**Interfaces:**
|
|
- Consumes: Everything — domain, application, api-types, infra-wiring, all adapters
|
|
- Produces: Running HTTP server
|
|
|
|
Read old code from `k-tv-backend/api/src/`. This is the largest task.
|
|
|
|
Handlers follow this pattern:
|
|
```rust
|
|
async fn create_channel(
|
|
State(ctx): State<AppContext>,
|
|
CurrentUser(user): CurrentUser,
|
|
Json(req): Json<CreateChannelRequest>,
|
|
) -> Result<Json<ChannelResponse>, ApiError> {
|
|
let cmd = CreateChannelCommand { owner_id: user.id().value(), name: req.name, ... };
|
|
let channel = channels::create::execute(&ctx.channel_deps, cmd).await?;
|
|
Ok(Json(ChannelResponse::from(channel)))
|
|
}
|
|
```
|
|
|
|
Feature flags on Cargo.toml:
|
|
```toml
|
|
[features]
|
|
default = ["sqlite", "auth-jwt", "jellyfin"]
|
|
sqlite = ["adapter-sqlite", "infra-wiring/sqlite"]
|
|
postgres = ["adapter-postgres", "infra-wiring/postgres"]
|
|
auth-jwt = ["adapter-auth/jwt"]
|
|
auth-oidc = ["adapter-auth/oidc"]
|
|
jellyfin = ["adapter-jellyfin"]
|
|
local-files = ["adapter-local-files"]
|
|
```
|
|
|
|
- [ ] **Step 1: Create Cargo.toml with feature flags**
|
|
- [ ] **Step 2: Create state.rs, context.rs**
|
|
- [ ] **Step 3: Create errors.rs** — ApiError enum, IntoResponse
|
|
- [ ] **Step 4: Create extractors.rs** — CurrentUser, AdminUser, OptionalCurrentUser
|
|
- [ ] **Step 5: Create mappers/** — domain → api-types From impls
|
|
- [ ] **Step 6: Create handlers/auth.rs**
|
|
- [ ] **Step 7: Create handlers/channels.rs**
|
|
- [ ] **Step 8: Create handlers/schedule.rs**
|
|
- [ ] **Step 9: Create handlers/library.rs**
|
|
- [ ] **Step 10: Create handlers/admin.rs, config.rs, iptv.rs, files.rs**
|
|
- [ ] **Step 11: Create routes.rs** — wire all handlers
|
|
- [ ] **Step 12: Create openapi/** — utoipa merge
|
|
- [ ] **Step 13: Create background/** — library_sync, auto_scheduler, broadcast_poller, webhook_consumer
|
|
- [ ] **Step 14: Create factory.rs** — build all adapters
|
|
- [ ] **Step 15: Create main.rs** — startup wiring
|
|
- [ ] **Step 16: Verify and commit**
|
|
|
|
Run: `cargo build -p presentation`
|
|
|
|
```bash
|
|
git add crates/presentation/
|
|
git commit -m "presentation: HTTP server with handlers, routes, OpenAPI, background tasks"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 24: MCP crate
|
|
|
|
**Files:**
|
|
- Create: `crates/mcp/Cargo.toml`
|
|
- Create: `crates/mcp/src/main.rs`
|
|
- Create: `crates/mcp/src/server.rs`
|
|
- Create: `crates/mcp/src/error.rs`
|
|
- Create: `crates/mcp/src/tools/mod.rs`, `channels.rs`, `library.rs`, `schedule.rs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `domain`, `application`, `infra-wiring`, adapter crates
|
|
- Produces: MCP stdio binary
|
|
|
|
Read from `k-tv-backend/mcp/src/`. Adapt tool implementations to call application use cases instead of domain services directly.
|
|
|
|
- [ ] **Step 1-5: Create MCP crate**
|
|
- [ ] **Step 6: Verify and commit**
|
|
|
|
Run: `cargo build -p mcp`
|
|
|
|
```bash
|
|
git add crates/mcp/
|
|
git commit -m "mcp: MCP server calling application use cases"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 25: Cleanup — deprecate old code, update Docker, CI
|
|
|
|
**Files:**
|
|
- Modify: `k-tv-backend/README.md` — add DEPRECATED notice
|
|
- Modify: `compose.yml` — point to new presentation binary
|
|
- Modify: `compose.traefik.yml` — update if needed
|
|
- Modify: `.dockerignore` if exists
|
|
- Create or modify: `Dockerfile` for new structure
|
|
|
|
- [ ] **Step 1: Add DEPRECATED to k-tv-backend README**
|
|
- [ ] **Step 2: Update Docker build to use crates/presentation**
|
|
- [ ] **Step 3: Update compose files**
|
|
- [ ] **Step 4: Final full build and test**
|
|
|
|
Run: `cargo build --release -p presentation`
|
|
Run: `cargo test --workspace`
|
|
|
|
```bash
|
|
git add .
|
|
git commit -m "deprecate k-tv-backend, update Docker for new crate structure"
|
|
```
|