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:
@@ -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)]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
offset_secs: broadcast.offset_secs,
|
.schedule_config
|
||||||
})
|
.blocks
|
||||||
.into_response()),
|
.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,
|
||||||
|
})
|
||||||
|
.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)
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -58,19 +58,26 @@ 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,
|
||||||
timezone = EXCLUDED.timezone,
|
timezone = EXCLUDED.timezone,
|
||||||
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,
|
||||||
updated_at = EXCLUDED.updated_at
|
access_mode = EXCLUDED.access_mode,
|
||||||
|
access_password_hash = EXCLUDED.access_password_hash,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(channel.id.to_string())
|
.bind(channel.id.to_string())
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -58,19 +58,26 @@ 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,
|
||||||
timezone = excluded.timezone,
|
timezone = excluded.timezone,
|
||||||
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,
|
||||||
updated_at = excluded.updated_at
|
access_mode = excluded.access_mode,
|
||||||
|
access_password_hash = excluded.access_password_hash,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(channel.id.to_string())
|
.bind(channel.id.to_string())
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user