diff --git a/k-tv-backend/api/src/routes/channels.rs b/k-tv-backend/api/src/routes/channels.rs index 8ef8557..fde3a5d 100644 --- a/k-tv-backend/api/src/routes/channels.rs +++ b/k-tv-backend/api/src/routes/channels.rs @@ -1,8 +1,8 @@ //! Channel routes //! -//! CRUD for channels and broadcast/EPG endpoints. -//! -//! All routes require authentication (Bearer JWT). +//! CRUD + schedule generation require authentication (Bearer JWT). +//! Viewing endpoints (list, now, epg, stream) are intentionally public so the +//! TV page works without login. use axum::{ Json, Router, @@ -49,9 +49,8 @@ pub fn router() -> Router { async fn list_channels( State(state): State, - CurrentUser(user): CurrentUser, ) -> Result { - let channels = state.channel_service.find_by_owner(user.id).await?; + let channels = state.channel_service.find_all().await?; let response: Vec = channels.into_iter().map(Into::into).collect(); Ok(Json(response)) } @@ -173,11 +172,9 @@ async fn get_active_schedule( /// Returns 204 No Content when the channel is in a gap between blocks (no-signal). async fn get_current_broadcast( State(state): State, - CurrentUser(user): CurrentUser, Path(channel_id): Path, ) -> Result { - let channel = state.channel_service.find_by_id(channel_id).await?; - require_owner(&channel, user.id)?; + let _channel = state.channel_service.find_by_id(channel_id).await?; let now = Utc::now(); let schedule = state @@ -209,12 +206,10 @@ struct EpgQuery { async fn get_epg( State(state): State, - CurrentUser(user): CurrentUser, Path(channel_id): Path, Query(params): Query, ) -> Result { - let channel = state.channel_service.find_by_id(channel_id).await?; - require_owner(&channel, user.id)?; + let _channel = state.channel_service.find_by_id(channel_id).await?; let now = Utc::now(); let from = parse_optional_dt(params.from, now)?; @@ -244,11 +239,9 @@ async fn get_epg( /// Returns 204 No Content when the channel is in a gap (no-signal). async fn get_stream( State(state): State, - CurrentUser(user): CurrentUser, Path(channel_id): Path, ) -> Result { - let channel = state.channel_service.find_by_id(channel_id).await?; - require_owner(&channel, user.id)?; + let _channel = state.channel_service.find_by_id(channel_id).await?; let now = Utc::now(); let schedule = state diff --git a/k-tv-backend/domain/src/repositories.rs b/k-tv-backend/domain/src/repositories.rs index 2babdfd..98f44f1 100644 --- a/k-tv-backend/domain/src/repositories.rs +++ b/k-tv-backend/domain/src/repositories.rs @@ -36,6 +36,7 @@ pub trait UserRepository: Send + Sync { pub trait ChannelRepository: Send + Sync { async fn find_by_id(&self, id: ChannelId) -> DomainResult>; async fn find_by_owner(&self, owner_id: UserId) -> DomainResult>; + async fn find_all(&self) -> DomainResult>; /// Insert or update a channel. async fn save(&self, channel: &Channel) -> DomainResult<()>; async fn delete(&self, id: ChannelId) -> DomainResult<()>; diff --git a/k-tv-backend/domain/src/services.rs b/k-tv-backend/domain/src/services.rs index 2a50a3b..6240511 100644 --- a/k-tv-backend/domain/src/services.rs +++ b/k-tv-backend/domain/src/services.rs @@ -112,6 +112,10 @@ impl ChannelService { .ok_or(DomainError::ChannelNotFound(id)) } + pub async fn find_all(&self) -> DomainResult> { + self.channel_repo.find_all().await + } + pub async fn find_by_owner( &self, owner_id: crate::value_objects::UserId, diff --git a/k-tv-backend/infra/src/channel_repository.rs b/k-tv-backend/infra/src/channel_repository.rs index a05e11b..964311f 100644 --- a/k-tv-backend/infra/src/channel_repository.rs +++ b/k-tv-backend/infra/src/channel_repository.rs @@ -113,6 +113,16 @@ impl ChannelRepository for SqliteChannelRepository { rows.into_iter().map(Channel::try_from).collect() } + async fn find_all(&self) -> DomainResult> { + let sql = format!("SELECT {SELECT_COLS} FROM channels ORDER BY created_at ASC"); + let rows: Vec = sqlx::query_as(&sql) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::RepositoryError(e.to_string()))?; + + rows.into_iter().map(Channel::try_from).collect() + } + async fn save(&self, channel: &Channel) -> DomainResult<()> { let schedule_config = serde_json::to_string(&channel.schedule_config).map_err(|e| { DomainError::RepositoryError(format!("Failed to serialize schedule_config: {}", e)) @@ -205,6 +215,16 @@ impl ChannelRepository for PostgresChannelRepository { rows.into_iter().map(Channel::try_from).collect() } + async fn find_all(&self) -> DomainResult> { + let sql = format!("SELECT {SELECT_COLS} FROM channels ORDER BY created_at ASC"); + let rows: Vec = sqlx::query_as(&sql) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::RepositoryError(e.to_string()))?; + + rows.into_iter().map(Channel::try_from).collect() + } + async fn save(&self, channel: &Channel) -> DomainResult<()> { let schedule_config = serde_json::to_string(&channel.schedule_config).map_err(|e| { DomainError::RepositoryError(format!("Failed to serialize schedule_config: {}", e)) diff --git a/k-tv-frontend/app/api/stream/[channelId]/route.ts b/k-tv-frontend/app/api/stream/[channelId]/route.ts index dcf2c7d..dda01ca 100644 --- a/k-tv-frontend/app/api/stream/[channelId]/route.ts +++ b/k-tv-frontend/app/api/stream/[channelId]/route.ts @@ -27,14 +27,12 @@ export async function GET( const { channelId } = await params; const token = request.nextUrl.searchParams.get("token"); - if (!token) { - return new Response(null, { status: 401 }); - } - let res: Response; try { + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; res = await fetch(`${API_URL}/channels/${channelId}/stream`, { - headers: { Authorization: `Bearer ${token}` }, + headers, redirect: "manual", }); } catch { diff --git a/k-tv-frontend/hooks/use-channels.ts b/k-tv-frontend/hooks/use-channels.ts index b18759e..cd74424 100644 --- a/k-tv-frontend/hooks/use-channels.ts +++ b/k-tv-frontend/hooks/use-channels.ts @@ -9,8 +9,8 @@ export function useChannels() { const { token } = useAuthContext(); return useQuery({ queryKey: ["channels"], - queryFn: () => api.channels.list(token!), - enabled: !!token, + // Public endpoint — no token needed for TV viewing + queryFn: () => api.channels.list(token ?? ""), }); } @@ -85,8 +85,8 @@ export function useCurrentBroadcast(channelId: string) { const { token } = useAuthContext(); return useQuery({ queryKey: ["broadcast", channelId], - queryFn: () => api.schedule.getCurrentBroadcast(channelId, token!), - enabled: !!token && !!channelId, + queryFn: () => api.schedule.getCurrentBroadcast(channelId, token ?? ""), + enabled: !!channelId, refetchInterval: 30_000, retry: false, }); @@ -96,7 +96,7 @@ export function useEpg(channelId: string, from?: string, until?: string) { const { token } = useAuthContext(); return useQuery({ queryKey: ["epg", channelId, from, until], - queryFn: () => api.schedule.getEpg(channelId, token!, from, until), - enabled: !!token && !!channelId, + queryFn: () => api.schedule.getEpg(channelId, token ?? "", from, until), + enabled: !!channelId, }); } diff --git a/k-tv-frontend/hooks/use-tv.ts b/k-tv-frontend/hooks/use-tv.ts index 0a6f67e..3b39eb5 100644 --- a/k-tv-frontend/hooks/use-tv.ts +++ b/k-tv-frontend/hooks/use-tv.ts @@ -106,7 +106,8 @@ export function useStreamUrl( return useQuery({ queryKey: ["stream-url", channelId, slotId], queryFn: async (): Promise => { - const params = new URLSearchParams({ token: token! }); + const params = new URLSearchParams(); + if (token) params.set("token", token); const res = await fetch(`/api/stream/${channelId}?${params}`, { cache: "no-store", }); @@ -115,7 +116,7 @@ export function useStreamUrl( const { url } = await res.json(); return url as string; }, - enabled: !!channelId && !!token && !!slotId, + enabled: !!channelId && !!slotId, staleTime: Infinity, retry: false, });