feat: implement find_all method in ChannelRepository and update related services and routes for public access

This commit is contained in:
2026-03-11 21:29:33 +01:00
parent d7b21120c8
commit 8cc3439d2e
7 changed files with 44 additions and 27 deletions

View File

@@ -1,8 +1,8 @@
//! Channel routes //! Channel routes
//! //!
//! CRUD for channels and broadcast/EPG endpoints. //! CRUD + schedule generation require authentication (Bearer JWT).
//! //! Viewing endpoints (list, now, epg, stream) are intentionally public so the
//! All routes require authentication (Bearer JWT). //! TV page works without login.
use axum::{ use axum::{
Json, Router, Json, Router,
@@ -49,9 +49,8 @@ pub fn router() -> Router<AppState> {
async fn list_channels( async fn list_channels(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(user): CurrentUser,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let channels = state.channel_service.find_by_owner(user.id).await?; let channels = state.channel_service.find_all().await?;
let response: Vec<ChannelResponse> = channels.into_iter().map(Into::into).collect(); let response: Vec<ChannelResponse> = channels.into_iter().map(Into::into).collect();
Ok(Json(response)) 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). /// Returns 204 No Content when the channel is in a gap between blocks (no-signal).
async fn get_current_broadcast( async fn get_current_broadcast(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(channel_id): Path<Uuid>, Path(channel_id): Path<Uuid>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let channel = state.channel_service.find_by_id(channel_id).await?; let _channel = state.channel_service.find_by_id(channel_id).await?;
require_owner(&channel, user.id)?;
let now = Utc::now(); let now = Utc::now();
let schedule = state let schedule = state
@@ -209,12 +206,10 @@ struct EpgQuery {
async fn get_epg( async fn get_epg(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(channel_id): Path<Uuid>, Path(channel_id): Path<Uuid>,
Query(params): Query<EpgQuery>, Query(params): Query<EpgQuery>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let channel = state.channel_service.find_by_id(channel_id).await?; let _channel = state.channel_service.find_by_id(channel_id).await?;
require_owner(&channel, user.id)?;
let now = Utc::now(); let now = Utc::now();
let from = parse_optional_dt(params.from, 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). /// Returns 204 No Content when the channel is in a gap (no-signal).
async fn get_stream( async fn get_stream(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(channel_id): Path<Uuid>, Path(channel_id): Path<Uuid>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let channel = state.channel_service.find_by_id(channel_id).await?; let _channel = state.channel_service.find_by_id(channel_id).await?;
require_owner(&channel, user.id)?;
let now = Utc::now(); let now = Utc::now();
let schedule = state let schedule = state

View File

@@ -36,6 +36,7 @@ pub trait UserRepository: Send + Sync {
pub trait ChannelRepository: Send + Sync { pub trait ChannelRepository: Send + Sync {
async fn find_by_id(&self, id: ChannelId) -> DomainResult<Option<Channel>>; async fn find_by_id(&self, id: ChannelId) -> DomainResult<Option<Channel>>;
async fn find_by_owner(&self, owner_id: UserId) -> DomainResult<Vec<Channel>>; async fn find_by_owner(&self, owner_id: UserId) -> DomainResult<Vec<Channel>>;
async fn find_all(&self) -> DomainResult<Vec<Channel>>;
/// Insert or update a channel. /// Insert or update a channel.
async fn save(&self, channel: &Channel) -> DomainResult<()>; async fn save(&self, channel: &Channel) -> DomainResult<()>;
async fn delete(&self, id: ChannelId) -> DomainResult<()>; async fn delete(&self, id: ChannelId) -> DomainResult<()>;

View File

@@ -112,6 +112,10 @@ impl ChannelService {
.ok_or(DomainError::ChannelNotFound(id)) .ok_or(DomainError::ChannelNotFound(id))
} }
pub async fn find_all(&self) -> DomainResult<Vec<crate::entities::Channel>> {
self.channel_repo.find_all().await
}
pub async fn find_by_owner( pub async fn find_by_owner(
&self, &self,
owner_id: crate::value_objects::UserId, owner_id: crate::value_objects::UserId,

View File

@@ -113,6 +113,16 @@ impl ChannelRepository for SqliteChannelRepository {
rows.into_iter().map(Channel::try_from).collect() rows.into_iter().map(Channel::try_from).collect()
} }
async fn find_all(&self) -> DomainResult<Vec<Channel>> {
let sql = format!("SELECT {SELECT_COLS} FROM channels ORDER BY created_at ASC");
let rows: Vec<ChannelRow> = sqlx::query_as(&sql)
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
rows.into_iter().map(Channel::try_from).collect()
}
async fn save(&self, channel: &Channel) -> DomainResult<()> { async fn save(&self, channel: &Channel) -> DomainResult<()> {
let schedule_config = serde_json::to_string(&channel.schedule_config).map_err(|e| { let schedule_config = serde_json::to_string(&channel.schedule_config).map_err(|e| {
DomainError::RepositoryError(format!("Failed to serialize schedule_config: {}", 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() rows.into_iter().map(Channel::try_from).collect()
} }
async fn find_all(&self) -> DomainResult<Vec<Channel>> {
let sql = format!("SELECT {SELECT_COLS} FROM channels ORDER BY created_at ASC");
let rows: Vec<ChannelRow> = sqlx::query_as(&sql)
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
rows.into_iter().map(Channel::try_from).collect()
}
async fn save(&self, channel: &Channel) -> DomainResult<()> { async fn save(&self, channel: &Channel) -> DomainResult<()> {
let schedule_config = serde_json::to_string(&channel.schedule_config).map_err(|e| { let schedule_config = serde_json::to_string(&channel.schedule_config).map_err(|e| {
DomainError::RepositoryError(format!("Failed to serialize schedule_config: {}", e)) DomainError::RepositoryError(format!("Failed to serialize schedule_config: {}", e))

View File

@@ -27,14 +27,12 @@ export async function GET(
const { channelId } = await params; const { channelId } = await params;
const token = request.nextUrl.searchParams.get("token"); const token = request.nextUrl.searchParams.get("token");
if (!token) {
return new Response(null, { status: 401 });
}
let res: Response; let res: Response;
try { try {
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
res = await fetch(`${API_URL}/channels/${channelId}/stream`, { res = await fetch(`${API_URL}/channels/${channelId}/stream`, {
headers: { Authorization: `Bearer ${token}` }, headers,
redirect: "manual", redirect: "manual",
}); });
} catch { } catch {

View File

@@ -9,8 +9,8 @@ export function useChannels() {
const { token } = useAuthContext(); const { token } = useAuthContext();
return useQuery({ return useQuery({
queryKey: ["channels"], queryKey: ["channels"],
queryFn: () => api.channels.list(token!), // Public endpoint — no token needed for TV viewing
enabled: !!token, queryFn: () => api.channels.list(token ?? ""),
}); });
} }
@@ -85,8 +85,8 @@ export function useCurrentBroadcast(channelId: string) {
const { token } = useAuthContext(); const { token } = useAuthContext();
return useQuery({ return useQuery({
queryKey: ["broadcast", channelId], queryKey: ["broadcast", channelId],
queryFn: () => api.schedule.getCurrentBroadcast(channelId, token!), queryFn: () => api.schedule.getCurrentBroadcast(channelId, token ?? ""),
enabled: !!token && !!channelId, enabled: !!channelId,
refetchInterval: 30_000, refetchInterval: 30_000,
retry: false, retry: false,
}); });
@@ -96,7 +96,7 @@ export function useEpg(channelId: string, from?: string, until?: string) {
const { token } = useAuthContext(); const { token } = useAuthContext();
return useQuery({ return useQuery({
queryKey: ["epg", channelId, from, until], queryKey: ["epg", channelId, from, until],
queryFn: () => api.schedule.getEpg(channelId, token!, from, until), queryFn: () => api.schedule.getEpg(channelId, token ?? "", from, until),
enabled: !!token && !!channelId, enabled: !!channelId,
}); });
} }

View File

@@ -106,7 +106,8 @@ export function useStreamUrl(
return useQuery({ return useQuery({
queryKey: ["stream-url", channelId, slotId], queryKey: ["stream-url", channelId, slotId],
queryFn: async (): Promise<string | null> => { queryFn: async (): Promise<string | null> => {
const params = new URLSearchParams({ token: token! }); const params = new URLSearchParams();
if (token) params.set("token", token);
const res = await fetch(`/api/stream/${channelId}?${params}`, { const res = await fetch(`/api/stream/${channelId}?${params}`, {
cache: "no-store", cache: "no-store",
}); });
@@ -115,7 +116,7 @@ export function useStreamUrl(
const { url } = await res.json(); const { url } = await res.json();
return url as string; return url as string;
}, },
enabled: !!channelId && !!token && !!slotId, enabled: !!channelId && !!slotId,
staleTime: Infinity, staleTime: Infinity,
retry: false, retry: false,
}); });