feat: add access control to channels with various modes

- Introduced AccessMode enum to define channel access levels: Public, PasswordProtected, AccountRequired, and OwnerOnly.
- Updated Channel and ProgrammingBlock entities to include access_mode and access_password_hash fields.
- Enhanced create and update channel functionality to handle access mode and password.
- Implemented access checks in channel routes based on the defined access modes.
- Modified frontend components to support channel creation and editing with access control options.
- Added ChannelPasswordModal for handling password input when accessing restricted channels.
- Updated API calls to include channel and block passwords as needed.
- Created database migrations to add access_mode and access_password_hash columns to channels table.
This commit is contained in:
2026-03-14 01:45:10 +01:00
parent 924e162563
commit 81df6eb8ff
25 changed files with 635 additions and 53 deletions

View File

@@ -58,6 +58,9 @@ pub struct CreateChannelRequest {
pub description: Option<String>, pub description: Option<String>,
/// IANA timezone, e.g. "UTC" or "America/New_York" /// IANA timezone, e.g. "UTC" or "America/New_York"
pub timezone: String, pub timezone: String,
pub access_mode: Option<domain::AccessMode>,
/// Plain-text password; hashed before storage.
pub access_password: Option<String>,
} }
/// All fields are optional — only provided fields are updated. /// All fields are optional — only provided fields are updated.
@@ -70,6 +73,9 @@ pub struct UpdateChannelRequest {
pub schedule_config: Option<domain::ScheduleConfig>, pub schedule_config: Option<domain::ScheduleConfig>,
pub recycle_policy: Option<domain::RecyclePolicy>, pub recycle_policy: Option<domain::RecyclePolicy>,
pub auto_schedule: Option<bool>, pub auto_schedule: Option<bool>,
pub access_mode: Option<domain::AccessMode>,
/// Empty string clears the password; non-empty re-hashes.
pub access_password: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -82,6 +88,7 @@ pub struct ChannelResponse {
pub schedule_config: domain::ScheduleConfig, pub schedule_config: domain::ScheduleConfig,
pub recycle_policy: domain::RecyclePolicy, pub recycle_policy: domain::RecyclePolicy,
pub auto_schedule: bool, pub auto_schedule: bool,
pub access_mode: domain::AccessMode,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
@@ -97,6 +104,7 @@ impl From<domain::Channel> for ChannelResponse {
schedule_config: c.schedule_config, schedule_config: c.schedule_config,
recycle_policy: c.recycle_policy, recycle_policy: c.recycle_policy,
auto_schedule: c.auto_schedule, auto_schedule: c.auto_schedule,
access_mode: c.access_mode,
created_at: c.created_at, created_at: c.created_at,
updated_at: c.updated_at, updated_at: c.updated_at,
} }
@@ -147,6 +155,8 @@ pub struct ScheduledSlotResponse {
pub end_at: DateTime<Utc>, pub end_at: DateTime<Utc>,
pub item: MediaItemResponse, pub item: MediaItemResponse,
pub source_block_id: Uuid, pub source_block_id: Uuid,
#[serde(default)]
pub block_access_mode: domain::AccessMode,
} }
impl From<domain::ScheduledSlot> for ScheduledSlotResponse { impl From<domain::ScheduledSlot> for ScheduledSlotResponse {
@@ -157,6 +167,27 @@ impl From<domain::ScheduledSlot> for ScheduledSlotResponse {
end_at: s.end_at, end_at: s.end_at,
item: s.item.into(), item: s.item.into(),
source_block_id: s.source_block_id, source_block_id: s.source_block_id,
block_access_mode: domain::AccessMode::default(),
}
}
}
impl ScheduledSlotResponse {
pub fn with_block_access(slot: domain::ScheduledSlot, channel: &domain::Channel) -> Self {
let block_access_mode = channel
.schedule_config
.blocks
.iter()
.find(|b| b.id == slot.source_block_id)
.map(|b| b.access_mode.clone())
.unwrap_or_default();
Self {
id: slot.id,
start_at: slot.start_at,
end_at: slot.end_at,
item: slot.item.into(),
source_block_id: slot.source_block_id,
block_access_mode,
} }
} }
} }
@@ -169,6 +200,8 @@ pub struct CurrentBroadcastResponse {
/// Seconds elapsed since the start of the current item — use this as the /// Seconds elapsed since the start of the current item — use this as the
/// initial seek position for the player. /// initial seek position for the player.
pub offset_secs: u32, pub offset_secs: u32,
/// Access mode of the block currently playing. The stream is gated by this.
pub block_access_mode: domain::AccessMode,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]

View File

@@ -29,6 +29,12 @@ pub enum ApiError {
#[error("Unauthorized: {0}")] #[error("Unauthorized: {0}")]
Unauthorized(String), Unauthorized(String),
#[error("password_required")]
PasswordRequired,
#[error("auth_required")]
AuthRequired,
} }
/// Error response body /// Error response body
@@ -110,6 +116,22 @@ impl IntoResponse for ApiError {
details: Some(msg.clone()), details: Some(msg.clone()),
}, },
), ),
ApiError::PasswordRequired => (
StatusCode::UNAUTHORIZED,
ErrorResponse {
error: "password_required".to_string(),
details: None,
},
),
ApiError::AuthRequired => (
StatusCode::UNAUTHORIZED,
ErrorResponse {
error: "auth_required".to_string(),
details: None,
},
),
}; };
(status, Json(error_response)).into_response() (status, Json(error_response)).into_response()

View File

