diff --git a/k-tv-backend/api/src/config.rs b/k-tv-backend/api/src/config.rs index ffb99a9..1b182cc 100644 --- a/k-tv-backend/api/src/config.rs +++ b/k-tv-backend/api/src/config.rs @@ -39,6 +39,9 @@ pub struct Config { pub jellyfin_base_url: Option, pub jellyfin_api_key: Option, pub jellyfin_user_id: Option, + + /// Public base URL of this API server (used to build IPTV stream URLs). + pub base_url: String, } impl Config { @@ -111,6 +114,9 @@ impl Config { let jellyfin_api_key = env::var("JELLYFIN_API_KEY").ok(); let jellyfin_user_id = env::var("JELLYFIN_USER_ID").ok(); + let base_url = env::var("BASE_URL") + .unwrap_or_else(|_| format!("http://localhost:{}", port)); + Self { host, port, @@ -134,6 +140,7 @@ impl Config { jellyfin_base_url, jellyfin_api_key, jellyfin_user_id, + base_url, } } } diff --git a/k-tv-backend/api/src/extractors.rs b/k-tv-backend/api/src/extractors.rs index 28055c2..adef53b 100644 --- a/k-tv-backend/api/src/extractors.rs +++ b/k-tv-backend/api/src/extractors.rs @@ -39,6 +39,9 @@ impl FromRequestParts for CurrentUser { } /// Optional current user — returns None instead of error when auth is missing/invalid. +/// +/// Checks `Authorization: Bearer ` first; falls back to `?token=` query param +/// so IPTV clients and direct stream links work without custom headers. pub struct OptionalCurrentUser(pub Option); impl FromRequestParts for OptionalCurrentUser { @@ -50,7 +53,21 @@ impl FromRequestParts for OptionalCurrentUser { ) -> Result { #[cfg(feature = "auth-jwt")] { - return Ok(OptionalCurrentUser(try_jwt_auth(parts, state).await.ok())); + // Try Authorization header first + if let Ok(user) = try_jwt_auth(parts, state).await { + return Ok(OptionalCurrentUser(Some(user))); + } + // Fall back to ?token= query param + let query_token = parts.uri.query().and_then(|q| { + q.split('&') + .find(|seg| seg.starts_with("token=")) + .map(|seg| seg[6..].to_owned()) + }); + if let Some(token) = query_token { + let user = validate_jwt_token(&token, state).await.ok(); + return Ok(OptionalCurrentUser(user)); + } + return Ok(OptionalCurrentUser(None)); } #[cfg(not(feature = "auth-jwt"))] @@ -61,7 +78,7 @@ impl FromRequestParts for OptionalCurrentUser { } } -/// Authenticate using JWT Bearer token +/// Authenticate using JWT Bearer token from the `Authorization` header. #[cfg(feature = "auth-jwt")] async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result { use axum::http::header::AUTHORIZATION; @@ -79,6 +96,12 @@ async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result Result { let validator = state .jwt_validator .as_ref() diff --git a/k-tv-backend/api/src/routes/iptv.rs b/k-tv-backend/api/src/routes/iptv.rs new file mode 100644 index 0000000..6edf91f --- /dev/null +++ b/k-tv-backend/api/src/routes/iptv.rs @@ -0,0 +1,118 @@ +//! IPTV export routes +//! +//! Generates M3U playlists and XMLTV guides for use with standard IPTV clients. +//! Auth is provided via `?token=` query param so URLs can be pasted +//! directly into TiviMate, VLC, etc. + +use std::collections::HashMap; + +use axum::{ + Router, + extract::{Query, State}, + http::{HeaderValue, StatusCode, header}, + response::{IntoResponse, Response}, + routing::get, +}; +use chrono::Utc; +use serde::Deserialize; + +use crate::{error::ApiError, state::AppState}; + +#[cfg(feature = "auth-jwt")] +use crate::extractors::validate_jwt_token; + +pub fn router() -> Router { + Router::new() + .route("/playlist.m3u", get(get_playlist)) + .route("/epg.xml", get(get_epg)) +} + +#[derive(Debug, Deserialize)] +struct TokenQuery { + token: Option, +} + +/// `GET /api/v1/iptv/playlist.m3u?token={jwt}` +/// +/// Returns an M3U playlist with one entry per channel the authenticated user owns. +async fn get_playlist( + State(state): State, + Query(params): Query, +) -> Result { + let token = params + .token + .as_deref() + .unwrap_or("") + .to_owned(); + + let user = authenticate_query_token(&token, &state).await?; + + let channels = state.channel_service.find_by_owner(user.id).await?; + let body = domain::generate_m3u(&channels, &state.config.base_url, &token); + + Ok(( + StatusCode::OK, + [(header::CONTENT_TYPE, HeaderValue::from_static("audio/x-mpegurl"))], + body, + ) + .into_response()) +} + +/// `GET /api/v1/iptv/epg.xml?token={jwt}` +/// +/// Returns an XMLTV document covering the active schedule for all channels +/// owned by the authenticated user. +async fn get_epg( + State(state): State, + Query(params): Query, +) -> Result { + let token = params.token.as_deref().unwrap_or("").to_owned(); + let user = authenticate_query_token(&token, &state).await?; + + let channels = state.channel_service.find_by_owner(user.id).await?; + + let now = Utc::now(); + let mut slots_by_channel = HashMap::new(); + for ch in &channels { + if let Ok(Some(schedule)) = state.schedule_engine.get_active_schedule(ch.id, now).await { + slots_by_channel.insert(ch.id, schedule.slots); + } + } + + let body = domain::generate_xmltv(&channels, &slots_by_channel); + + Ok(( + StatusCode::OK, + [( + header::CONTENT_TYPE, + HeaderValue::from_static("application/xml; charset=utf-8"), + )], + body, + ) + .into_response()) +} + +/// Validate a JWT from the `?token=` query param and return the user. +async fn authenticate_query_token( + token: &str, + state: &AppState, +) -> Result { + if token.is_empty() { + return Err(ApiError::Unauthorized( + "Missing ?token= query parameter".to_string(), + )); + } + + #[cfg(feature = "auth-jwt")] + { + return validate_jwt_token(token, state).await; + } + + #[cfg(not(feature = "auth-jwt"))] + { + let _ = (token, state); + Err(ApiError::Unauthorized( + "No authentication backend configured".to_string(), + )) + } +} diff --git a/k-tv-backend/api/src/routes/mod.rs b/k-tv-backend/api/src/routes/mod.rs index 83d5bbc..db79404 100644 --- a/k-tv-backend/api/src/routes/mod.rs +++ b/k-tv-backend/api/src/routes/mod.rs @@ -8,6 +8,7 @@ use axum::Router; pub mod auth; pub mod channels; pub mod config; +pub mod iptv; pub mod library; /// Construct the API v1 router @@ -16,5 +17,6 @@ pub fn api_v1_router() -> Router { .nest("/auth", auth::router()) .nest("/channels", channels::router()) .nest("/config", config::router()) + .nest("/iptv", iptv::router()) .nest("/library", library::router()) } diff --git a/k-tv-backend/domain/src/iptv.rs b/k-tv-backend/domain/src/iptv.rs new file mode 100644 index 0000000..0aefb6c --- /dev/null +++ b/k-tv-backend/domain/src/iptv.rs @@ -0,0 +1,93 @@ +//! IPTV export: M3U playlist and XMLTV guide generation. +//! +//! Pure functions — no I/O, no dependencies beyond domain types. + +use std::collections::HashMap; + +use crate::entities::{Channel, ScheduledSlot}; +use crate::value_objects::ChannelId; + +/// Generate an M3U playlist for the given channels. +/// +/// Each entry points to the channel's `/stream` endpoint authenticated with the +/// provided JWT token so IPTV clients can load it directly. +pub fn generate_m3u(channels: &[Channel], base_url: &str, token: &str) -> String { + let mut out = String::from("#EXTM3U\n"); + for ch in channels { + out.push_str(&format!( + "#EXTINF:-1 tvg-id=\"{}\" tvg-name=\"{}\" tvg-logo=\"\" group-title=\"K-TV\",{}\n", + ch.id, ch.name, ch.name + )); + out.push_str(&format!( + "{}/api/v1/channels/{}/stream?token={}\n", + base_url, ch.id, token + )); + } + out +} + +/// Generate an XMLTV EPG document for the given channels and their scheduled slots. +pub fn generate_xmltv( + channels: &[Channel], + slots_by_channel: &HashMap>, +) -> String { + let mut out = + String::from("\n\n"); + + for ch in channels { + out.push_str(&format!( + " {}\n", + ch.id, + escape_xml(&ch.name) + )); + } + + for ch in channels { + if let Some(slots) = slots_by_channel.get(&ch.id) { + for slot in slots { + let start = slot.start_at.format("%Y%m%d%H%M%S +0000"); + let stop = slot.end_at.format("%Y%m%d%H%M%S +0000"); + out.push_str(&format!( + " \n", + start, stop, ch.id + )); + out.push_str(&format!( + " {}\n", + escape_xml(&slot.item.title) + )); + if let Some(desc) = &slot.item.description { + out.push_str(&format!( + " {}\n", + escape_xml(desc) + )); + } + if let Some(genre) = slot.item.genres.first() { + out.push_str(&format!( + " {}\n", + escape_xml(genre) + )); + } + if let (Some(season), Some(episode)) = + (slot.item.season_number, slot.item.episode_number) + { + out.push_str(&format!( + " S{}E{}\n", + season, episode + )); + } + out.push_str(" \n"); + } + } + } + + out.push_str("\n"); + out +} + +fn escape_xml(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} diff --git a/k-tv-backend/domain/src/lib.rs b/k-tv-backend/domain/src/lib.rs index 9d23325..1274bdf 100644 --- a/k-tv-backend/domain/src/lib.rs +++ b/k-tv-backend/domain/src/lib.rs @@ -5,6 +5,7 @@ pub mod entities; pub mod errors; +pub mod iptv; pub mod ports; pub mod repositories; pub mod services; @@ -15,5 +16,6 @@ pub use entities::*; pub use errors::{DomainError, DomainResult}; pub use ports::{Collection, IMediaProvider, SeriesSummary}; pub use repositories::*; +pub use iptv::{generate_m3u, generate_xmltv}; pub use services::{ChannelService, ScheduleEngineService, UserService}; pub use value_objects::*; diff --git a/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx b/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx index e9c6e2e..ac62074 100644 --- a/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx +++ b/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx @@ -1,7 +1,16 @@ "use client"; import Link from "next/link"; -import { Pencil, Trash2, RefreshCw, Tv2, CalendarDays, Download, ChevronUp, ChevronDown } from "lucide-react"; +import { + Pencil, + Trash2, + RefreshCw, + Tv2, + CalendarDays, + Download, + ChevronUp, + ChevronDown, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { useActiveSchedule } from "@/hooks/use-channels"; import type { ChannelResponse } from "@/lib/types"; @@ -34,7 +43,12 @@ function useScheduleStatus(channelId: string) { const h = Math.ceil(hoursLeft); return { status: "expiring" as const, label: `Expires in ${h}h` }; } - const fmt = expiresAt.toLocaleDateString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false }); + const fmt = expiresAt.toLocaleDateString(undefined, { + weekday: "short", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); return { status: "ok" as const, label: `Until ${fmt}` }; } @@ -55,10 +69,13 @@ export function ChannelCard({ const { status, label } = useScheduleStatus(channel.id); const scheduleColor = - status === "expired" ? "text-red-400" : - status === "expiring" ? "text-amber-400" : - status === "ok" ? "text-zinc-500" : - "text-zinc-600"; + status === "expired" + ? "text-red-400" + : status === "expiring" + ? "text-amber-400" + : status === "ok" + ? "text-zinc-500" + : "text-zinc-600"; return (
@@ -131,9 +148,7 @@ export function ChannelCard({ {blockCount} {blockCount === 1 ? "block" : "blocks"} - {label && ( - {label} - )} + {label && {label}}
{/* Actions */} @@ -144,11 +159,12 @@ export function ChannelCard({ disabled={isGenerating} className={`flex-1 ${status === "expired" ? "border border-red-800/50 bg-red-950/30 text-red-300 hover:bg-red-900/40" : ""}`} > - + {isGenerating ? "Generating…" : "Generate schedule"} + + + +
+ +

+ EPG Source → paste URL +

+
+ + +
+
+ +

+ The token in these URLs is your session JWT. Anyone with these URLs + can stream your channels. +

+ + + + ); +} diff --git a/k-tv-frontend/app/(main)/dashboard/page.tsx b/k-tv-frontend/app/(main)/dashboard/page.tsx index 531845b..3162e09 100644 --- a/k-tv-frontend/app/(main)/dashboard/page.tsx +++ b/k-tv-frontend/app/(main)/dashboard/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { Plus, Upload, RefreshCw } from "lucide-react"; +import { Plus, Upload, RefreshCw, Antenna } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useChannels, @@ -19,8 +19,16 @@ import { CreateChannelDialog } from "./components/create-channel-dialog"; import { DeleteChannelDialog } from "./components/delete-channel-dialog"; import { EditChannelSheet } from "./components/edit-channel-sheet"; import { ScheduleSheet } from "./components/schedule-sheet"; -import { ImportChannelDialog, type ChannelImportData } from "./components/import-channel-dialog"; -import type { ChannelResponse, ProgrammingBlock, RecyclePolicy } from "@/lib/types"; +import { + ImportChannelDialog, + type ChannelImportData, +} from "./components/import-channel-dialog"; +import { IptvExportDialog } from "./components/iptv-export-dialog"; +import type { + ChannelResponse, + ProgrammingBlock, + RecyclePolicy, +} from "@/lib/types"; export default function DashboardPage() { const { token } = useAuthContext(); @@ -43,7 +51,9 @@ export default function DashboardPage() { const saveOrder = (order: string[]) => { setChannelOrder(order); - try { localStorage.setItem("k-tv-channel-order", JSON.stringify(order)); } catch {} + try { + localStorage.setItem("k-tv-channel-order", JSON.stringify(order)); + } catch {} }; // Sort channels by stored order; new channels appear at the end @@ -91,17 +101,22 @@ export default function DashboardPage() { } } setIsRegeneratingAll(false); - if (failed === 0) toast.success(`All ${channels.length} schedules regenerated`); + if (failed === 0) + toast.success(`All ${channels.length} schedules regenerated`); else toast.error(`${failed} schedule(s) failed to generate`); }; + const [iptvOpen, setIptvOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [importOpen, setImportOpen] = useState(false); const [importPending, setImportPending] = useState(false); const [importError, setImportError] = useState(null); const [editChannel, setEditChannel] = useState(null); - const [deleteTarget, setDeleteTarget] = useState(null); - const [scheduleChannel, setScheduleChannel] = useState(null); + const [deleteTarget, setDeleteTarget] = useState( + null, + ); + const [scheduleChannel, setScheduleChannel] = + useState(null); const handleCreate = (data: { name: string; @@ -147,12 +162,19 @@ export default function DashboardPage() { setImportError(null); try { const created = await api.channels.create( - { name: data.name, timezone: data.timezone, description: data.description }, + { + name: data.name, + timezone: data.timezone, + description: data.description, + }, token, ); await api.channels.update( created.id, - { schedule_config: { blocks: data.blocks }, recycle_policy: data.recycle_policy }, + { + schedule_config: { blocks: data.blocks }, + recycle_policy: data.recycle_policy, + }, token, ); await queryClient.invalidateQueries({ queryKey: ["channels"] }); @@ -172,7 +194,9 @@ export default function DashboardPage() { blocks: channel.schedule_config.blocks, recycle_policy: channel.recycle_policy, }; - const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); + const blob = new Blob([JSON.stringify(payload, null, 2)], { + type: "application/json", + }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; @@ -201,17 +225,28 @@ export default function DashboardPage() {
{channels && channels.length > 0 && ( )} - + @@ -238,7 +273,7 @@ export default function DashboardPage() { {!isLoading && channels && channels.length === 0 && (

No channels yet

- @@ -270,9 +305,22 @@ export default function DashboardPage() { )} {/* Dialogs / sheets */} + {token && ( + + )} + { if (!open) { setImportOpen(false); setImportError(null); } }} + onOpenChange={(open) => { + if (!open) { + setImportOpen(false); + setImportError(null); + } + }} onSubmit={handleImport} isPending={importPending} error={importError} @@ -289,7 +337,9 @@ export default function DashboardPage() { { if (!open) setEditChannel(null); }} + onOpenChange={(open) => { + if (!open) setEditChannel(null); + }} onSubmit={handleEdit} isPending={updateChannel.isPending} error={updateChannel.error?.message} @@ -298,14 +348,18 @@ export default function DashboardPage() { { if (!open) setScheduleChannel(null); }} + onOpenChange={(open) => { + if (!open) setScheduleChannel(null); + }} /> {deleteTarget && ( { if (!open) setDeleteTarget(null); }} + onOpenChange={(open) => { + if (!open) setDeleteTarget(null); + }} onConfirm={handleDelete} isPending={deleteChannel.isPending} />