# 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`, `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) -> Self { Self::ValidationError(message.into()) } pub fn unauthenticated(message: impl Into) -> Self { Self::Unauthenticated(message.into()) } pub fn forbidden(message: impl Into) -> 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 = Result; ``` - [ ] **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 { Ok(Self(s.parse()?)) } } impl From 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) -> Self { Self(value.into()) } pub fn into_inner(self) -> String { self.0 } pub fn value(&self) -> &str { &self.0 } } impl AsRef 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 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` to errors** Add to `crates/domain/src/errors/mod.rs`: ```rust impl From 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, content_type: Option, series_names: Vec, collection_id: Option, genres: Vec, decade: Option, min_duration_secs: Option, max_duration_secs: Option, search_term: Option, season_number: Option, offset: u32, limit: u32, } impl LibrarySearchFilter { pub fn new() -> Self { Self::default() } // Builder methods pub fn with_provider_id(mut self, id: impl Into) -> 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) -> Self { self.series_names = names; self } pub fn with_collection_id(mut self, id: impl Into) -> Self { self.collection_id = Some(id.into()); self } pub fn with_genres(mut self, genres: Vec) -> 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) -> 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 { self.decade } pub fn min_duration_secs(&self) -> Option { self.min_duration_secs } pub fn max_duration_secs(&self) -> Option { self.max_duration_secs } pub fn search_term(&self) -> Option<&str> { self.search_term.as_deref() } pub fn season_number(&self) -> Option { 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` 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** - [ ] **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>; async fn find_by_subject(&self, subject: &str) -> DomainResult>; async fn find_by_email(&self, email: &str) -> DomainResult>; async fn count_users(&self) -> DomainResult; } ``` 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; fn verify_password(&self, password: &str, hash: &str) -> DomainResult; } ``` 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>; } #[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>>` 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 { // 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, 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` 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, } impl ChannelEventBus { pub fn new(capacity: usize) -> Self { ... } pub fn subscriber(&self) -> broadcast::Receiver { ... } } #[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, CurrentUser(user): CurrentUser, Json(req): Json, ) -> Result, 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" ```