@@ -38,6 +38,29 @@ impl FromRequestParts<AppState> for CurrentUser {
} }
} }
/// Optional current user — returns None instead of error when auth is missing/invalid.
pub struct OptionalCurrentUser(pub Option<User>);
impl FromRequestParts<AppState> for OptionalCurrentUser {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
#[cfg(feature = "auth-jwt")]
{
return Ok(OptionalCurrentUser(try_jwt_auth(parts, state).await.ok()));
}
#[cfg(not(feature = "auth-jwt"))]
{
let _ = (parts, state);
Ok(OptionalCurrentUser(None))
}
}
}
/// Authenticate using JWT Bearer token /// Authenticate using JWT Bearer token
#[cfg(feature = "auth-jwt")] #[cfg(feature = "auth-jwt")]
async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result<User, ApiError> { async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result<User, ApiError> {

View File

@@ -6,7 +6,9 @@ use std::net::SocketAddr;
use std::time::Duration as StdDuration; use std::time::Duration as StdDuration;
use axum::Router; use axum::Router;
use axum::http::{HeaderName, HeaderValue};
use std::sync::Arc; use std::sync::Arc;
use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
use domain::{ChannelService, IMediaProvider, ScheduleEngineService, UserService}; use domain::{ChannelService, IMediaProvider, ScheduleEngineService, UserService};
use infra::factory::{build_channel_repository, build_schedule_repository, build_user_repository}; use infra::factory::{build_channel_repository, build_schedule_repository, build_user_repository};
@@ -102,6 +104,24 @@ async fn main() -> anyhow::Result<()> {
let app = apply_standard_middleware(app, &server_config); let app = apply_standard_middleware(app, &server_config);
// Wrap with an outer CorsLayer that includes the custom password headers.
// Being outermost it handles OPTIONS preflights before k_core's inner layer.
let origins: Vec<HeaderValue> = config
.cors_allowed_origins
.iter()
.filter_map(|o| o.parse().ok())
.collect();
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::list(origins))
.allow_methods(AllowMethods::any())
.allow_headers(AllowHeaders::list([
axum::http::header::AUTHORIZATION,
axum::http::header::CONTENT_TYPE,
HeaderName::from_static("x-channel-password"),
HeaderName::from_static("x-block-password"),
]));
let app = app.layer(cors);
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?; let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
let listener = TcpListener::bind(addr).await?; let listener = TcpListener::bind(addr).await?;

View File

@@ -1,7 +1,7 @@
use axum::{ use axum::{
Json, Json,
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::{HeaderMap, StatusCode},
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
use chrono::Utc; use chrono::Utc;
@@ -13,18 +13,41 @@ use domain::{DomainError, ScheduleEngineService};
use crate::{ use crate::{
dto::{CurrentBroadcastResponse, ScheduledSlotResponse}, dto::{CurrentBroadcastResponse, ScheduledSlotResponse},
error::ApiError, error::ApiError,
extractors::OptionalCurrentUser,
state::AppState, state::AppState,
}; };
use super::parse_optional_dt; use super::{check_access, parse_optional_dt};
fn channel_password(headers: &HeaderMap) -> Option<&str> {
headers
.get("X-Channel-Password")
.and_then(|v| v.to_str().ok())
}
fn block_password(headers: &HeaderMap) -> Option<&str> {
headers
.get("X-Block-Password")
.and_then(|v| v.to_str().ok())
}
/// What is currently playing right now on this channel. /// What is currently playing right now on this channel.
/// 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).
pub(super) async fn get_current_broadcast( pub(super) async fn get_current_broadcast(
State(state): State<AppState>, State(state): State<AppState>,
Path(channel_id): Path<Uuid>, Path(channel_id): Path<Uuid>,
OptionalCurrentUser(user): OptionalCurrentUser,
headers: HeaderMap,
) -> 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?;
check_access(
&channel.access_mode,
channel.access_password_hash.as_deref(),
user.as_ref(),
channel.owner_id,
channel_password(&headers),
)?;
let now = Utc::now(); let now = Utc::now();
let schedule = state let schedule = state
@@ -35,11 +58,21 @@ pub(super) async fn get_current_broadcast(
match ScheduleEngineService::get_current_broadcast(&schedule, now) { match ScheduleEngineService::get_current_broadcast(&schedule, now) {
None => Ok(StatusCode::NO_CONTENT.into_response()), None => Ok(StatusCode::NO_CONTENT.into_response()),
Some(broadcast) => Ok(Json(CurrentBroadcastResponse { Some(broadcast) => {
slot: broadcast.slot.into(), let block_access_mode = channel
.schedule_config
.blocks
.iter()
.find(|b| b.id == broadcast.slot.source_block_id)
.map(|b| b.access_mode.clone())
.unwrap_or_default();
Ok(Json(CurrentBroadcastResponse {
block_access_mode: block_access_mode.clone(),
slot: ScheduledSlotResponse::with_block_access(broadcast.slot, &channel),
offset_secs: broadcast.offset_secs, offset_secs: broadcast.offset_secs,
}) })
.into_response()), .into_response())
}
} }
} }
@@ -57,9 +90,19 @@ pub(super) struct EpgQuery {
pub(super) async fn get_epg( pub(super) async fn get_epg(
State(state): State<AppState>, State(state): State<AppState>,
Path(channel_id): Path<Uuid>, Path(channel_id): Path<Uuid>,
OptionalCurrentUser(user): OptionalCurrentUser,
headers: HeaderMap,
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?;
check_access(
&channel.access_mode,
channel.access_password_hash.as_deref(),
user.as_ref(),
channel.owner_id,
channel_password(&headers),
)?;
let now = Utc::now(); let now = Utc::now();
let from = parse_optional_dt(params.from, now)?; let from = parse_optional_dt(params.from, now)?;
@@ -78,7 +121,7 @@ pub(super) async fn get_epg(
let slots: Vec<ScheduledSlotResponse> = ScheduleEngineService::get_epg(&schedule, from, until) let slots: Vec<ScheduledSlotResponse> = ScheduleEngineService::get_epg(&schedule, from, until)
.into_iter() .into_iter()
.cloned() .cloned()
.map(Into::into) .map(|slot| ScheduledSlotResponse::with_block_access(slot, &channel))
.collect(); .collect();
Ok(Json(slots)) Ok(Json(slots))
@@ -90,8 +133,18 @@ pub(super) async fn get_epg(
pub(super) async fn get_stream( pub(super) async fn get_stream(
State(state): State<AppState>, State(state): State<AppState>,
Path(channel_id): Path<Uuid>, Path(channel_id): Path<Uuid>,
OptionalCurrentUser(user): OptionalCurrentUser,
headers: HeaderMap,
) -> 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?;
check_access(
&channel.access_mode,
channel.access_password_hash.as_deref(),
user.as_ref(),
channel.owner_id,
channel_password(&headers),
)?;
let now = Utc::now(); let now = Utc::now();
let schedule = state let schedule = state
@@ -105,6 +158,22 @@ pub(super) async fn get_stream(
Some(b) => b, Some(b) => b,
}; };
// Block-level access check
if let Some(block) = channel
.schedule_config
.blocks
.iter()
.find(|b| b.id == broadcast.slot.source_block_id)
{
check_access(
&block.access_mode,
block.access_password_hash.as_deref(),
user.as_ref(),
channel.owner_id,
block_password(&headers),
)?;
}
let url = state let url = state
.schedule_engine .schedule_engine
.get_stream_url(&broadcast.slot.item.id) .get_stream_url(&broadcast.slot.item.id)

View File

@@ -34,8 +34,20 @@ pub(super) async fn create_channel(
.create(user.id, &payload.name, &payload.timezone) .create(user.id, &payload.name, &payload.timezone)
.await?; .await?;
let mut changed = false;
if let Some(desc) = payload.description { if let Some(desc) = payload.description {
channel.description = Some(desc); channel.description = Some(desc);
changed = true;
}
if let Some(mode) = payload.access_mode {
channel.access_mode = mode;
changed = true;
}
if let Some(pw) = payload.access_password.as_deref().filter(|p| !p.is_empty()) {
channel.access_password_hash = Some(infra::auth::hash_password(pw));
changed = true;
}
if changed {
channel = state.channel_service.update(channel).await?; channel = state.channel_service.update(channel).await?;
} }
@@ -79,6 +91,16 @@ pub(super) async fn update_channel(
if let Some(auto) = payload.auto_schedule { if let Some(auto) = payload.auto_schedule {
channel.auto_schedule = auto; channel.auto_schedule = auto;
} }
if let Some(mode) = payload.access_mode {
channel.access_mode = mode;
}
if let Some(pw) = payload.access_password {
if pw.is_empty() {
channel.access_password_hash = None;
} else {
channel.access_password_hash = Some(infra::auth::hash_password(&pw));
}
}
channel.updated_at = Utc::now(); channel.updated_at = Utc::now();
let channel = state.channel_service.update(channel).await?; let channel = state.channel_service.update(channel).await?;

View File

@@ -8,6 +8,8 @@ use axum::{Router, routing::{get, post}};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use uuid::Uuid; use uuid::Uuid;
use domain::{AccessMode, User};
use crate::{error::ApiError, state::AppState}; use crate::{error::ApiError, state::AppState};
mod broadcast; mod broadcast;
@@ -42,6 +44,40 @@ pub(super) fn require_owner(channel: &domain::Channel, user_id: Uuid) -> Result<
} }
} }
/// Gate access to a channel or block based on its `AccessMode`.
pub(super) fn check_access(
mode: &AccessMode,
password_hash: Option<&str>,
user: Option<&User>,
owner_id: Uuid,
supplied_password: Option<&str>,
) -> Result<(), ApiError> {
match mode {
AccessMode::Public => Ok(()),
AccessMode::PasswordProtected => {
let hash = password_hash.ok_or(ApiError::PasswordRequired)?;
let supplied = supplied_password.unwrap_or("").trim();
if supplied.is_empty() {
return Err(ApiError::PasswordRequired);
}
if !infra::auth::verify_password(supplied, hash) {
return Err(ApiError::PasswordRequired);
}
Ok(())
}
AccessMode::AccountRequired => {
if user.is_some() { Ok(()) } else { Err(ApiError::AuthRequired) }
}
AccessMode::OwnerOnly => {
if user.map(|u| u.id) == Some(owner_id) {
Ok(())
} else {
Err(ApiError::Forbidden("owner only".into()))
}
}
}
}
pub(super) fn parse_optional_dt( pub(super) fn parse_optional_dt(
s: Option<String>, s: Option<String>,
default: DateTime<Utc>, default: DateTime<Utc>,

View File

@@ -9,7 +9,8 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::value_objects::{ use crate::value_objects::{
BlockId, ChannelId, ContentType, FillStrategy, MediaFilter, MediaItemId, RecyclePolicy, SlotId, AccessMode, BlockId, ChannelId, ContentType, FillStrategy, MediaFilter, MediaItemId,
RecyclePolicy, SlotId,
}; };
/// A user in the system. /// A user in the system.
@@ -82,6 +83,8 @@ pub struct Channel {
pub schedule_config: ScheduleConfig, pub schedule_config: ScheduleConfig,
pub recycle_policy: RecyclePolicy, pub recycle_policy: RecyclePolicy,
pub auto_schedule: bool, pub auto_schedule: bool,
pub access_mode: AccessMode,
pub access_password_hash: Option<String>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
@@ -102,6 +105,8 @@ impl Channel {
schedule_config: ScheduleConfig::default(), schedule_config: ScheduleConfig::default(),
recycle_policy: RecyclePolicy::default(), recycle_policy: RecyclePolicy::default(),
auto_schedule: false, auto_schedule: false,
access_mode: AccessMode::default(),
access_password_hash: None,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
} }
@@ -176,6 +181,14 @@ pub struct ProgrammingBlock {
/// regardless of what other blocks aired. /// regardless of what other blocks aired.
#[serde(default)] #[serde(default)]
pub ignore_recycle_policy: bool, pub ignore_recycle_policy: bool,
/// Who can watch the stream during this block. Gates only /stream, not /now.
#[serde(default)]
pub access_mode: AccessMode,
/// Bcrypt/argon2 hash of the block password (when access_mode = PasswordProtected).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub access_password_hash: Option<String>,
} }
fn default_true() -> bool { fn default_true() -> bool {
@@ -198,6 +211,8 @@ impl ProgrammingBlock {
content: BlockContent::Algorithmic { filter, strategy }, content: BlockContent::Algorithmic { filter, strategy },
loop_on_finish: true, loop_on_finish: true,
ignore_recycle_policy: false, ignore_recycle_policy: false,
access_mode: AccessMode::default(),
access_password_hash: None,
} }
} }
@@ -215,6 +230,8 @@ impl ProgrammingBlock {
content: BlockContent::Manual { items }, content: BlockContent::Manual { items },
loop_on_finish: true, loop_on_finish: true,
ignore_recycle_policy: false, ignore_recycle_policy: false,
access_mode: AccessMode::default(),
access_password_hash: None,
} }
} }
} }

View File

@@ -1,6 +1,17 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
/// Controls who can view a channel's broadcast and stream.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AccessMode {
#[default]
Public,
PasswordProtected,
AccountRequired,
OwnerOnly,
}
/// Opaque media item identifier — format is provider-specific internally. /// Opaque media item identifier — format is provider-specific internally.
/// The domain never inspects the string; it just passes it back to the provider. /// The domain never inspects the string; it just passes it back to the provider.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]

View File

@@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
use sqlx::FromRow; use sqlx::FromRow;
use uuid::Uuid; use uuid::Uuid;
use domain::{Channel, ChannelId, DomainError, RecyclePolicy, ScheduleConfig, UserId}; use domain::{AccessMode, Channel, ChannelId, DomainError, RecyclePolicy, ScheduleConfig, UserId};
#[derive(Debug, FromRow)] #[derive(Debug, FromRow)]
pub(super) struct ChannelRow { pub(super) struct ChannelRow {
@@ -14,6 +14,8 @@ pub(super) struct ChannelRow {
pub schedule_config: String, pub schedule_config: String,
pub recycle_policy: String, pub recycle_policy: String,
pub auto_schedule: i64, pub auto_schedule: i64,
pub access_mode: String,
pub access_password_hash: Option<String>,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
@@ -44,6 +46,11 @@ impl TryFrom<ChannelRow> for Channel {
DomainError::RepositoryError(format!("Invalid recycle_policy JSON: {}", e)) DomainError::RepositoryError(format!("Invalid recycle_policy JSON: {}", e))
})?; })?;
let access_mode: AccessMode = serde_json::from_value(
serde_json::Value::String(row.access_mode),
)
.unwrap_or_default();
Ok(Channel { Ok(Channel {
id, id,
owner_id, owner_id,
@@ -53,6 +60,8 @@ impl TryFrom<ChannelRow> for Channel {
schedule_config, schedule_config,
recycle_policy, recycle_policy,
auto_schedule: row.auto_schedule != 0, auto_schedule: row.auto_schedule != 0,
access_mode,
access_password_hash: row.access_password_hash,
created_at: parse_dt(&row.created_at)?, created_at: parse_dt(&row.created_at)?,
updated_at: parse_dt(&row.updated_at)?, updated_at: parse_dt(&row.updated_at)?,
}) })
@@ -60,4 +69,4 @@ impl TryFrom<ChannelRow> for Channel {
} }
pub(super) const SELECT_COLS: &str = pub(super) const SELECT_COLS: &str =
"id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, created_at, updated_at"; "id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, created_at, updated_at";

View File

@@ -58,11 +58,16 @@ impl ChannelRepository for PostgresChannelRepository {
DomainError::RepositoryError(format!("Failed to serialize recycle_policy: {}", e)) DomainError::RepositoryError(format!("Failed to serialize recycle_policy: {}", e))
})?; })?;
let access_mode = serde_json::to_value(&channel.access_mode)
.ok()
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_else(|| "public".to_owned());
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO channels INSERT INTO channels
(id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, created_at, updated_at) (id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name = EXCLUDED.name, name = EXCLUDED.name,
description = EXCLUDED.description, description = EXCLUDED.description,
@@ -70,6 +75,8 @@ impl ChannelRepository for PostgresChannelRepository {
schedule_config = EXCLUDED.schedule_config, schedule_config = EXCLUDED.schedule_config,
recycle_policy = EXCLUDED.recycle_policy, recycle_policy = EXCLUDED.recycle_policy,
auto_schedule = EXCLUDED.auto_schedule, auto_schedule = EXCLUDED.auto_schedule,
access_mode = EXCLUDED.access_mode,
access_password_hash = EXCLUDED.access_password_hash,
updated_at = EXCLUDED.updated_at updated_at = EXCLUDED.updated_at
"#, "#,
) )
@@ -81,6 +88,8 @@ impl ChannelRepository for PostgresChannelRepository {
.bind(&schedule_config) .bind(&schedule_config)
.bind(&recycle_policy) .bind(&recycle_policy)
.bind(channel.auto_schedule as i64) .bind(channel.auto_schedule as i64)
.bind(&access_mode)
.bind(&channel.access_password_hash)
.bind(channel.created_at.to_rfc3339()) .bind(channel.created_at.to_rfc3339())
.bind(channel.updated_at.to_rfc3339()) .bind(channel.updated_at.to_rfc3339())
.execute(&self.pool) .execute(&self.pool)

View File

@@ -58,11 +58,16 @@ impl ChannelRepository for SqliteChannelRepository {
DomainError::RepositoryError(format!("Failed to serialize recycle_policy: {}", e)) DomainError::RepositoryError(format!("Failed to serialize recycle_policy: {}", e))
})?; })?;
let access_mode = serde_json::to_value(&channel.access_mode)
.ok()
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_else(|| "public".to_owned());
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO channels INSERT INTO channels
(id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, created_at, updated_at) (id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name = excluded.name, name = excluded.name,
description = excluded.description, description = excluded.description,
@@ -70,6 +75,8 @@ impl ChannelRepository for SqliteChannelRepository {
schedule_config = excluded.schedule_config, schedule_config = excluded.schedule_config,
recycle_policy = excluded.recycle_policy, recycle_policy = excluded.recycle_policy,
auto_schedule = excluded.auto_schedule, auto_schedule = excluded.auto_schedule,
access_mode = excluded.access_mode,
access_password_hash = excluded.access_password_hash,
updated_at = excluded.updated_at updated_at = excluded.updated_at
"#, "#,
) )
@@ -81,6 +88,8 @@ impl ChannelRepository for SqliteChannelRepository {
.bind(&schedule_config) .bind(&schedule_config)
.bind(&recycle_policy) .bind(&recycle_policy)
.bind(channel.auto_schedule as i64) .bind(channel.auto_schedule as i64)
.bind(&access_mode)
.bind(&channel.access_password_hash)
.bind(channel.created_at.to_rfc3339()) .bind(channel.created_at.to_rfc3339())
.bind(channel.updated_at.to_rfc3339()) .bind(channel.updated_at.to_rfc3339())
.execute(&self.pool) .execute(&self.pool)

View File

@@ -0,0 +1,2 @@
ALTER TABLE channels ADD COLUMN access_mode TEXT NOT NULL DEFAULT 'public';
ALTER TABLE channels ADD COLUMN access_password_hash TEXT;

View File

@@ -9,6 +9,7 @@ import {
DialogFooter, DialogFooter,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { AccessMode } from "@/lib/types";
interface CreateChannelDialogProps { interface CreateChannelDialogProps {
open: boolean; open: boolean;
@@ -17,6 +18,8 @@ interface CreateChannelDialogProps {
name: string; name: string;
timezone: string; timezone: string;
description: string; description: string;
access_mode?: AccessMode;
access_password?: string;
}) => void; }) => void;
isPending: boolean; isPending: boolean;
error?: string | null; error?: string | null;
@@ -32,10 +35,18 @@ export function CreateChannelDialog({
const [name, setName] = useState(""); const [name, setName] = useState("");
const [timezone, setTimezone] = useState("UTC"); const [timezone, setTimezone] = useState("UTC");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [accessMode, setAccessMode] = useState<AccessMode>("public");
const [accessPassword, setAccessPassword] = useState("");
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
onSubmit({ name, timezone, description }); onSubmit({
name,
timezone,
description,
access_mode: accessMode !== "public" ? accessMode : undefined,
access_password: accessMode === "password_protected" && accessPassword ? accessPassword : undefined,
});
}; };
const handleOpenChange = (next: boolean) => { const handleOpenChange = (next: boolean) => {
@@ -45,6 +56,8 @@ export function CreateChannelDialog({
setName(""); setName("");
setTimezone("UTC"); setTimezone("UTC");
setDescription(""); setDescription("");
setAccessMode("public");
setAccessPassword("");
} }
} }
}; };
@@ -99,6 +112,33 @@ export function CreateChannelDialog({
/> />
</div> </div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-zinc-400">Access</label>
<select
value={accessMode}
onChange={(e) => setAccessMode(e.target.value as AccessMode)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-zinc-500 focus:outline-none"
>
<option value="public">Public</option>
<option value="password_protected">Password protected</option>
<option value="account_required">Account required</option>
<option value="owner_only">Owner only</option>
</select>
</div>
{accessMode === "password_protected" && (
<div className="space-y-1.5">
<label className="block text-xs font-medium text-zinc-400">Password</label>
<input
type="password"
value={accessPassword}
onChange={(e) => setAccessPassword(e.target.value)}
placeholder="Channel password"
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
/>
</div>
)}
{error && <p className="text-xs text-red-400">{error}</p>} {error && <p className="text-xs text-red-400">{error}</p>}
<DialogFooter> <DialogFooter>

View File

@@ -11,6 +11,7 @@ import { SeriesPicker } from "./series-picker";
import { FilterPreview } from "./filter-preview"; import { FilterPreview } from "./filter-preview";
import { useCollections, useSeries, useGenres } from "@/hooks/use-library"; import { useCollections, useSeries, useGenres } from "@/hooks/use-library";
import type { import type {
AccessMode,
ChannelResponse, ChannelResponse,
ProgrammingBlock, ProgrammingBlock,
BlockContent, BlockContent,
@@ -42,6 +43,8 @@ const mediaFilterSchema = z.object({
search_term: z.string().nullable().optional(), search_term: z.string().nullable().optional(),
}); });
const accessModeSchema = z.enum(["public", "password_protected", "account_required", "owner_only"]);
const blockSchema = z.object({ const blockSchema = z.object({
id: z.string(), id: z.string(),
name: z.string().min(1, "Block name is required"), name: z.string().min(1, "Block name is required"),
@@ -60,6 +63,8 @@ const blockSchema = z.object({
]), ]),
loop_on_finish: z.boolean().optional(), loop_on_finish: z.boolean().optional(),
ignore_recycle_policy: z.boolean().optional(), ignore_recycle_policy: z.boolean().optional(),
access_mode: accessModeSchema.optional(),
access_password: z.string().optional(),
}); });
const channelFormSchema = z.object({ const channelFormSchema = z.object({
@@ -73,6 +78,8 @@ const channelFormSchema = z.object({
min_available_ratio: z.number().min(0, "Must be ≥ 0").max(1, "Must be ≤ 1"), min_available_ratio: z.number().min(0, "Must be ≥ 0").max(1, "Must be ≤ 1"),
}), }),
auto_schedule: z.boolean(), auto_schedule: z.boolean(),
access_mode: accessModeSchema.optional(),
access_password: z.string().optional(),
}); });
type FieldErrors = Record<string, string | undefined>; type FieldErrors = Record<string, string | undefined>;
@@ -216,6 +223,7 @@ function defaultBlock(startMins = 20 * 60, durationMins = 60): ProgrammingBlock
content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" }, content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" },
loop_on_finish: true, loop_on_finish: true,
ignore_recycle_policy: false, ignore_recycle_policy: false,
access_mode: "public",
}; };
} }
@@ -603,6 +611,29 @@ function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemo
<p className="text-[11px] text-zinc-600">One Jellyfin item ID per line, played in order.</p> <p className="text-[11px] text-zinc-600">One Jellyfin item ID per line, played in order.</p>
</div> </div>
)} )}
{/* Block-level access control */}
<div className="space-y-2 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Block access</p>
<NativeSelect
value={block.access_mode ?? "public"}
onChange={(v) => onChange({ ...block, access_mode: v as AccessMode })}
>
<option value="public">Public</option>
<option value="password_protected">Password protected</option>
<option value="account_required">Account required</option>
<option value="owner_only">Owner only</option>
</NativeSelect>
{(block.access_mode === "password_protected") && (
<input
type="password"
placeholder="Block password (leave blank to keep existing)"
value={block.access_password ?? ""}
onChange={(e) => onChange({ ...block, access_password: e.target.value })}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
/>
)}
</div>
</div> </div>
)} )}
</div> </div>
@@ -678,6 +709,8 @@ interface EditChannelSheetProps {
schedule_config: { blocks: ProgrammingBlock[] }; schedule_config: { blocks: ProgrammingBlock[] };
recycle_policy: RecyclePolicy; recycle_policy: RecyclePolicy;
auto_schedule: boolean; auto_schedule: boolean;
access_mode?: AccessMode;
access_password?: string;
}, },
) => void; ) => void;
isPending: boolean; isPending: boolean;
@@ -702,6 +735,8 @@ export function EditChannelSheet({
min_available_ratio: 0.1, min_available_ratio: 0.1,
}); });
const [autoSchedule, setAutoSchedule] = useState(false); const [autoSchedule, setAutoSchedule] = useState(false);
const [accessMode, setAccessMode] = useState<AccessMode>("public");
const [accessPassword, setAccessPassword] = useState("");
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null); const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({}); const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
@@ -713,6 +748,8 @@ export function EditChannelSheet({
setBlocks(channel.schedule_config.blocks); setBlocks(channel.schedule_config.blocks);
setRecyclePolicy(channel.recycle_policy); setRecyclePolicy(channel.recycle_policy);
setAutoSchedule(channel.auto_schedule); setAutoSchedule(channel.auto_schedule);
setAccessMode(channel.access_mode ?? "public");
setAccessPassword("");
setSelectedBlockId(null); setSelectedBlockId(null);
setFieldErrors({}); setFieldErrors({});
} }
@@ -723,7 +760,8 @@ export function EditChannelSheet({
if (!channel) return; if (!channel) return;
const result = channelFormSchema.safeParse({ const result = channelFormSchema.safeParse({
name, description, timezone, blocks, recycle_policy: recyclePolicy, auto_schedule: autoSchedule, name, description, timezone, blocks, recycle_policy: recyclePolicy,
auto_schedule: autoSchedule, access_mode: accessMode, access_password: accessPassword,
}); });
if (!result.success) { if (!result.success) {
@@ -739,6 +777,8 @@ export function EditChannelSheet({
schedule_config: { blocks }, schedule_config: { blocks },
recycle_policy: recyclePolicy, recycle_policy: recyclePolicy,
auto_schedule: autoSchedule, auto_schedule: autoSchedule,
access_mode: accessMode !== "public" ? accessMode : "public",
access_password: accessPassword || "",
}); });
}; };
@@ -822,6 +862,25 @@ export function EditChannelSheet({
/> />
</button> </button>
</label> </label>
<Field label="Channel access">
<NativeSelect value={accessMode} onChange={(v) => { setAccessMode(v as AccessMode); setAccessPassword(""); }}>
<option value="public">Public</option>
<option value="password_protected">Password protected</option>
<option value="account_required">Account required</option>
<option value="owner_only">Owner only</option>
</NativeSelect>
</Field>
{accessMode === "password_protected" && (
<Field label="Channel password" hint="Leave blank to keep existing password">
<TextInput
value={accessPassword}
onChange={setAccessPassword}
placeholder="New password…"
/>
</Field>
)}
</section> </section>
{/* Programming blocks */} {/* Programming blocks */}

View File

@@ -107,9 +107,17 @@ export default function DashboardPage() {
name: string; name: string;
timezone: string; timezone: string;
description: string; description: string;
access_mode?: import("@/lib/types").AccessMode;
access_password?: string;
}) => { }) => {
createChannel.mutate( createChannel.mutate(
{ name: data.name, timezone: data.timezone, description: data.description || undefined }, {
name: data.name,
timezone: data.timezone,
description: data.description || undefined,
access_mode: data.access_mode,
access_password: data.access_password,
},
{ onSuccess: () => setCreateOpen(false) }, { onSuccess: () => setCreateOpen(false) },
); );
}; };
@@ -123,6 +131,8 @@ export default function DashboardPage() {
schedule_config: { blocks: ProgrammingBlock[] }; schedule_config: { blocks: ProgrammingBlock[] };
recycle_policy: RecyclePolicy; recycle_policy: RecyclePolicy;
auto_schedule: boolean; auto_schedule: boolean;
access_mode?: import("@/lib/types").AccessMode;
access_password?: string;
}, },
) => { ) => {
updateChannel.mutate( updateChannel.mutate(

View File

@@ -0,0 +1,61 @@
"use client";
import { useState } from "react";
interface ChannelPasswordModalProps {
label: string;
onSubmit: (password: string) => void;
onCancel: () => void;
}
export function ChannelPasswordModal({
label,
onSubmit,
onCancel,
}: ChannelPasswordModalProps) {
const [value, setValue] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (value.trim()) onSubmit(value.trim());
};
return (
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<form
onSubmit={handleSubmit}
className="flex w-80 flex-col gap-4 rounded-xl border border-zinc-700 bg-zinc-900 p-6 shadow-2xl"
>
<div className="text-center">
<p className="text-sm font-semibold uppercase tracking-widest text-zinc-400">
{label}
</p>
</div>
<input
type="password"
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Enter password"
className="rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
/>
<div className="flex gap-2">
<button
type="button"
onClick={onCancel}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/60 px-4 py-2 text-xs text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-100"
>
Cancel
</button>
<button
type="submit"
disabled={!value.trim()}
className="flex-1 rounded-md bg-zinc-100 px-4 py-2 text-xs font-medium text-zinc-900 transition-colors hover:bg-white disabled:opacity-40"
>
Unlock
</button>
</div>
</form>
</div>
);
}

View File

@@ -16,3 +16,5 @@ export type { UpNextBannerProps } from "./up-next-banner";
export { NoSignal } from "./no-signal"; export { NoSignal } from "./no-signal";
export type { NoSignalProps, NoSignalVariant } from "./no-signal"; export type { NoSignalProps, NoSignalVariant } from "./no-signal";
export { ChannelPasswordModal } from "./channel-password-modal";

View File

@@ -1,6 +1,6 @@
import { WifiOff, AlertTriangle, Loader2 } from "lucide-react"; import { WifiOff, AlertTriangle, Loader2, Lock } from "lucide-react";
type NoSignalVariant = "no-signal" | "error" | "loading"; type NoSignalVariant = "no-signal" | "error" | "loading" | "locked";
interface NoSignalProps { interface NoSignalProps {
variant?: NoSignalVariant; variant?: NoSignalVariant;
@@ -27,6 +27,11 @@ const VARIANTS: Record<
heading: "Loading", heading: "Loading",
defaultMessage: "Tuning in…", defaultMessage: "Tuning in…",
}, },
locked: {
icon: <Lock className="h-10 w-10 text-zinc-600" />,
heading: "Access Restricted",
defaultMessage: "You don't have permission to watch this channel.",
},
}; };
export function NoSignal({ variant = "no-signal", message, children }: NoSignalProps) { export function NoSignal({ variant = "no-signal", message, children }: NoSignalProps) {

View File

@@ -10,6 +10,7 @@ import {
ScheduleOverlay, ScheduleOverlay,
UpNextBanner, UpNextBanner,
NoSignal, NoSignal,
ChannelPasswordModal,
} from "./components"; } from "./components";
import type { SubtitleTrack } from "./components/video-player"; import type { SubtitleTrack } from "./components/video-player";
import { Maximize2, Minimize2, Volume1, Volume2, VolumeX } from "lucide-react"; import { Maximize2, Minimize2, Volume1, Volume2, VolumeX } from "lucide-react";
@@ -76,6 +77,18 @@ function TvPageContent() {
// Video ref — used to resume playback if autoplay was blocked on load // Video ref — used to resume playback if autoplay was blocked on load
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
// Access control — persisted per channel in localStorage
const [channelPasswords, setChannelPasswords] = useState<Record<string, string>>(() => {
try { return JSON.parse(localStorage.getItem("channel_passwords") ?? "{}"); } catch { return {}; }
});
const [blockPasswords, setBlockPasswords] = useState<Record<string, string>>(() => {
try { return JSON.parse(localStorage.getItem("block_passwords") ?? "{}"); } catch { return {}; }
});
const [showChannelPasswordModal, setShowChannelPasswordModal] = useState(false);
const [showBlockPasswordModal, setShowBlockPasswordModal] = useState(false);
const channelPassword = channel ? channelPasswords[channel.id] : undefined;
// Stream error recovery // Stream error recovery
const [streamError, setStreamError] = useState(false); const [streamError, setStreamError] = useState(false);
@@ -160,10 +173,13 @@ function TvPageContent() {
}, []); }, []);
// Per-channel data // Per-channel data
const { data: broadcast, isLoading: isLoadingBroadcast } = const { data: broadcast, isLoading: isLoadingBroadcast, error: broadcastError } =
useCurrentBroadcast(channel?.id ?? ""); useCurrentBroadcast(channel?.id ?? "", channelPassword);
const { data: epgSlots } = useEpg(channel?.id ?? ""); const blockPassword = broadcast?.slot.id ? blockPasswords[broadcast.slot.id] : undefined;
const { data: streamUrl } = useStreamUrl(channel?.id, token, broadcast?.slot.id); const { data: epgSlots } = useEpg(channel?.id ?? "", undefined, undefined, channelPassword);
const { data: streamUrl, error: streamUrlError } = useStreamUrl(
channel?.id, token, broadcast?.slot.id, channelPassword, blockPassword,
);
// iOS Safari: track fullscreen state via webkit video element events. // iOS Safari: track fullscreen state via webkit video element events.
// Re-run when streamUrl changes so we catch the video element after it mounts. // Re-run when streamUrl changes so we catch the video element after it mounts.
@@ -180,6 +196,20 @@ function TvPageContent() {
}; };
}, [streamUrl]); }, [streamUrl]);
// Show channel password modal when broadcast returns password_required
useEffect(() => {
if ((broadcastError as Error)?.message === "password_required") {
setShowChannelPasswordModal(true);
}
}, [broadcastError]);
// Show block password modal when stream URL fetch returns password_required
useEffect(() => {
if ((streamUrlError as Error)?.message === "password_required") {
setShowBlockPasswordModal(true);
}
}, [streamUrlError]);
// Clear transient states when a new slot is detected // Clear transient states when a new slot is detected
useEffect(() => { useEffect(() => {
setStreamError(false); setStreamError(false);
@@ -354,6 +384,24 @@ function TvPageContent() {
setStreamError(false); setStreamError(false);
}, [queryClient, channel?.id, broadcast?.slot.id]); }, [queryClient, channel?.id, broadcast?.slot.id]);
const submitChannelPassword = useCallback((password: string) => {
if (!channel) return;
const next = { ...channelPasswords, [channel.id]: password };
setChannelPasswords(next);
try { localStorage.setItem("channel_passwords", JSON.stringify(next)); } catch {}
setShowChannelPasswordModal(false);
queryClient.invalidateQueries({ queryKey: ["broadcast", channel.id] });
}, [channel, channelPasswords, queryClient]);
const submitBlockPassword = useCallback((password: string) => {
if (!broadcast?.slot.id) return;
const next = { ...blockPasswords, [broadcast.slot.id]: password };
setBlockPasswords(next);
try { localStorage.setItem("block_passwords", JSON.stringify(next)); } catch {}
setShowBlockPasswordModal(false);
queryClient.invalidateQueries({ queryKey: ["stream-url", channel?.id, broadcast.slot.id] });
}, [broadcast?.slot.id, blockPasswords, channel?.id, queryClient]);
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Render helpers // Render helpers
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -370,6 +418,16 @@ function TvPageContent() {
/> />
); );
} }
// Channel-level access errors (not password — those show a modal)
const broadcastErrMsg = (broadcastError as Error)?.message;
if (broadcastErrMsg === "auth_required") {
return <NoSignal variant="locked" message="Sign in to watch this channel." />;
}
if (broadcastErrMsg && broadcastError && (broadcastError as { status?: number }).status === 403) {
return <NoSignal variant="locked" message="This channel is owner-only." />;
}
if (isLoadingBroadcast) { if (isLoadingBroadcast) {
return <NoSignal variant="loading" message="Tuning in…" />; return <NoSignal variant="loading" message="Tuning in…" />;
} }
@@ -381,6 +439,16 @@ function TvPageContent() {
/> />
); );
} }
// Block-level access errors (not password — those show a modal overlay)
const streamErrMsg = (streamUrlError as Error)?.message;
if (streamErrMsg === "auth_required") {
return <NoSignal variant="locked" message="Sign in to watch this block." />;
}
if (streamUrlError && (streamUrlError as { status?: number }).status === 403) {
return <NoSignal variant="locked" message="This block is owner-only." />;
}
if (streamError) { if (streamError) {
return ( return (
<NoSignal variant="error" message="Stream failed to load."> <NoSignal variant="error" message="Stream failed to load.">
@@ -431,6 +499,24 @@ function TvPageContent() {
{/* ── Base layer ─────────────────────────────────────────────── */} {/* ── Base layer ─────────────────────────────────────────────── */}
<div className="absolute inset-0">{renderBase()}</div> <div className="absolute inset-0">{renderBase()}</div>
{/* ── Channel password modal ──────────────────────────────────── */}
{showChannelPasswordModal && (
<ChannelPasswordModal
label="Channel password required"
onSubmit={submitChannelPassword}
onCancel={() => setShowChannelPasswordModal(false)}
/>
)}
{/* ── Block password modal ────────────────────────────────────── */}
{showBlockPasswordModal && (
<ChannelPasswordModal
label="Block password required"
onSubmit={submitBlockPassword}
onCancel={() => setShowBlockPasswordModal(false)}
/>
)}
{/* ── Autoplay blocked prompt ─────────────────────────────────── */} {/* ── Autoplay blocked prompt ─────────────────────────────────── */}
{needsInteraction && ( {needsInteraction && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center"> <div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">

View File

@@ -26,11 +26,15 @@ 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");
const channelPassword = request.nextUrl.searchParams.get("channel_password");
const blockPassword = request.nextUrl.searchParams.get("block_password");
let res: Response; let res: Response;
try { try {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
if (channelPassword) headers["X-Channel-Password"] = channelPassword;
if (blockPassword) headers["X-Block-Password"] = blockPassword;
res = await fetch(`${API_URL}/channels/${channelId}/stream`, { res = await fetch(`${API_URL}/channels/${channelId}/stream`, {
headers, headers,
redirect: "manual", redirect: "manual",
@@ -43,6 +47,11 @@ export async function GET(
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
if (res.status === 401 || res.status === 403) {
const body = await res.json().catch(() => ({}));
return Response.json(body, { status: res.status });
}
if (res.status === 307 || res.status === 302 || res.status === 301) { if (res.status === 307 || res.status === 302 || res.status === 301) {
const location = res.headers.get("Location"); const location = res.headers.get("Location");
if (location) { if (location) {

View File

@@ -98,22 +98,22 @@ export function useActiveSchedule(channelId: string) {
}); });
} }
export function useCurrentBroadcast(channelId: string) { export function useCurrentBroadcast(channelId: string, channelPassword?: string) {
const { token } = useAuthContext(); const { token } = useAuthContext();
return useQuery({ return useQuery({
queryKey: ["broadcast", channelId], queryKey: ["broadcast", channelId, channelPassword],
queryFn: () => api.schedule.getCurrentBroadcast(channelId, token ?? ""), queryFn: () => api.schedule.getCurrentBroadcast(channelId, token ?? "", channelPassword),
enabled: !!channelId, enabled: !!channelId,
refetchInterval: 30_000, refetchInterval: 30_000,
retry: false, retry: false,
}); });
} }
export function useEpg(channelId: string, from?: string, until?: string) { export function useEpg(channelId: string, from?: string, until?: string, channelPassword?: string) {
const { token } = useAuthContext(); const { token } = useAuthContext();
return useQuery({ return useQuery({
queryKey: ["epg", channelId, from, until], queryKey: ["epg", channelId, from, until, channelPassword],
queryFn: () => api.schedule.getEpg(channelId, token ?? "", from, until), queryFn: () => api.schedule.getEpg(channelId, token ?? "", from, until, channelPassword),
enabled: !!channelId, enabled: !!channelId,
}); });
} }

View File

@@ -130,17 +130,25 @@ export function useStreamUrl(
channelId: string | undefined, channelId: string | undefined,
token: string | null, token: string | null,
slotId: string | undefined, slotId: string | undefined,
channelPassword?: string,
blockPassword?: string,
) { ) {
return useQuery({ return useQuery({
queryKey: ["stream-url", channelId, slotId], queryKey: ["stream-url", channelId, slotId, channelPassword, blockPassword],
queryFn: async (): Promise<string | null> => { queryFn: async (): Promise<string | null> => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (token) params.set("token", token); if (token) params.set("token", token);
if (channelPassword) params.set("channel_password", channelPassword);
if (blockPassword) params.set("block_password", blockPassword);
const res = await fetch(`/api/stream/${channelId}?${params}`, { const res = await fetch(`/api/stream/${channelId}?${params}`, {
cache: "no-store", cache: "no-store",
}); });
if (res.status === 204) return null; if (res.status === 204) return null;
if (!res.ok) throw new Error(`Stream resolve failed: ${res.status}`); if (!res.ok) {
const body = await res.json().catch(() => ({}));
const msg = body?.error ?? `Stream resolve failed: ${res.status}`;
throw new Error(msg);
}
const { url } = await res.json(); const { url } = await res.json();
return url as string; return url as string;
}, },

View File

@@ -152,24 +152,31 @@ export const api = {
getActive: (channelId: string, token: string) => getActive: (channelId: string, token: string) =>
request<ScheduleResponse>(`/channels/${channelId}/schedule`, { token }), request<ScheduleResponse>(`/channels/${channelId}/schedule`, { token }),
getCurrentBroadcast: (channelId: string, token: string) => getCurrentBroadcast: (channelId: string, token: string, channelPassword?: string) => {
request<CurrentBroadcastResponse | null>(`/channels/${channelId}/now`, { const headers: Record<string, string> = {};
if (channelPassword) headers["X-Channel-Password"] = channelPassword;
return request<CurrentBroadcastResponse | null>(`/channels/${channelId}/now`, {
token, token,
}), headers,
});
},
getEpg: ( getEpg: (
channelId: string, channelId: string,
token: string, token: string,
from?: string, from?: string,
until?: string, until?: string,
channelPassword?: string,
) => { ) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (from) params.set("from", from); if (from) params.set("from", from);
if (until) params.set("until", until); if (until) params.set("until", until);
const qs = params.toString(); const qs = params.toString();
const headers: Record<string, string> = {};
if (channelPassword) headers["X-Channel-Password"] = channelPassword;
return request<ScheduledSlotResponse[]>( return request<ScheduledSlotResponse[]>(
`/channels/${channelId}/epg${qs ? `?${qs}` : ""}`, `/channels/${channelId}/epg${qs ? `?${qs}` : ""}`,
{ token }, { token, headers },
); );
}, },
}, },

View File

@@ -2,6 +2,8 @@
export type ContentType = "movie" | "episode" | "short"; export type ContentType = "movie" | "episode" | "short";
export type AccessMode = "public" | "password_protected" | "account_required" | "owner_only";
export type FillStrategy = "best_fit" | "sequential" | "random"; export type FillStrategy = "best_fit" | "sequential" | "random";
export interface MediaFilter { export interface MediaFilter {
@@ -67,6 +69,9 @@ export interface ProgrammingBlock {
loop_on_finish?: boolean; loop_on_finish?: boolean;
/** When true, skip the channel-level recycle policy for this block. Default false on backend. */ /** When true, skip the channel-level recycle policy for this block. Default false on backend. */
ignore_recycle_policy?: boolean; ignore_recycle_policy?: boolean;
access_mode?: AccessMode;
/** Plain-text password sent to API; hashed server-side. Only set on write operations. */
access_password?: string;
} }
export interface ScheduleConfig { export interface ScheduleConfig {
@@ -104,6 +109,7 @@ export interface ChannelResponse {
schedule_config: ScheduleConfig; schedule_config: ScheduleConfig;
recycle_policy: RecyclePolicy; recycle_policy: RecyclePolicy;
auto_schedule: boolean; auto_schedule: boolean;
access_mode: AccessMode;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -112,6 +118,8 @@ export interface CreateChannelRequest {
name: string; name: string;
timezone: string; timezone: string;
description?: string; description?: string;
access_mode?: AccessMode;
access_password?: string;
} }
export interface UpdateChannelRequest { export interface UpdateChannelRequest {
@@ -121,6 +129,9 @@ export interface UpdateChannelRequest {
schedule_config?: ScheduleConfig; schedule_config?: ScheduleConfig;
recycle_policy?: RecyclePolicy; recycle_policy?: RecyclePolicy;
auto_schedule?: boolean; auto_schedule?: boolean;
access_mode?: AccessMode;
/** Empty string clears the password. */
access_password?: string;
} }
// Media & Schedule // Media & Schedule
@@ -150,6 +161,7 @@ export interface ScheduledSlotResponse {
start_at: string; start_at: string;
/** RFC3339 */ /** RFC3339 */
end_at: string; end_at: string;
block_access_mode: AccessMode;
} }
export interface ScheduleResponse { export interface ScheduleResponse {
@@ -165,4 +177,5 @@ export interface ScheduleResponse {
export interface CurrentBroadcastResponse { export interface CurrentBroadcastResponse {
slot: ScheduledSlotResponse; slot: ScheduledSlotResponse;
offset_secs: number; offset_secs: number;
block_access_mode: AccessMode;
} }