From d1113effa53e97d4ec47ae5c35b521f854f19106 Mon Sep 17 00:00:00 2001 From: AB Date: Thu, 28 May 2026 17:34:37 +0300 Subject: [PATCH] PLAYER: Added users media editor --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/player/dto.rs | 164 ++- src/player/mod.rs | 2034 ++++++++++++++++++++++++++++++++- src/player/queries.rs | 11 + src/player/rows.rs | 79 ++ templates/player/modals.html | 281 +++++ templates/player/scripts.html | 653 ++++++++++- templates/player/shell.html | 69 +- templates/player/styles.html | 827 +++++++++++++- 10 files changed, 4053 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5823493..35865bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.2.2" +version = "0.2.3" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 47ae974..28eee72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.2.3" +version = "0.2.4" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/player/dto.rs b/src/player/dto.rs index 0a406dc..885e045 100644 --- a/src/player/dto.rs +++ b/src/player/dto.rs @@ -40,13 +40,13 @@ pub(super) struct ArtistDetail { pub(super) featured_tracks: Vec, } -#[derive(Debug, Serialize, JsonSchema)] +#[derive(Debug, Clone, Serialize, JsonSchema)] pub(super) struct ArtistRef { pub(super) id: i64, pub(super) name: String, } -#[derive(Debug, Serialize, JsonSchema)] +#[derive(Debug, Clone, Serialize, JsonSchema)] pub(super) struct TrackItem { pub(super) id: i64, pub(super) title: String, @@ -141,6 +141,7 @@ pub(super) struct PlaybackStateDto { pub(super) struct DeviceHeartbeatRequest { pub(super) device_id: String, pub(super) user_agent: Option, + pub(super) current_jam_id: Option, pub(super) playback_state: Option, } @@ -153,6 +154,7 @@ pub(super) struct DeviceSelectRequest { #[derive(Debug, Deserialize, JsonSchema)] pub(super) struct DeviceCommandRequest { pub(super) target_device_id: Option, + pub(super) jam_id: Option, pub(super) command: String, #[serde(default)] pub(super) payload: serde_json::Value, @@ -168,6 +170,48 @@ pub(super) struct PlayerDeviceDto { pub(super) last_seen_ms: i64, } +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct PlayerJamDto { + pub(super) id: String, + pub(super) name: String, + pub(super) host_user_id: i64, + pub(super) host_name: String, + pub(super) is_owner: bool, + pub(super) is_member: bool, + pub(super) is_pending: bool, + pub(super) is_active: bool, + pub(super) member_count: i64, + pub(super) host_last_seen_ms: i64, + pub(super) host_device_online: bool, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub(super) struct PlayerJamCreateRequest { + pub(super) device_id: String, + #[serde(default)] + pub(super) invitee_user_ids: Vec, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub(super) struct PlayerJamJoinRequest { + pub(super) jam_id: String, + pub(super) device_id: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub(super) struct PlayerJamLeaveRequest { + pub(super) jam_id: String, + pub(super) device_id: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct PlayerJamUserDto { + pub(super) id: i64, + pub(super) username: String, + pub(super) display_name: Option, + pub(super) email: Option, +} + #[derive(Debug, Serialize, JsonSchema)] pub(super) struct PlayerDeviceCommandDto { pub(super) id: String, @@ -196,6 +240,8 @@ pub(super) struct PlayerDevicesResponse { pub(super) device_id: String, pub(super) active_device_id: Option, pub(super) devices: Vec, + pub(super) jams: Vec, + pub(super) current_jam_id: Option, pub(super) playback_state: Option, } @@ -204,6 +250,8 @@ pub(super) struct PlayerDevicePollResponse { pub(super) device_id: String, pub(super) active_device_id: Option, pub(super) devices: Vec, + pub(super) jams: Vec, + pub(super) current_jam_id: Option, pub(super) commands: Vec, pub(super) playback_state: Option, } @@ -278,6 +326,118 @@ pub(super) struct AgentQueueStatus { pub(super) processing_count: i64, } +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub(super) struct UserUploadTrack { + pub(super) track: TrackItem, + pub(super) media_file_id: i64, + pub(super) is_hidden: bool, + pub(super) release_is_hidden: bool, + pub(super) release_type: String, + pub(super) year: Option, + pub(super) uploaded_at: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct UserUploadRelease { + pub(super) id: i64, + pub(super) title: String, + pub(super) release_type: String, + pub(super) year: Option, + pub(super) is_hidden: bool, + pub(super) artists: Vec, + pub(super) tracks: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct UserUploadReviewFields { + pub(super) title: String, + pub(super) artist: String, + pub(super) album: String, + pub(super) year: String, + pub(super) track_number: String, + pub(super) genre: String, + pub(super) featured_artists: Vec, + pub(super) release_type: String, + pub(super) notes: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct UserUploadReviewItem { + pub(super) id: i64, + pub(super) status: String, + pub(super) filename: String, + pub(super) created_at: String, + pub(super) updated_at: String, + pub(super) error_message: Option, + pub(super) fields: UserUploadReviewFields, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct UserUploadQueueItem { + pub(super) id: i64, + pub(super) status: String, + pub(super) filename: String, + pub(super) created_at: String, + pub(super) updated_at: String, + pub(super) error_message: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct UserUploadsPage { + pub(super) tracks: Vec, + pub(super) releases: Vec, + pub(super) pending: Vec, + pub(super) queued: Vec, + pub(super) pending_total: i64, + pub(super) queued_total: i64, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub(super) struct UserUploadTrackUpdateRequest { + pub(super) title: Option, + pub(super) artist_names: Option>, + pub(super) featured_artist_names: Option>, + pub(super) release_title: Option, + pub(super) release_type: Option, + pub(super) release_year: Option, + pub(super) track_number: Option, + pub(super) disc_number: Option, + pub(super) is_hidden: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub(super) struct UserUploadReleaseUpdateRequest { + pub(super) title: Option, + pub(super) artist_names: Option>, + pub(super) release_type: Option, + pub(super) year: Option, + pub(super) is_hidden: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub(super) struct UserUploadBulkTrackUpdateRequest { + pub(super) track_ids: Vec, + pub(super) artist_names: Option>, + pub(super) featured_artist_names: Option>, + pub(super) release_title: Option, + pub(super) release_type: Option, + pub(super) release_year: Option, + pub(super) is_hidden: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub(super) struct UserUploadReviewUpdateRequest { + pub(super) title: Option, + pub(super) artist: Option, + pub(super) album: Option, + pub(super) year: Option, + pub(super) track_number: Option, + pub(super) genre: Option, + pub(super) featured_artists: Option>, + pub(super) release_type: Option, + pub(super) notes: Option, +} + #[derive(Debug, Serialize, JsonSchema)] pub(super) struct PlayHistoryItem { pub(super) id: i64, diff --git a/src/player/mod.rs b/src/player/mod.rs index 07475a5..4195465 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::{Arc, Mutex}; use cot::db::Database; @@ -7,7 +7,7 @@ use cot::http::header::{ ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, HeaderName, RANGE, }; use cot::json::Json; -use cot::request::extractors::Path; +use cot::request::extractors::{Path, UrlQuery}; use cot::response::IntoResponse; use cot::router::method::{get, post}; use cot::router::{Route, Router}; @@ -54,6 +54,8 @@ struct LocalUploadResponse { const PLAYER_DEVICE_TTL_MS: i64 = 30_000; const PLAYER_DEVICE_COMMAND_TTL_MS: i64 = 20_000; const PLAYER_DEVICE_MAX_COMMANDS: usize = 32; +const PLAYER_JAM_IDLE_TTL_MS: i64 = 4 * 60 * 60 * 1000; +const PLAYER_JAM_MAX_INVITEES: usize = 25; #[derive(Debug, Clone)] struct PlayerDevice { @@ -71,12 +73,34 @@ struct PendingPlayerDeviceCommand { created_at_ms: i64, } +#[derive(Debug, Clone, PartialEq, Eq)] +enum PlayerJamMemberStatus { + Invited, + Joined, +} + +#[derive(Debug, Clone)] +struct PlayerJamMember { + status: PlayerJamMemberStatus, + last_seen_ms: i64, +} + +#[derive(Debug, Clone)] +struct PlayerJamSession { + id: String, + host_user_id: i64, + host_name: String, + host_last_seen_ms: i64, + members: HashMap, +} + #[derive(Debug, Default)] struct PlayerDeviceHubState { devices_by_user: HashMap>, active_device_by_user: HashMap, commands_by_device: HashMap<(i64, String), VecDeque>, playback_state_by_user: HashMap, + jams_by_id: HashMap, } #[derive(Debug, Default)] @@ -90,6 +114,7 @@ impl PlayerDeviceHub { user_id: i64, device_id: &str, user_agent: Option<&str>, + current_jam_id: Option<&str>, playback_state: Option, ) -> PlayerDevicesResponse { let now = current_millis(); @@ -97,7 +122,8 @@ impl PlayerDeviceHub { self.prune_locked(&mut state, now); self.touch_locked(&mut state, user_id, device_id, user_agent, now); self.update_playback_state_locked(&mut state, user_id, device_id, playback_state, now); - self.snapshot_locked(&state, user_id, device_id, now) + self.touch_jam_locked(&mut state, user_id, device_id, current_jam_id, now); + self.snapshot_locked(&state, user_id, device_id, current_jam_id, now) } fn poll( @@ -105,6 +131,7 @@ impl PlayerDeviceHub { user_id: i64, device_id: &str, user_agent: Option<&str>, + current_jam_id: Option<&str>, playback_state: Option, ) -> PlayerDevicePollResponse { let now = current_millis(); @@ -112,6 +139,7 @@ impl PlayerDeviceHub { self.prune_locked(&mut state, now); self.touch_locked(&mut state, user_id, device_id, user_agent, now); self.update_playback_state_locked(&mut state, user_id, device_id, playback_state, now); + self.touch_jam_locked(&mut state, user_id, device_id, current_jam_id, now); let commands = state .commands_by_device .remove(&(user_id, device_id.to_string())) @@ -123,11 +151,13 @@ impl PlayerDeviceHub { payload: cmd.payload, }) .collect(); - let snapshot = self.snapshot_locked(&state, user_id, device_id, now); + let snapshot = self.snapshot_locked(&state, user_id, device_id, current_jam_id, now); PlayerDevicePollResponse { device_id: snapshot.device_id, active_device_id: snapshot.active_device_id, devices: snapshot.devices, + jams: snapshot.jams, + current_jam_id: snapshot.current_jam_id, commands, playback_state: snapshot.playback_state, } @@ -172,13 +202,14 @@ impl PlayerDeviceHub { } } } - Some(self.snapshot_locked(&state, user_id, current_device_id, now)) + Some(self.snapshot_locked(&state, user_id, current_device_id, None, now)) } fn enqueue_command( &self, user_id: i64, target_device_id: Option<&str>, + jam_id: Option<&str>, command: &str, payload: serde_json::Value, ) -> Result<(), &'static str> { @@ -186,24 +217,44 @@ impl PlayerDeviceHub { let mut state = self.state.lock().expect("player device hub lock"); self.prune_locked(&mut state, now); - let target_id = match target_device_id { - Some(id) => id.to_string(), - None => state - .active_device_by_user + let (target_user_id, target_id) = if let Some(jam_id) = jam_id { + let jam = state.jams_by_id.get(jam_id).ok_or("jam is not available")?; + let member = jam.members.get(&user_id).ok_or("jam is not available")?; + if member.status != PlayerJamMemberStatus::Joined { + return Err("join the jam first"); + } + let target_id = self + .jam_target_device_id_locked(&state, jam) + .ok_or("jam playback device is offline")?; + (jam.host_user_id, target_id) + } else { + let target_id = match target_device_id { + Some(id) => id.to_string(), + None => state + .active_device_by_user + .get(&user_id) + .cloned() + .ok_or("no active device")?, + }; + + let devices = state + .devices_by_user .get(&user_id) - .cloned() - .ok_or("no active device")?, + .ok_or("target device is offline")?; + if !devices.contains_key(&target_id) { + return Err("target device is offline"); + } + (user_id, target_id) }; - let devices = state - .devices_by_user - .get(&user_id) - .ok_or("target device is offline")?; - if !devices.contains_key(&target_id) { - return Err("target device is offline"); - } - - self.enqueue_command_locked(&mut state, user_id, &target_id, command, payload, now); + self.enqueue_command_locked( + &mut state, + target_user_id, + &target_id, + command, + payload, + now, + ); Ok(()) } @@ -279,6 +330,7 @@ impl PlayerDeviceHub { }; playback_state.updated_at_ms = now; state.playback_state_by_user.insert(user_id, playback_state); + self.touch_host_jams_locked(state, user_id, device_id, now); } fn snapshot_locked( @@ -286,9 +338,12 @@ impl PlayerDeviceHub { state: &PlayerDeviceHubState, user_id: i64, current_device_id: &str, + current_jam_id: Option<&str>, now: i64, ) -> PlayerDevicesResponse { let active_device_id = state.active_device_by_user.get(&user_id).cloned(); + let current_jam_id = current_jam_id + .filter(|jam_id| self.jam_accessible_locked(state, user_id, jam_id, false)); let mut devices: Vec = state .devices_by_user .get(&user_id) @@ -316,11 +371,278 @@ impl PlayerDeviceHub { device_id: current_device_id.to_string(), active_device_id, devices, - playback_state: state.playback_state_by_user.get(&user_id).cloned(), + jams: self.jam_dtos_locked(state, user_id, current_jam_id, now), + current_jam_id: current_jam_id.map(str::to_string), + playback_state: self.playback_state_for_context_locked( + state, + user_id, + current_jam_id, + now, + ), } } + fn create_jam( + &self, + host_user_id: i64, + host_name: &str, + current_device_id: &str, + invitees: Vec<(i64, String)>, + ) -> Result { + let now = current_millis(); + let mut state = self.state.lock().expect("player device hub lock"); + self.prune_locked(&mut state, now); + + let devices = state + .devices_by_user + .get(&host_user_id) + .ok_or("current device is offline")?; + if !devices.contains_key(current_device_id) { + return Err("current device is offline"); + } + + state + .active_device_by_user + .insert(host_user_id, current_device_id.to_string()); + + let mut seen = HashSet::new(); + let mut members = HashMap::new(); + members.insert( + host_user_id, + PlayerJamMember { + status: PlayerJamMemberStatus::Joined, + last_seen_ms: now, + }, + ); + seen.insert(host_user_id); + + for (user_id, _name) in invitees.into_iter().take(PLAYER_JAM_MAX_INVITEES) { + if !seen.insert(user_id) { + continue; + } + members.insert( + user_id, + PlayerJamMember { + status: PlayerJamMemberStatus::Invited, + last_seen_ms: 0, + }, + ); + } + + let jam_id = uuid::Uuid::new_v4().simple().to_string(); + let jam = PlayerJamSession { + id: jam_id.clone(), + host_user_id, + host_name: host_name.to_string(), + host_last_seen_ms: now, + members, + }; + state.jams_by_id.insert(jam_id.clone(), jam); + Ok(self.snapshot_locked(&state, host_user_id, current_device_id, Some(&jam_id), now)) + } + + fn join_jam( + &self, + user_id: i64, + device_id: &str, + jam_id: &str, + ) -> Result { + let now = current_millis(); + let mut state = self.state.lock().expect("player device hub lock"); + self.prune_locked(&mut state, now); + + let Some(jam) = state.jams_by_id.get_mut(jam_id) else { + return Err("jam is not available"); + }; + let Some(member) = jam.members.get_mut(&user_id) else { + return Err("jam is not available"); + }; + member.status = PlayerJamMemberStatus::Joined; + member.last_seen_ms = now; + if user_id == jam.host_user_id { + jam.host_last_seen_ms = now; + } + + Ok(self.snapshot_locked(&state, user_id, device_id, Some(jam_id), now)) + } + + fn leave_jam( + &self, + user_id: i64, + device_id: &str, + jam_id: &str, + ) -> Result { + let now = current_millis(); + let mut state = self.state.lock().expect("player device hub lock"); + self.prune_locked(&mut state, now); + + let Some(jam) = state.jams_by_id.get(jam_id) else { + return Ok(self.snapshot_locked(&state, user_id, device_id, None, now)); + }; + if !jam.members.contains_key(&user_id) { + return Err("jam is not available"); + } + if jam.host_user_id == user_id { + state.jams_by_id.remove(jam_id); + } else if let Some(jam) = state.jams_by_id.get_mut(jam_id) { + jam.members.remove(&user_id); + } + + Ok(self.snapshot_locked(&state, user_id, device_id, None, now)) + } + + fn touch_jam_locked( + &self, + state: &mut PlayerDeviceHubState, + user_id: i64, + device_id: &str, + current_jam_id: Option<&str>, + now: i64, + ) { + let Some(jam_id) = current_jam_id else { + return; + }; + let is_active_host_device = state + .active_device_by_user + .get(&user_id) + .is_some_and(|active_id| active_id == device_id); + let Some(jam) = state.jams_by_id.get_mut(jam_id) else { + return; + }; + let Some(member) = jam.members.get_mut(&user_id) else { + return; + }; + member.last_seen_ms = now; + if member.status == PlayerJamMemberStatus::Invited { + return; + } + if user_id == jam.host_user_id && is_active_host_device { + jam.host_last_seen_ms = now; + } + } + + fn touch_host_jams_locked( + &self, + state: &mut PlayerDeviceHubState, + user_id: i64, + device_id: &str, + now: i64, + ) { + let is_active = state + .active_device_by_user + .get(&user_id) + .is_some_and(|active_id| active_id == device_id); + if !is_active { + return; + } + for jam in state.jams_by_id.values_mut() { + if jam.host_user_id == user_id { + jam.host_last_seen_ms = now; + if let Some(member) = jam.members.get_mut(&user_id) { + member.last_seen_ms = now; + } + } + } + } + + fn jam_accessible_locked( + &self, + state: &PlayerDeviceHubState, + user_id: i64, + jam_id: &str, + require_joined: bool, + ) -> bool { + let Some(jam) = state.jams_by_id.get(jam_id) else { + return false; + }; + let Some(member) = jam.members.get(&user_id) else { + return false; + }; + !require_joined || member.status == PlayerJamMemberStatus::Joined + } + + fn jam_target_device_id_locked( + &self, + state: &PlayerDeviceHubState, + jam: &PlayerJamSession, + ) -> Option { + let active_device_id = state.active_device_by_user.get(&jam.host_user_id)?; + let host_devices = state.devices_by_user.get(&jam.host_user_id)?; + host_devices + .contains_key(active_device_id) + .then(|| active_device_id.clone()) + } + + fn playback_state_for_context_locked( + &self, + state: &PlayerDeviceHubState, + user_id: i64, + current_jam_id: Option<&str>, + now: i64, + ) -> Option { + let playback_user_id = current_jam_id + .and_then(|jam_id| state.jams_by_id.get(jam_id)) + .and_then(|jam| { + jam.members.get(&user_id).and_then(|member| { + (member.status == PlayerJamMemberStatus::Joined).then_some(jam.host_user_id) + }) + }) + .unwrap_or(user_id); + state + .playback_state_by_user + .get(&playback_user_id) + .cloned() + .map(|playback_state| playback_state_at(playback_state, now)) + } + + fn jam_dtos_locked( + &self, + state: &PlayerDeviceHubState, + user_id: i64, + current_jam_id: Option<&str>, + now: i64, + ) -> Vec { + let mut jams: Vec = state + .jams_by_id + .values() + .filter_map(|jam| { + let member = jam.members.get(&user_id)?; + let member_count = jam + .members + .values() + .filter(|member| member.status == PlayerJamMemberStatus::Joined) + .count() as i64; + let host_device_online = self.jam_target_device_id_locked(state, jam).is_some(); + Some(PlayerJamDto { + id: jam.id.clone(), + name: format!("{}'s Jam", jam.host_name), + host_user_id: jam.host_user_id, + host_name: jam.host_name.clone(), + is_owner: jam.host_user_id == user_id, + is_member: member.status == PlayerJamMemberStatus::Joined, + is_pending: member.status == PlayerJamMemberStatus::Invited, + is_active: current_jam_id == Some(jam.id.as_str()), + member_count, + host_last_seen_ms: now.saturating_sub(jam.host_last_seen_ms), + host_device_online, + }) + }) + .collect(); + jams.sort_by(|a, b| { + b.is_active + .cmp(&a.is_active) + .then_with(|| b.is_owner.cmp(&a.is_owner)) + .then_with(|| b.is_pending.cmp(&a.is_pending)) + .then_with(|| a.name.cmp(&b.name)) + }); + jams + } + fn prune_locked(&self, state: &mut PlayerDeviceHubState, now: i64) { + state + .jams_by_id + .retain(|_, jam| now.saturating_sub(jam.host_last_seen_ms) <= PLAYER_JAM_IDLE_TTL_MS); + state.devices_by_user.retain(|user_id, devices| { devices.retain(|_, device| { now.saturating_sub(device.last_seen_ms) <= PLAYER_DEVICE_TTL_MS @@ -1072,6 +1394,1262 @@ async fn agent_queue_handler( .into_response() } +// --------------------------------------------------------------------------- +// User-uploaded tracks +// --------------------------------------------------------------------------- + +async fn user_uploads_handler( + session: Session, + db: Database, + pool: &sqlx::PgPool, + UrlQuery(query): UrlQuery, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + let limit = query.limit.unwrap_or(120).clamp(1, 500); + let page = load_user_uploads_page(pool, user.id, limit as i64).await?; + Json(page).into_response() +} + +async fn user_upload_track_update_handler( + session: Session, + db: Database, + pool: &sqlx::PgPool, + path: Path, + Json(body): Json, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + let track_id = path.0.track_id; + let Some(existing) = sqlx::query_as::<_, UploadTrackEditRow>( + r#"SELECT t.release_id, + t.title::text AS title, + t.track_number, + t.disc_number, + t.is_hidden, + r.title::text AS release_title, + r.release_type::text AS release_type, + r.year AS release_year + FROM furumusic__track t + JOIN furumusic__media_file mf ON mf.id = t.audio_file_id + JOIN furumusic__release r ON r.id = t.release_id + WHERE t.id = $1 AND mf.uploaded_by_user_id = $2"#, + ) + .bind(track_id) + .bind(user.id) + .fetch_optional(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))? + else { + return Ok(json_error( + StatusCode::NOT_FOUND, + "uploaded track not found", + )); + }; + + let title = match clean_required_string(body.title.as_deref(), &existing.title, 255) { + Ok(value) => value, + Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, message)), + }; + let release_title = + match clean_required_string(body.release_title.as_deref(), &existing.release_title, 255) { + Ok(value) => value, + Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, message)), + }; + let release_type = body + .release_type + .as_deref() + .map(normalize_release_type) + .unwrap_or_else(|| existing.release_type.clone()); + let release_year = match parse_optional_i32( + body.release_year.as_deref(), + existing.release_year, + 0, + 3000, + "invalid release year", + ) { + Ok(value) => value, + Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, message)), + }; + let track_number = match parse_optional_i32( + body.track_number.as_deref(), + existing.track_number, + 1, + 999, + "invalid track number", + ) { + Ok(value) => value, + Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, message)), + }; + let disc_number = match parse_optional_i32( + body.disc_number.as_deref(), + existing.disc_number, + 1, + 99, + "invalid disc number", + ) { + Ok(value) => value, + Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, message)), + }; + let is_hidden = body.is_hidden.unwrap_or(existing.is_hidden); + + let release_changed = release_title != existing.release_title + || release_type != existing.release_type + || release_year != existing.release_year; + if release_changed && !user_owns_release_tracks(pool, user.id, existing.release_id).await? { + return Ok(json_error( + StatusCode::FORBIDDEN, + "release contains tracks uploaded by another user", + )); + } + + let artist_names = match body.artist_names { + Some(names) => match clean_artist_names(names) { + Ok(names) => Some(names), + Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, message)), + }, + None => None, + }; + let featured_artist_names = match body.featured_artist_names { + Some(names) => match clean_optional_artist_names(names) { + Ok(names) => Some(names), + Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, message)), + }, + None => None, + }; + + let now = now_iso_string(); + if release_changed { + sqlx::query( + r#"UPDATE furumusic__release + SET title = $1, title_sort = $2, release_type = $3, year = $4, updated_at = $5 + WHERE id = $6"#, + ) + .bind(&release_title) + .bind(sort_name(&release_title)) + .bind(&release_type) + .bind(release_year) + .bind(&now) + .bind(existing.release_id) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + } + + sqlx::query( + r#"UPDATE furumusic__track + SET title = $1, + title_sort = $2, + track_number = $3, + disc_number = $4, + year = $5, + is_hidden = $6, + updated_at = $7 + WHERE id = $8"#, + ) + .bind(&title) + .bind(sort_name(&title)) + .bind(track_number) + .bind(disc_number) + .bind(release_year) + .bind(is_hidden) + .bind(&now) + .bind(track_id) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + if let Some(artist_names) = artist_names { + sqlx::query("DELETE FROM furumusic__track_artist WHERE track_id = $1 AND role = 'main'") + .bind(track_id) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + for (position, name) in artist_names.iter().enumerate() { + let artist_id = find_or_create_player_artist(pool, name).await?; + sqlx::query( + r#"INSERT INTO furumusic__track_artist (track_id, artist_id, role, position) + VALUES ($1, $2, 'main', $3)"#, + ) + .bind(track_id) + .bind(artist_id) + .bind(position as i32) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + } + } + if let Some(featured_artist_names) = featured_artist_names { + replace_track_role_artists(pool, track_id, "featuring", &featured_artist_names, 1).await?; + } + + let mut tracks = load_user_upload_tracks(pool, user.id, Some(track_id), 1).await?; + let Some(track) = tracks.pop() else { + return Ok(json_error( + StatusCode::NOT_FOUND, + "uploaded track not found", + )); + }; + Json(track).into_response() +} + +async fn user_upload_release_update_handler( + session: Session, + db: Database, + pool: &sqlx::PgPool, + path: Path, + Json(body): Json, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + let release_id = path.0.id; + if !user_owns_release_tracks(pool, user.id, release_id).await? { + return Ok(json_error( + StatusCode::FORBIDDEN, + "release contains tracks uploaded by another user", + )); + } + + let Some(existing) = sqlx::query_as::<_, UploadTrackEditRow>( + r#"SELECT t.release_id, + t.title::text AS title, + t.track_number, + t.disc_number AS disc_number, + t.is_hidden, + r.title::text AS release_title, + r.release_type::text AS release_type, + r.year AS release_year + FROM furumusic__release r + JOIN furumusic__track t ON t.release_id = r.id + WHERE r.id = $1 + ORDER BY t.id + LIMIT 1"#, + ) + .bind(release_id) + .fetch_optional(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))? + else { + return Ok(json_error( + StatusCode::NOT_FOUND, + "uploaded release not found", + )); + }; + + let title = match clean_required_string(body.title.as_deref(), &existing.release_title, 255) { + Ok(value) => value, + Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, message)), + }; + let release_type = body + .release_type + .as_deref() + .map(normalize_release_type) + .unwrap_or(existing.release_type); + let year = match parse_optional_i32( + body.year.as_deref(), + existing.release_year, + 0, + 3000, + "invalid release year", + ) { + Ok(value) => value, + Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, message)), + }; + let artist_names = match body.artist_names { + Some(names) => match clean_artist_names(names) { + Ok(names) => Some(names), + Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, message)), + }, + None => None, + }; + let now = now_iso_string(); + sqlx::query( + r#"UPDATE furumusic__release + SET title = $1, + title_sort = $2, + release_type = $3, + year = $4, + is_hidden = COALESCE($5, is_hidden), + updated_at = $6 + WHERE id = $7"#, + ) + .bind(&title) + .bind(sort_name(&title)) + .bind(&release_type) + .bind(year) + .bind(body.is_hidden) + .bind(&now) + .bind(release_id) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + sqlx::query( + r#"UPDATE furumusic__track + SET year = $1, updated_at = $2 + WHERE release_id = $3"#, + ) + .bind(year) + .bind(&now) + .bind(release_id) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + if let Some(artist_names) = artist_names { + replace_release_artists(pool, release_id, &artist_names).await?; + } + + let page = load_user_uploads_page(pool, user.id, 500).await?; + Json(page).into_response() +} + +async fn user_upload_tracks_bulk_update_handler( + session: Session, + db: Database, + pool: &sqlx::PgPool, + Json(body): Json, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + let mut track_ids = body + .track_ids + .into_iter() + .filter(|id| *id > 0) + .collect::>(); + track_ids.sort_unstable(); + track_ids.dedup(); + if track_ids.is_empty() { + return Ok(json_error(StatusCode::BAD_REQUEST, "no tracks selected")); + } + if track_ids.len() > 500 { + return Ok(json_error( + StatusCode::BAD_REQUEST, + "too many tracks selected", + )); + } + + let release_ids = uploaded_track_release_ids(pool, user.id, &track_ids).await?; + if release_ids.is_empty() { + return Ok(json_error( + StatusCode::NOT_FOUND, + "uploaded tracks not found", + )); + } + if release_ids.len() > track_ids.len() { + return Ok(json_error( + StatusCode::BAD_REQUEST, + "invalid track selection", + )); + } + + let artist_names = match body.artist_names { + Some(names) => match clean_artist_names(names) { + Ok(names) => Some(names), + Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, message)), + }, + None => None, + }; + let featured_artist_names = match body.featured_artist_names { + Some(names) => match clean_optional_artist_names(names) { + Ok(names) => Some(names), + Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, message)), + }, + None => None, + }; + let release_title = match clean_optional_string(body.release_title.as_deref(), 255) { + Ok(value) => value, + Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, message)), + }; + let release_type = body.release_type.as_deref().map(normalize_release_type); + let release_year = match parse_optional_i32( + body.release_year.as_deref(), + None, + 0, + 3000, + "invalid release year", + ) { + Ok(value) => value, + Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, message)), + }; + let now = now_iso_string(); + + if body.is_hidden.is_some() { + sqlx::query( + r#"UPDATE furumusic__track + SET is_hidden = $1, updated_at = $2 + WHERE id = ANY($3)"#, + ) + .bind(body.is_hidden) + .bind(&now) + .bind(&track_ids) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + } + if let Some(artist_names) = artist_names { + for track_id in &track_ids { + replace_track_role_artists(pool, *track_id, "main", &artist_names, 0).await?; + } + } + if let Some(featured_artist_names) = featured_artist_names { + for track_id in &track_ids { + replace_track_role_artists(pool, *track_id, "featuring", &featured_artist_names, 1) + .await?; + } + } + if release_title.is_some() || release_type.is_some() || body.release_year.is_some() { + for release_id in &release_ids { + if !user_owns_release_tracks(pool, user.id, *release_id).await? { + return Ok(json_error( + StatusCode::FORBIDDEN, + "release contains tracks uploaded by another user", + )); + } + sqlx::query( + r#"UPDATE furumusic__release + SET title = COALESCE($1, title), + title_sort = COALESCE($2, title_sort), + release_type = COALESCE($3, release_type), + year = CASE WHEN $4 THEN $5 ELSE year END, + updated_at = $6 + WHERE id = $7"#, + ) + .bind(release_title.as_ref()) + .bind(release_title.as_ref().map(|title| sort_name(title))) + .bind(release_type.as_ref()) + .bind(body.release_year.is_some()) + .bind(release_year) + .bind(&now) + .bind(*release_id) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + if body.release_year.is_some() { + sqlx::query( + r#"UPDATE furumusic__track + SET year = $1, updated_at = $2 + WHERE release_id = $3"#, + ) + .bind(release_year) + .bind(&now) + .bind(*release_id) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + } + } + } + + let page = load_user_uploads_page(pool, user.id, 500).await?; + Json(page).into_response() +} + +async fn user_upload_review_save_handler( + session: Session, + db: Database, + pool: &sqlx::PgPool, + path: Path, + Json(body): Json, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + let review_id = path.0.id; + if !user_owns_review(pool, user.id, review_id).await? { + return Ok(json_error(StatusCode::NOT_FOUND, "upload review not found")); + } + let normalized = match normalized_from_upload_review_body(&body) { + Ok(value) => value, + Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, message)), + }; + let result_json = serde_json::to_string(&normalized) + .map_err(|e| cot::Error::internal(format!("failed to serialize review fields: {e}")))?; + save_user_upload_review_result(&db, review_id, result_json).await?; + let mut reviews = load_user_upload_reviews(pool, user.id, Some(review_id), 1) + .await? + .0; + let Some(review) = reviews.pop() else { + return Ok(json_error(StatusCode::NOT_FOUND, "upload review not found")); + }; + Json(review).into_response() +} + +async fn user_upload_review_approve_handler( + session: Session, + db: Database, + pool: &sqlx::PgPool, + path: Path, + Json(body): Json, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + let review_id = path.0.id; + if !user_owns_review(pool, user.id, review_id).await? { + return Ok(json_error(StatusCode::NOT_FOUND, "upload review not found")); + } + let normalized = match normalized_from_upload_review_body(&body) { + Ok(value) => value, + Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, message)), + }; + let result_json = serde_json::to_string(&normalized) + .map_err(|e| cot::Error::internal(format!("failed to serialize review fields: {e}")))?; + let mut review = crate::scheduler::PendingReview::get_by_id(&db, review_id) + .await + .map_err(|e| cot::Error::internal(format!("db error: {e}")))? + .ok_or_else(|| cot::Error::internal("upload review not found"))?; + let status = review.status.as_str(); + if status == "processing" || status == "approved" || status == "auto_approved" { + return Ok(json_error( + StatusCode::BAD_REQUEST, + "review cannot be approved from this status", + )); + } + review + .set_result_json(&db, result_json) + .await + .map_err(|e| cot::Error::internal(format!("db error: {e}")))?; + + let context: serde_json::Value = + serde_json::from_str(review.context_json_str()).unwrap_or_default(); + let (live_config, _) = AppConfig::load_with_db(&db).await; + let input_path = crate::media_paths::resolve_path_from_root( + &live_config.agent_inbox_dir, + review.input_path_str(), + ); + let input_path = input_path.to_string_lossy().to_string(); + match crate::jobs::inbox_process::finalize_approved( + &db, + pool, + &live_config, + &input_path, + &normalized, + &context, + &live_config.agent_storage_dir, + None, + ) + .await + { + Ok(()) => { + let _ = review.set_approved(&db).await; + let page = load_user_uploads_page(pool, user.id, 500).await?; + Json(page).into_response() + } + Err(err) => { + let message = err.to_string(); + let _ = review.set_failed(&db, &message).await; + Ok(json_error(StatusCode::BAD_REQUEST, &message)) + } + } +} + +async fn load_user_uploads_page( + pool: &sqlx::PgPool, + user_id: i64, + limit: i64, +) -> cot::Result { + let tracks = load_user_upload_tracks(pool, user_id, None, limit).await?; + let releases = group_user_upload_releases(pool, &tracks).await?; + let (pending, pending_total) = load_user_upload_reviews(pool, user_id, None, 100).await?; + let (queued, queued_total) = load_user_upload_queue(pool, user_id).await?; + Ok(UserUploadsPage { + tracks, + releases, + pending, + queued, + pending_total, + queued_total, + }) +} + +async fn group_user_upload_releases( + pool: &sqlx::PgPool, + tracks: &[UserUploadTrack], +) -> cot::Result> { + let mut release_ids = tracks + .iter() + .map(|item| item.track.release_id) + .collect::>(); + release_ids.sort_unstable(); + release_ids.dedup(); + + let release_artists = if release_ids.is_empty() { + Vec::new() + } else { + sqlx::query_as::<_, ReleaseArtistRefRow>( + r#"SELECT ra.release_id, + a.id AS artist_id, + a.name::text AS artist_name + FROM furumusic__release_artist ra + JOIN furumusic__artist a ON a.id = ra.artist_id + WHERE ra.release_id = ANY($1) + ORDER BY ra.release_id, ra.position"#, + ) + .bind(&release_ids) + .fetch_all(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))? + }; + let mut artists_by_release: HashMap> = HashMap::new(); + for row in release_artists { + artists_by_release + .entry(row.release_id) + .or_default() + .push(ArtistRef { + id: row.artist_id, + name: row.artist_name, + }); + } + + let mut grouped: Vec = Vec::new(); + for track in tracks { + let release_id = track.track.release_id; + if let Some(release) = grouped.iter_mut().find(|release| release.id == release_id) { + release.tracks.push(track.clone()); + continue; + } + grouped.push(UserUploadRelease { + id: release_id, + title: track.track.release_title.clone(), + release_type: track.release_type.clone(), + year: track.track.release_year, + is_hidden: track.release_is_hidden, + artists: artists_by_release.remove(&release_id).unwrap_or_default(), + tracks: vec![track.clone()], + }); + } + grouped.sort_by(|a, b| { + b.tracks + .first() + .map(|track| track.uploaded_at.as_str()) + .cmp(&a.tracks.first().map(|track| track.uploaded_at.as_str())) + }); + Ok(grouped) +} + +async fn load_user_upload_tracks( + pool: &sqlx::PgPool, + user_id: i64, + track_id: Option, + limit: i64, +) -> cot::Result> { + let rows = sqlx::query_as::<_, UploadedTrackRow>( + r#"SELECT t.id, + t.title::text AS title, + t.track_number, + t.disc_number, + t.duration_seconds, + t.cover_file_id, + r.cover_file_id AS release_cover_file_id, + r.id AS release_id, + r.title::text AS release_title, + r.release_type::text AS release_type, + r.year AS release_year, + r.is_hidden AS release_is_hidden, + COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name, + mf.audio_format, + mf.audio_bitrate, + mf.audio_sample_rate, + mf.audio_bit_depth, + mf.file_size_bytes, + t.lastfm_listeners, + t.lastfm_playcount, + t.lastfm_rating, + t.lastfm_updated_at, + mf.id AS media_file_id, + t.is_hidden, + t.year, + mf.created_at::text AS uploaded_at + FROM furumusic__track t + JOIN furumusic__release r ON r.id = t.release_id + JOIN furumusic__media_file mf ON mf.id = t.audio_file_id + WHERE mf.uploaded_by_user_id = $1 + AND ($2::bigint IS NULL OR t.id = $2) + ORDER BY mf.created_at DESC, t.id DESC + LIMIT $3"#, + ) + .bind(user_id) + .bind(track_id) + .bind(limit) + .fetch_all(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + let track_ids = rows.iter().map(|row| row.id).collect::>(); + let track_artists = if track_ids.is_empty() { + Vec::new() + } else { + sqlx::query_as::<_, TrackArtistRow>( + r#"SELECT ta.track_id, ta.artist_id, a.name::text as artist_name, ta.role::text as role + FROM furumusic__track_artist ta + JOIN furumusic__artist a ON a.id = ta.artist_id + WHERE ta.track_id = ANY($1) + ORDER BY ta.track_id, ta.position"#, + ) + .bind(&track_ids) + .fetch_all(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))? + }; + let mut main_artists: HashMap> = HashMap::new(); + let mut featured_artists: HashMap> = HashMap::new(); + for ta in track_artists { + let artist = ArtistRef { + id: ta.artist_id, + name: ta.artist_name, + }; + if ta.role == "featuring" { + featured_artists + .entry(ta.track_id) + .or_default() + .push(artist); + } else { + main_artists.entry(ta.track_id).or_default().push(artist); + } + } + + Ok(rows + .into_iter() + .map(|row| { + let track_id = row.id; + UserUploadTrack { + track: TrackItem { + id: row.id, + title: row.title, + track_number: row.track_number, + disc_number: row.disc_number, + duration_seconds: row.duration_seconds, + artists: main_artists.remove(&track_id).unwrap_or_default(), + featured_artists: featured_artists.remove(&track_id).unwrap_or_default(), + release_id: row.release_id, + release_title: row.release_title, + release_year: row.release_year, + cover_url: track_cover_variant_url( + row.cover_file_id, + row.release_cover_file_id, + "medium", + ), + stream_url: format!("/api/player/stream/{track_id}"), + uploader_name: row.uploader_name, + audio_format: row.audio_format, + audio_bitrate: row.audio_bitrate, + audio_sample_rate: row.audio_sample_rate, + audio_bit_depth: row.audio_bit_depth, + file_size_bytes: row.file_size_bytes, + lastfm_listeners: row.lastfm_listeners, + lastfm_playcount: row.lastfm_playcount, + lastfm_rating: row.lastfm_rating, + lastfm_updated_at: row.lastfm_updated_at, + }, + media_file_id: row.media_file_id, + is_hidden: row.is_hidden, + release_is_hidden: row.release_is_hidden, + release_type: row.release_type, + year: row.year, + uploaded_at: row.uploaded_at, + } + }) + .collect()) +} + +async fn load_user_upload_reviews( + pool: &sqlx::PgPool, + user_id: i64, + review_id: Option, + limit: i64, +) -> cot::Result<(Vec, i64)> { + let uploaded_by_pattern = format!(r#""uploaded_by_user_id"\s*:\s*{}([^0-9]|$)"#, user_id); + let total = sqlx::query_scalar::<_, i64>( + r#"SELECT COUNT(*) + FROM furumusic__pending_review + WHERE context_json IS NOT NULL + AND context_json ~ $1 + AND ($2::bigint IS NULL OR id = $2) + AND status IN ('pending', 'failed')"#, + ) + .bind(&uploaded_by_pattern) + .bind(review_id) + .fetch_one(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let rows = sqlx::query_as::<_, UserUploadReviewRow>( + r#"SELECT id, + status::text AS status, + input_path, + result_json, + context_json, + created_at::text AS created_at, + updated_at::text AS updated_at, + error_message + FROM furumusic__pending_review + WHERE context_json IS NOT NULL + AND context_json ~ $1 + AND ($2::bigint IS NULL OR id = $2) + AND status IN ('pending', 'failed') + ORDER BY CASE status WHEN 'failed' THEN 0 ELSE 1 END, updated_at DESC + LIMIT $3"#, + ) + .bind(uploaded_by_pattern) + .bind(review_id) + .bind(limit) + .fetch_all(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let items = rows + .into_iter() + .map(|row| UserUploadReviewItem { + id: row.id, + status: row.status, + filename: input_path_filename(row.input_path.as_deref()), + created_at: row.created_at, + updated_at: row.updated_at, + error_message: row.error_message, + fields: review_fields_from_json( + row.result_json.as_deref(), + row.context_json.as_deref(), + ), + }) + .collect(); + Ok((items, total)) +} + +async fn load_user_upload_queue( + pool: &sqlx::PgPool, + user_id: i64, +) -> cot::Result<(Vec, i64)> { + let uploaded_by_pattern = format!(r#""uploaded_by_user_id"\s*:\s*{}([^0-9]|$)"#, user_id); + let total = sqlx::query_scalar::<_, i64>( + r#"SELECT COUNT(*) + FROM furumusic__pending_review + WHERE context_json IS NOT NULL + AND context_json ~ $1 + AND status IN ('queued', 'processing')"#, + ) + .bind(&uploaded_by_pattern) + .fetch_one(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let rows = sqlx::query_as::<_, UserUploadQueueRow>( + r#"SELECT id, + status::text AS status, + input_path, + created_at::text AS created_at, + updated_at::text AS updated_at, + error_message + FROM furumusic__pending_review + WHERE context_json IS NOT NULL + AND context_json ~ $1 + AND status IN ('queued', 'processing') + ORDER BY + CASE status WHEN 'processing' THEN 0 ELSE 1 END, + created_at DESC + LIMIT 20"#, + ) + .bind(uploaded_by_pattern) + .fetch_all(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let items = rows + .into_iter() + .map(|row| UserUploadQueueItem { + id: row.id, + status: row.status, + filename: input_path_filename(row.input_path.as_deref()), + created_at: row.created_at, + updated_at: row.updated_at, + error_message: row.error_message, + }) + .collect(); + Ok((items, total)) +} + +async fn user_owns_release_tracks( + pool: &sqlx::PgPool, + user_id: i64, + release_id: i64, +) -> cot::Result { + let other_count: i64 = sqlx::query_scalar( + r#"SELECT COUNT(*) + FROM furumusic__track t + JOIN furumusic__media_file mf ON mf.id = t.audio_file_id + WHERE t.release_id = $1 + AND COALESCE(mf.uploaded_by_user_id, -1) <> $2"#, + ) + .bind(release_id) + .bind(user_id) + .fetch_one(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + Ok(other_count == 0) +} + +async fn user_owns_review(pool: &sqlx::PgPool, user_id: i64, review_id: i64) -> cot::Result { + let uploaded_by_pattern = format!(r#""uploaded_by_user_id"\s*:\s*{}([^0-9]|$)"#, user_id); + let count: i64 = sqlx::query_scalar( + r#"SELECT COUNT(*) + FROM furumusic__pending_review + WHERE id = $1 + AND context_json IS NOT NULL + AND context_json ~ $2"#, + ) + .bind(review_id) + .bind(uploaded_by_pattern) + .fetch_one(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + Ok(count > 0) +} + +async fn uploaded_track_release_ids( + pool: &sqlx::PgPool, + user_id: i64, + track_ids: &[i64], +) -> cot::Result> { + let rows = sqlx::query_scalar::<_, i64>( + r#"SELECT DISTINCT t.release_id + FROM furumusic__track t + JOIN furumusic__media_file mf ON mf.id = t.audio_file_id + WHERE t.id = ANY($1) + AND mf.uploaded_by_user_id = $2"#, + ) + .bind(track_ids) + .bind(user_id) + .fetch_all(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let owned_count: i64 = sqlx::query_scalar( + r#"SELECT COUNT(*) + FROM furumusic__track t + JOIN furumusic__media_file mf ON mf.id = t.audio_file_id + WHERE t.id = ANY($1) + AND mf.uploaded_by_user_id = $2"#, + ) + .bind(track_ids) + .bind(user_id) + .fetch_one(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + if owned_count != track_ids.len() as i64 { + return Ok(Vec::new()); + } + Ok(rows) +} + +async fn save_user_upload_review_result( + db: &Database, + review_id: i64, + result_json: String, +) -> cot::Result<()> { + let mut review = crate::scheduler::PendingReview::get_by_id(db, review_id) + .await + .map_err(|e| cot::Error::internal(format!("db error: {e}")))? + .ok_or_else(|| cot::Error::internal("upload review not found"))?; + review + .set_result_json(db, result_json) + .await + .map_err(|e| cot::Error::internal(format!("db error: {e}")))?; + Ok(()) +} + +async fn replace_release_artists( + pool: &sqlx::PgPool, + release_id: i64, + names: &[String], +) -> cot::Result<()> { + sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = $1") + .bind(release_id) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + for (position, name) in names.iter().enumerate() { + let artist_id = find_or_create_player_artist(pool, name).await?; + sqlx::query( + r#"INSERT INTO furumusic__release_artist (release_id, artist_id, position) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING"#, + ) + .bind(release_id) + .bind(artist_id) + .bind(position as i32) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + } + Ok(()) +} + +async fn replace_track_role_artists( + pool: &sqlx::PgPool, + track_id: i64, + role: &str, + names: &[String], + position_offset: i32, +) -> cot::Result<()> { + sqlx::query("DELETE FROM furumusic__track_artist WHERE track_id = $1 AND role = $2") + .bind(track_id) + .bind(role) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + for (index, name) in names.iter().enumerate() { + let artist_id = find_or_create_player_artist(pool, name).await?; + sqlx::query( + r#"INSERT INTO furumusic__track_artist (track_id, artist_id, role, position) + VALUES ($1, $2, $3, $4)"#, + ) + .bind(track_id) + .bind(artist_id) + .bind(role) + .bind(position_offset + index as i32) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + } + Ok(()) +} + +async fn find_or_create_player_artist(pool: &sqlx::PgPool, name: &str) -> cot::Result { + let name = name.trim(); + let sort = sort_name(name); + if let Some(id) = sqlx::query_scalar::<_, i64>( + "SELECT id FROM furumusic__artist WHERE name_sort = $1 ORDER BY id LIMIT 1", + ) + .bind(&sort) + .fetch_optional(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))? + { + return Ok(id); + } + + let now = now_iso_string(); + sqlx::query_scalar( + r#"INSERT INTO furumusic__artist (name, name_sort, image_file_id, is_hidden, model_name, created_at, updated_at) + VALUES ($1, $2, NULL, false, NULL, $3, $3) + RETURNING id"#, + ) + .bind(name) + .bind(sort) + .bind(now) + .fetch_one(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string())) +} + +fn now_iso_string() -> String { + chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() +} + +fn sort_name(name: &str) -> String { + name.trim().to_lowercase() +} + +fn normalize_release_type(value: &str) -> String { + let value = value.trim().to_lowercase(); + if crate::music::RELEASE_TYPES + .iter() + .any(|(code, _, _)| *code == value) + { + value + } else { + "album".to_string() + } +} + +fn clean_required_string( + raw: Option<&str>, + fallback: &str, + max_len: usize, +) -> Result { + let value = raw.unwrap_or(fallback).trim(); + if value.is_empty() { + return Err("value cannot be empty"); + } + Ok(value.chars().take(max_len).collect()) +} + +fn parse_optional_i32( + raw: Option<&str>, + fallback: Option, + min: i32, + max: i32, + error: &'static str, +) -> Result, &'static str> { + let Some(raw) = raw else { + return Ok(fallback); + }; + let raw = raw.trim(); + if raw.is_empty() { + return Ok(None); + } + let value = raw.parse::().map_err(|_| error)?; + if value < min || value > max { + return Err(error); + } + Ok(Some(value)) +} + +fn clean_artist_names(raw_names: Vec) -> Result, &'static str> { + let mut seen = HashSet::new(); + let mut names = Vec::new(); + for raw_name in raw_names { + let name = raw_name.trim(); + if name.is_empty() { + continue; + } + let name: String = name.chars().take(255).collect(); + if seen.insert(sort_name(&name)) { + names.push(name); + } + if names.len() >= 12 { + break; + } + } + if names.is_empty() { + return Err("at least one artist is required"); + } + Ok(names) +} + +fn clean_optional_artist_names(raw_names: Vec) -> Result, &'static str> { + let mut seen = HashSet::new(); + let mut names = Vec::new(); + for raw_name in raw_names { + let name = raw_name.trim(); + if name.is_empty() { + continue; + } + let name: String = name.chars().take(255).collect(); + if seen.insert(sort_name(&name)) { + names.push(name); + } + if names.len() >= 12 { + break; + } + } + Ok(names) +} + +fn clean_optional_string( + raw: Option<&str>, + max_len: usize, +) -> Result, &'static str> { + let Some(raw) = raw else { + return Ok(None); + }; + let value = raw.trim(); + if value.is_empty() { + return Ok(None); + } + Ok(Some(value.chars().take(max_len).collect())) +} + +fn review_fields_from_json( + result_json: Option<&str>, + context_json: Option<&str>, +) -> UserUploadReviewFields { + let normalized = result_json + .and_then(|value| serde_json::from_str::(value).ok()) + .unwrap_or_default(); + let context = context_json + .and_then(|value| serde_json::from_str::(value).ok()) + .unwrap_or_default(); + let ctx_str = |key: &str| { + context + .get(key) + .and_then(|value| value.as_str()) + .unwrap_or("") + .trim() + .to_owned() + }; + let ctx_i32 = |key: &str| { + context + .get(key) + .and_then(|value| value.as_i64()) + .map(|value| value.to_string()) + .unwrap_or_default() + }; + UserUploadReviewFields { + title: normalized + .title + .unwrap_or_else(|| first_non_empty([ctx_str("raw_title"), ctx_str("path_title")])), + artist: normalized + .artist + .unwrap_or_else(|| first_non_empty([ctx_str("raw_artist"), ctx_str("path_artist")])), + album: normalized + .album + .unwrap_or_else(|| first_non_empty([ctx_str("raw_album"), ctx_str("path_album")])), + year: normalized + .year + .map(|value| value.to_string()) + .unwrap_or_else(|| first_non_empty([ctx_i32("raw_year"), ctx_i32("path_year")])), + track_number: normalized + .track_number + .map(|value| value.to_string()) + .unwrap_or_else(|| { + first_non_empty([ctx_i32("raw_track_number"), ctx_i32("path_track_number")]) + }), + genre: normalized.genre.unwrap_or_else(|| ctx_str("raw_genre")), + featured_artists: normalized.featured_artists, + release_type: normalized + .release_type + .unwrap_or_else(|| "album".to_owned()), + notes: normalized.notes.unwrap_or_default(), + } +} + +fn first_non_empty(values: [String; N]) -> String { + values + .into_iter() + .find(|value| !value.trim().is_empty()) + .unwrap_or_default() +} + +fn normalized_from_upload_review_body( + body: &UserUploadReviewUpdateRequest, +) -> Result { + let title = clean_required_string(body.title.as_deref(), "", 255)?; + let artist = clean_required_string(body.artist.as_deref(), "", 255)?; + let album = clean_required_string(body.album.as_deref(), "", 255)?; + let year = parse_optional_i32(body.year.as_deref(), None, 0, 3000, "invalid release year")?; + let track_number = parse_optional_i32( + body.track_number.as_deref(), + None, + 1, + 999, + "invalid track number", + )?; + let featured_artists = + clean_optional_artist_names(body.featured_artists.clone().unwrap_or_default())?; + Ok(crate::agent::dto::NormalizedFields { + title: Some(title), + artist: Some(artist), + album: Some(album), + year, + track_number, + genre: clean_optional_string(body.genre.as_deref(), 255)?, + featured_artists, + release_type: Some( + body.release_type + .as_deref() + .map(normalize_release_type) + .unwrap_or_else(|| "album".to_owned()), + ), + confidence: Some(1.0), + notes: clean_optional_string(body.notes.as_deref(), 2000)?, + }) +} + +fn input_path_filename(path: Option<&str>) -> String { + path.and_then(|path| path.rsplit(['/', '\\']).next()) + .filter(|name| !name.trim().is_empty()) + .unwrap_or("queued track") + .to_string() +} + // --------------------------------------------------------------------------- // GET /api/player/artists?page=N&limit=N // --------------------------------------------------------------------------- @@ -2194,6 +3772,10 @@ async fn devices_heartbeat_handler( user.id, &device_id, dto.user_agent.as_deref(), + dto.current_jam_id + .as_deref() + .and_then(normalize_device_id) + .as_deref(), dto.playback_state, ); Json(response).into_response() @@ -2216,6 +3798,10 @@ async fn devices_poll_handler( user.id, &device_id, dto.user_agent.as_deref(), + dto.current_jam_id + .as_deref() + .and_then(normalize_device_id) + .as_deref(), dto.playback_state, ); Json(response).into_response() @@ -2273,13 +3859,185 @@ async fn devices_command_handler( } None => None, }; + let jam_id = match dto.jam_id.as_deref() { + Some(raw) => { + let Some(jam_id) = normalize_device_id(raw) else { + return Ok(json_error(StatusCode::BAD_REQUEST, "invalid jam id")); + }; + Some(jam_id) + } + None => None, + }; - match hub.enqueue_command(user.id, target_device_id.as_deref(), command, dto.payload) { + match hub.enqueue_command( + user.id, + target_device_id.as_deref(), + jam_id.as_deref(), + command, + dto.payload, + ) { Ok(()) => Json(serde_json::json!({"ok": true})).into_response(), Err(message) => Ok(json_error(StatusCode::BAD_REQUEST, message)), } } +async fn jam_users_search_handler( + session: Session, + db: Database, + pool: &sqlx::PgPool, + UrlQuery(query): UrlQuery, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + + let q = query.q.unwrap_or_default(); + let q = q.trim(); + if q.is_empty() { + return Json(Vec::::new()).into_response(); + } + let limit = query.limit.unwrap_or(10).clamp(1, 20); + let pattern = format!("%{q}%"); + let prefix = format!("{q}%"); + + let rows = sqlx::query_as::<_, PlayerJamUserRow>( + r#"SELECT id, username::text AS username, display_name, email + FROM furumusic__user + WHERE is_active = true + AND id <> $1 + AND ( + username ILIKE $2 + OR COALESCE(display_name, '') ILIKE $2 + OR COALESCE(email, '') ILIKE $2 + ) + ORDER BY + CASE + WHEN username ILIKE $3 THEN 0 + WHEN COALESCE(display_name, '') ILIKE $3 THEN 1 + ELSE 2 + END, + COALESCE(NULLIF(display_name, ''), username) + LIMIT $4"#, + ) + .bind(user.id) + .bind(pattern) + .bind(prefix) + .bind(limit as i64) + .fetch_all(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + let users = rows + .into_iter() + .map(|row| PlayerJamUserDto { + id: row.id, + username: row.username, + display_name: row.display_name, + email: row.email, + }) + .collect::>(); + + Json(users).into_response() +} + +async fn jam_create_handler( + session: Session, + db: Database, + pool: &sqlx::PgPool, + hub: Arc, + Json(dto): Json, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + let Some(device_id) = normalize_device_id(&dto.device_id) else { + return Ok(json_error(StatusCode::BAD_REQUEST, "invalid device id")); + }; + + let mut invitee_ids = dto + .invitee_user_ids + .into_iter() + .filter(|id| *id > 0 && *id != user.id) + .collect::>(); + invitee_ids.sort_unstable(); + invitee_ids.dedup(); + invitee_ids.truncate(PLAYER_JAM_MAX_INVITEES); + + let invitees = if invitee_ids.is_empty() { + Vec::new() + } else { + sqlx::query_as::<_, PlayerJamUserRow>( + r#"SELECT id, username::text AS username, display_name, email + FROM furumusic__user + WHERE is_active = true AND id = ANY($1)"#, + ) + .bind(&invitee_ids) + .fetch_all(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))? + .into_iter() + .map(|row| { + let name = row + .display_name + .as_deref() + .filter(|name| !name.trim().is_empty()) + .unwrap_or(&row.username) + .to_string(); + (row.id, name) + }) + .collect::>() + }; + + match hub.create_jam(user.id, &user.name, &device_id, invitees) { + Ok(response) => Json(response).into_response(), + Err(message) => Ok(json_error(StatusCode::BAD_REQUEST, message)), + } +} + +async fn jam_join_handler( + session: Session, + db: Database, + hub: Arc, + Json(dto): Json, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + let Some(jam_id) = normalize_device_id(&dto.jam_id) else { + return Ok(json_error(StatusCode::BAD_REQUEST, "invalid jam id")); + }; + let Some(device_id) = normalize_device_id(&dto.device_id) else { + return Ok(json_error(StatusCode::BAD_REQUEST, "invalid device id")); + }; + + match hub.join_jam(user.id, &device_id, &jam_id) { + Ok(response) => Json(response).into_response(), + Err(message) => Ok(json_error(StatusCode::BAD_REQUEST, message)), + } +} + +async fn jam_leave_handler( + session: Session, + db: Database, + hub: Arc, + Json(dto): Json, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + let Some(jam_id) = normalize_device_id(&dto.jam_id) else { + return Ok(json_error(StatusCode::BAD_REQUEST, "invalid jam id")); + }; + let Some(device_id) = normalize_device_id(&dto.device_id) else { + return Ok(json_error(StatusCode::BAD_REQUEST, "invalid device id")); + }; + + match hub.leave_jam(user.id, &device_id, &jam_id) { + Ok(response) => Json(response).into_response(), + Err(message) => Ok(json_error(StatusCode::BAD_REQUEST, message)), + } +} + // --------------------------------------------------------------------------- // GET /api/player/state // --------------------------------------------------------------------------- @@ -3888,6 +5646,166 @@ impl App for PlayerApp { }, "player_local_upload", ), + Route::with_handler_and_name( + "/uploads/tracks", + get({ + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + move |session: Session, db: Database, query: UrlQuery| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + user_uploads_handler(session, db, pg_pool, query).await + } + } + }), + "player_upload_tracks", + ), + Route::with_handler_and_name( + "/uploads/tracks/{track_id}", + post({ + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + move |session: Session, + db: Database, + path: Path, + json: Json| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + user_upload_track_update_handler(session, db, pg_pool, path, json).await + } + } + }), + "player_upload_track_update", + ), + Route::with_handler_and_name( + "/uploads/bulk-tracks", + post({ + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + move |session: Session, + db: Database, + json: Json| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + user_upload_tracks_bulk_update_handler(session, db, pg_pool, json).await + } + } + }), + "player_upload_tracks_bulk_update", + ), + Route::with_handler_and_name( + "/uploads/releases/{id}", + post({ + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + move |session: Session, + db: Database, + path: Path, + json: Json| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + user_upload_release_update_handler(session, db, pg_pool, path, json) + .await + } + } + }), + "player_upload_release_update", + ), + Route::with_handler_and_name( + "/uploads/reviews/{id}", + post({ + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + move |session: Session, + db: Database, + path: Path, + json: Json| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + user_upload_review_save_handler(session, db, pg_pool, path, json).await + } + } + }), + "player_upload_review_save", + ), + Route::with_handler_and_name( + "/uploads/reviews/{id}/approve", + post({ + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + move |session: Session, + db: Database, + path: Path, + json: Json| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + user_upload_review_approve_handler(session, db, pg_pool, path, json) + .await + } + } + }), + "player_upload_review_approve", + ), Route::with_handler_and_name( "/torrents/{id}/start", { @@ -4538,6 +6456,78 @@ impl App for PlayerApp { }), "player_devices_command", ), + Route::with_handler_and_name( + "/jams/users", + get({ + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + move |session: Session, db: Database, query: UrlQuery| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + jam_users_search_handler(session, db, pg_pool, query).await + } + } + }), + "player_jam_users", + ), + Route::with_handler_and_name( + "/jams", + post({ + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + let device_hub = Arc::clone(&device_hub); + move |session: Session, db: Database, json: Json| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + let device_hub = Arc::clone(&device_hub); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + jam_create_handler(session, db, pg_pool, device_hub, json).await + } + } + }), + "player_jams_create", + ), + Route::with_handler_and_name( + "/jams/join", + post({ + let device_hub = Arc::clone(&device_hub); + move |session: Session, db: Database, json: Json| { + let device_hub = Arc::clone(&device_hub); + async move { jam_join_handler(session, db, device_hub, json).await } + } + }), + "player_jams_join", + ), + Route::with_handler_and_name( + "/jams/leave", + post({ + let device_hub = Arc::clone(&device_hub); + move |session: Session, db: Database, json: Json| { + let device_hub = Arc::clone(&device_hub); + async move { jam_leave_handler(session, db, device_hub, json).await } + } + }), + "player_jams_leave", + ), // -- Playback state GET -- Route::with_handler_and_name( "/state", diff --git a/src/player/queries.rs b/src/player/queries.rs index 0fc2b82..a3990e6 100644 --- a/src/player/queries.rs +++ b/src/player/queries.rs @@ -19,6 +19,11 @@ pub(super) struct TracksByIdsRequest { pub(super) ids: Vec, } +#[derive(Debug, Deserialize)] +pub(super) struct UserUploadsQuery { + pub(super) limit: Option, +} + #[derive(Debug, Deserialize)] pub(super) struct CreatePlaylistRequest { pub(super) title: String, @@ -62,6 +67,12 @@ pub(super) struct SearchQuery { pub(super) limit: Option, } +#[derive(Debug, Deserialize)] +pub(super) struct JamUserSearchQuery { + pub(super) q: Option, + pub(super) limit: Option, +} + #[derive(Debug, Deserialize)] pub(super) struct PathTrackId { pub(super) track_id: i64, diff --git a/src/player/rows.rs b/src/player/rows.rs index f9221cf..3406fe2 100644 --- a/src/player/rows.rs +++ b/src/player/rows.rs @@ -12,6 +12,14 @@ pub(super) struct CountRow { pub(super) count: i64, } +#[derive(sqlx::FromRow)] +pub(super) struct PlayerJamUserRow { + pub(super) id: i64, + pub(super) username: String, + pub(super) display_name: Option, + pub(super) email: Option, +} + #[derive(sqlx::FromRow)] pub(super) struct ReleaseRow { pub(super) id: i64, @@ -60,6 +68,13 @@ pub(super) struct TrackArtistRow { pub(super) role: String, } +#[derive(sqlx::FromRow)] +pub(super) struct ReleaseArtistRefRow { + pub(super) release_id: i64, + pub(super) artist_id: i64, + pub(super) artist_name: String, +} + #[derive(sqlx::FromRow)] pub(super) struct MediaFileRow { pub(super) file_path: String, @@ -124,6 +139,70 @@ pub(super) struct PlaylistTrackRow { pub(super) lastfm_updated_at: Option, } +#[derive(sqlx::FromRow)] +pub(super) struct UploadedTrackRow { + pub(super) id: i64, + pub(super) title: String, + pub(super) track_number: Option, + pub(super) disc_number: Option, + pub(super) duration_seconds: f64, + pub(super) cover_file_id: Option, + pub(super) release_cover_file_id: Option, + pub(super) release_id: i64, + pub(super) release_title: String, + pub(super) release_type: String, + pub(super) release_year: Option, + pub(super) release_is_hidden: bool, + pub(super) uploader_name: String, + pub(super) audio_format: Option, + pub(super) audio_bitrate: Option, + pub(super) audio_sample_rate: Option, + pub(super) audio_bit_depth: Option, + pub(super) file_size_bytes: Option, + pub(super) lastfm_listeners: Option, + pub(super) lastfm_playcount: Option, + pub(super) lastfm_rating: Option, + pub(super) lastfm_updated_at: Option, + pub(super) media_file_id: i64, + pub(super) is_hidden: bool, + pub(super) year: Option, + pub(super) uploaded_at: String, +} + +#[derive(sqlx::FromRow)] +pub(super) struct UserUploadQueueRow { + pub(super) id: i64, + pub(super) status: String, + pub(super) input_path: Option, + pub(super) created_at: String, + pub(super) updated_at: String, + pub(super) error_message: Option, +} + +#[derive(sqlx::FromRow)] +pub(super) struct UserUploadReviewRow { + pub(super) id: i64, + pub(super) status: String, + pub(super) input_path: Option, + pub(super) result_json: Option, + pub(super) context_json: Option, + pub(super) created_at: String, + pub(super) updated_at: String, + pub(super) error_message: Option, +} + +#[derive(sqlx::FromRow)] +pub(super) struct UploadTrackEditRow { + pub(super) release_id: i64, + pub(super) title: String, + pub(super) track_number: Option, + pub(super) disc_number: Option, + pub(super) is_hidden: bool, + pub(super) release_title: String, + pub(super) release_type: String, + pub(super) release_year: Option, +} + #[derive(sqlx::FromRow)] pub(super) struct AppearanceTrackRow { pub(super) id: i64, diff --git a/templates/player/modals.html b/templates/player/modals.html index c830815..4f8b059 100644 --- a/templates/player/modals.html +++ b/templates/player/modals.html @@ -112,6 +112,21 @@ +
+ + +
+ + + + + diff --git a/templates/player/scripts.html b/templates/player/scripts.html index a1959e1..8f3e7b6 100644 --- a/templates/player/scripts.html +++ b/templates/player/scripts.html @@ -849,6 +849,16 @@ document.addEventListener('alpine:init', () => { this.setVolume(payload.volume); } else if (command.command === 'set_options') { // Options were already applied above. + } else if (command.command === 'queue_add_end') { + queue._addToEndLocal(payload.tracks || []); + } else if (command.command === 'queue_add_next') { + queue._addNextLocal(payload.tracks || []); + } else if (command.command === 'queue_remove') { + queue._removeLocal(Number(payload.index)); + } else if (command.command === 'queue_move') { + queue._moveTrackLocal(Number(payload.from_index), Number(payload.to_index)); + } else if (command.command === 'queue_clear') { + queue._clearLocal(); } this._saveState(); Alpine.store('devices')?.heartbeat(); @@ -1055,13 +1065,22 @@ document.addEventListener('alpine:init', () => { Alpine.store('devices', { id: null, devices: [], + jams: [], activeDeviceId: null, + currentJamId: null, open: false, + jamPanelOpen: false, + jamQuery: '', + jamUsers: [], + jamSelectedUsers: [], + jamSearching: false, _pollTimer: null, + _jamSearchTimer: null, _stateRefreshTick: 0, init() { this.id = this._ensureId(); + this.currentJamId = sessionStorage.getItem('furu_player_jam_id') || null; this.heartbeat(); this._pollTimer = setInterval(() => this.poll(), 500); document.addEventListener('visibilitychange', () => { @@ -1087,6 +1106,7 @@ document.addEventListener('alpine:init', () => { return { device_id: this.id, user_agent: navigator.userAgent || '', + current_jam_id: this.currentJamId, playback_state: player && this.isActive() ? player._devicePlaybackStatePayload() : null, }; }, @@ -1132,16 +1152,36 @@ document.addEventListener('alpine:init', () => { const wasActive = this.isActive(); this.activeDeviceId = data.active_device_id || null; this.devices = Array.isArray(data.devices) ? data.devices : []; + this.jams = Array.isArray(data.jams) ? data.jams : []; + if (data.current_jam_id) { + this.currentJamId = data.current_jam_id; + sessionStorage.setItem('furu_player_jam_id', this.currentJamId); + } else if (this.currentJamId && !this.jams.some(jam => jam.id === this.currentJamId)) { + this.currentJamId = null; + sessionStorage.removeItem('furu_player_jam_id'); + } if (wasActive && !this.isActive()) { Alpine.store('player')?._pauseLocal(); } }, + selectedJam() { + return this.currentJamId ? this.jams.find(jam => jam.id === this.currentJamId) : null; + }, + + isControllingRemoteJam() { + const jam = this.selectedJam(); + return !!jam && !jam.is_owner; + }, + isActive() { + if (this.isControllingRemoteJam()) return false; return !this.activeDeviceId || this.activeDeviceId === this.id; }, activeLabel() { + const jam = this.selectedJam(); + if (jam) return jam.name; const active = this.devices.find(device => device.id === this.activeDeviceId); return active ? active.name : 'Devices'; }, @@ -1156,6 +1196,7 @@ document.addEventListener('alpine:init', () => { const player = Alpine.store('player'); try { + this.clearJamSelection(); const res = await fetch('/api/player/devices/active', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1181,14 +1222,16 @@ document.addEventListener('alpine:init', () => { }, async sendCommand(command, payload = {}, targetDeviceId = null) { - const target = targetDeviceId || this.activeDeviceId; - if (!target || target === this.id) return false; + const jamId = this.isControllingRemoteJam() ? this.currentJamId : null; + const target = jamId ? null : (targetDeviceId || this.activeDeviceId); + if (!jamId && (!target || target === this.id)) return false; try { const res = await fetch('/api/player/devices/command', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target_device_id: target, + jam_id: jamId, command, payload, }), @@ -1198,6 +1241,118 @@ document.addEventListener('alpine:init', () => { return false; } }, + + openJamPanel() { + this.jamPanelOpen = !this.jamPanelOpen; + if (this.jamPanelOpen && this.jamQuery.trim()) this.searchJamUsers(); + }, + + queueJamSearch() { + clearTimeout(this._jamSearchTimer); + this._jamSearchTimer = setTimeout(() => this.searchJamUsers(), 180); + }, + + async searchJamUsers() { + const query = this.jamQuery.trim(); + if (!query) { + this.jamUsers = []; + return; + } + this.jamSearching = true; + try { + const res = await fetch('/api/player/jams/users?q=' + encodeURIComponent(query)); + if (!res.ok) return; + const selected = new Set(this.jamSelectedUsers.map(user => user.id)); + this.jamUsers = (await res.json()).filter(user => !selected.has(user.id)); + } catch { + } finally { + this.jamSearching = false; + } + }, + + addJamInvitee(user) { + if (!user || this.jamSelectedUsers.some(item => item.id === user.id)) return; + this.jamSelectedUsers.push(user); + this.jamUsers = this.jamUsers.filter(item => item.id !== user.id); + this.jamQuery = ''; + }, + + removeJamInvitee(userId) { + this.jamSelectedUsers = this.jamSelectedUsers.filter(user => user.id !== userId); + }, + + async createJam() { + try { + const res = await fetch('/api/player/jams', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + device_id: this.id, + invitee_user_ids: this.jamSelectedUsers.map(user => user.id), + }), + }); + if (!res.ok) return; + const data = await res.json(); + this._apply(data); + this.jamPanelOpen = false; + this.jamQuery = ''; + this.jamUsers = []; + this.jamSelectedUsers = []; + this.open = false; + } catch {} + }, + + async selectJam(jam) { + if (!jam) return; + try { + if (jam.is_pending) { + const ok = window.confirm('Join this Jam and control the shared queue?'); + if (!ok) return; + } + const res = await fetch('/api/player/jams/join', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jam_id: jam.id, + device_id: this.id, + }), + }); + if (!res.ok) return; + const data = await res.json(); + this.currentJamId = jam.id; + sessionStorage.setItem('furu_player_jam_id', jam.id); + this._apply(data); + this.open = false; + const player = Alpine.store('player'); + if (player && this.isControllingRemoteJam() && data.playback_state) { + player._applyRemotePlaybackState(data.playback_state); + } + } catch {} + }, + + async leaveJam(jamId = null) { + const id = jamId || this.currentJamId; + if (!id) return; + try { + const res = await fetch('/api/player/jams/leave', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jam_id: id, + device_id: this.id, + }), + }); + if (res.ok) this._apply(await res.json()); + } catch { + } finally { + this.clearJamSelection(); + } + }, + + clearJamSelection() { + this.currentJamId = null; + sessionStorage.removeItem('furu_player_jam_id'); + }, }); // ----------------------------------------------------------------------- @@ -1210,16 +1365,27 @@ document.addEventListener('alpine:init', () => { _dragIdx: null, add(track) { - this.tracks.push(track); + this.addToEnd([track]); }, addToEnd(tracks) { - this.tracks = [...this.tracks, ...tracks]; + const items = this._trackList(tracks); + if (!items.length) return; + if (this._sendRemoteQueueCommand('queue_add_end', { tracks: items })) { + this._addToEndLocal(items); + return; + } + this._addToEndLocal(items); }, addNextInQueue(tracks) { - const insertAt = this.currentIndex + 1; - this.tracks.splice(insertAt, 0, ...tracks); + const items = this._trackList(tracks); + if (!items.length) return; + if (this._sendRemoteQueueCommand('queue_add_next', { tracks: items })) { + this._addNextLocal(items); + return; + } + this._addNextLocal(items); }, playRelease(tracks, startIndex) { @@ -1233,6 +1399,54 @@ document.addEventListener('alpine:init', () => { }, remove(idx) { + if (this._sendRemoteQueueCommand('queue_remove', { index: idx })) { + this._removeLocal(idx); + return; + } + this._removeLocal(idx); + }, + + moveTrack(fromIdx, toIdx) { + if (this._sendRemoteQueueCommand('queue_move', { from_index: fromIdx, to_index: toIdx })) { + this._moveTrackLocal(fromIdx, toIdx); + return; + } + this._moveTrackLocal(fromIdx, toIdx); + }, + + clear() { + if (this._sendRemoteQueueCommand('queue_clear')) { + this._clearLocal(); + return; + } + this._clearLocal(); + }, + + _trackList(tracks) { + return (Array.isArray(tracks) ? tracks : [tracks]).filter(Boolean); + }, + + _sendRemoteQueueCommand(command, payload = {}) { + const player = Alpine.store('player'); + if (!player?._shouldSendRemote()) return false; + Alpine.store('devices')?.sendCommand(command, payload); + return true; + }, + + _addToEndLocal(tracks) { + const items = this._trackList(tracks); + if (!items.length) return; + this.tracks = [...this.tracks, ...items]; + }, + + _addNextLocal(tracks) { + const items = this._trackList(tracks); + if (!items.length) return; + const insertAt = Math.min(this.currentIndex + 1, this.tracks.length); + this.tracks.splice(insertAt, 0, ...items); + }, + + _removeLocal(idx) { if (idx < 0 || idx >= this.tracks.length) return; this.tracks.splice(idx, 1); if (this.tracks.length === 0) { @@ -1246,7 +1460,7 @@ document.addEventListener('alpine:init', () => { } }, - moveTrack(fromIdx, toIdx) { + _moveTrackLocal(fromIdx, toIdx) { if (fromIdx === toIdx) return; if (fromIdx < 0 || fromIdx >= this.tracks.length) return; if (toIdx < 0 || toIdx >= this.tracks.length) return; @@ -1262,7 +1476,7 @@ document.addEventListener('alpine:init', () => { } }, - clear() { + _clearLocal() { this.tracks = []; this.currentIndex = 0; }, @@ -1933,6 +2147,35 @@ document.addEventListener('alpine:init', () => { loadingAgentStatus: false, uploadProgress: 0, uploadProgressText: '', + activeTab: 'import', + uploadTracks: [], + uploadReleases: [], + uploadPending: [], + uploadQueued: [], + uploadPendingTotal: 0, + uploadQueuedTotal: 0, + uploadLoaded: false, + uploadLoading: false, + uploadEditId: null, + uploadSavingId: null, + uploadDraft: null, + uploadReviewEditId: null, + uploadReviewSavingId: null, + uploadReviewDraft: null, + uploadReleaseEditId: null, + uploadReleaseSavingId: null, + uploadReleaseDraft: null, + selectedUploadTracks: new Set(), + expandedUploadReleases: new Set(), + uploadBulkSaving: false, + uploadBulkDraft: { + artists: '', + featured_artists: '', + release_title: '', + release_type: '', + release_year: '', + hidden: '', + }, open() { this.modal = true; @@ -1940,6 +2183,7 @@ document.addEventListener('alpine:init', () => { this.error = false; this.loadSessions(); this.loadAgentStatus(); + if (this.activeTab === 'uploads') this.loadUploads(); this._startRefresh(); }, @@ -1959,6 +2203,18 @@ document.addEventListener('alpine:init', () => { return this.workspaceMode === 'new'; }, + showImportTab() { + this.activeTab = 'import'; + this._setMessage(''); + }, + + showUploadsTab() { + this.activeTab = 'uploads'; + this._stopPoll(); + this._setMessage(''); + this.loadUploads(); + }, + addNew() { if (this.loading) return; this._stopPoll(); @@ -2161,11 +2417,390 @@ document.addEventListener('alpine:init', () => { } }, + async loadUploads({ silent = false, preserveEditor = true } = {}) { + if (preserveEditor && this.uploadHasEditorOpen()) return; + if (this.uploadLoading) return; + this.uploadLoading = true; + try { + const res = await fetch('/api/player/uploads/tracks'); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to load uploaded tracks'); + this.applyUploadPage(data); + this.uploadLoaded = true; + if (this.uploadEditId && !this.uploadTracks.some(item => item.track.id === this.uploadEditId)) { + this.cancelUploadEdit(); + } + } catch (err) { + if (!silent) this._setMessage(err.message || String(err), true); + if (!this.uploadLoaded) { + this.uploadTracks = []; + this.uploadReleases = []; + this.uploadPending = []; + this.uploadQueued = []; + this.uploadPendingTotal = 0; + this.uploadQueuedTotal = 0; + } + } finally { + this.uploadLoading = false; + } + }, + + uploadSummary() { + const trackCount = this.uploadTracks.length; + const releaseCount = this.uploadReleases.length; + const parts = [trackCount + ' tracks', releaseCount + ' releases']; + if (this.uploadPendingTotal > 0) parts.push(this.uploadPendingTotal + ' need approval'); + if (this.uploadQueuedTotal > 0) parts.push(this.uploadQueuedTotal + ' queued'); + return parts.join(' / '); + }, + + uploadArtistsText(item) { + const track = item?.track || item; + const names = [ + ...((track?.artists || []).map(artist => artist.name)), + ...((track?.featured_artists || []).map(artist => 'ft. ' + artist.name)), + ]; + return names.join(', ') || T.unknown; + }, + + uploadReleaseArtistsText(release) { + const names = (release?.artists || []).map(artist => artist.name); + return names.join(', ') || T.unknown; + }, + + uploadFeaturedArtistsText(item) { + const track = item?.track || item; + return (track?.featured_artists || []).map(artist => artist.name).join(', '); + }, + + compactQueuedUploads() { + return this.uploadQueued.slice(0, 6); + }, + + uploadHasEditorOpen() { + return !!(this.uploadEditId || this.uploadReviewEditId || this.uploadReleaseEditId); + }, + + uploadEditorKicker() { + if (this.uploadReviewDraft) return 'Needs approval'; + if (this.uploadReleaseDraft) return 'Release metadata'; + if (this.uploadDraft) return 'Track metadata'; + return 'Metadata'; + }, + + uploadEditorTitle() { + if (this.uploadReviewDraft) return 'Approve metadata'; + if (this.uploadReleaseDraft) return this.uploadReleaseDraft.title || 'Edit release'; + if (this.uploadDraft) return this.uploadDraft.title || 'Edit track'; + return 'Edit metadata'; + }, + + closeUploadEditor() { + this.cancelUploadEdit(); + this.cancelUploadReleaseEdit(); + this.cancelUploadReviewEdit(); + }, + + uploadReleaseExpanded(releaseId) { + return this.expandedUploadReleases.has(releaseId); + }, + + toggleUploadRelease(releaseId) { + if (this.expandedUploadReleases.has(releaseId)) this.expandedUploadReleases.delete(releaseId); + else this.expandedUploadReleases.add(releaseId); + }, + + uploadReleaseTrackIds(release) { + return (release?.tracks || []).map(item => item.track.id); + }, + + uploadReleaseSelectionState(release) { + const ids = this.uploadReleaseTrackIds(release); + const selected = ids.filter(id => this.selectedUploadTracks.has(id)).length; + if (selected === 0) return 'empty'; + return selected === ids.length ? 'checked' : 'partial'; + }, + + toggleUploadReleaseSelection(release) { + const ids = this.uploadReleaseTrackIds(release); + const state = this.uploadReleaseSelectionState(release); + if (state === 'checked') ids.forEach(id => this.selectedUploadTracks.delete(id)); + else ids.forEach(id => this.selectedUploadTracks.add(id)); + }, + + toggleUploadTrackSelection(trackId) { + if (this.selectedUploadTracks.has(trackId)) this.selectedUploadTracks.delete(trackId); + else this.selectedUploadTracks.add(trackId); + }, + + uploadSelectedCount() { + return this.selectedUploadTracks.size; + }, + + clearUploadSelection() { + this.selectedUploadTracks.clear(); + this.uploadBulkDraft = { + artists: '', + featured_artists: '', + release_title: '', + release_type: '', + release_year: '', + hidden: '', + }; + }, + + pruneUploadSelection() { + const valid = new Set(this.uploadTracks.map(item => item.track.id)); + for (const id of [...this.selectedUploadTracks]) { + if (!valid.has(id)) this.selectedUploadTracks.delete(id); + } + }, + + editUpload(item) { + if (!item || !item.track) return; + this.cancelUploadReleaseEdit(); + this.cancelUploadReviewEdit(); + this.uploadEditId = item.track.id; + this.uploadDraft = { + title: item.track.title || '', + artists: (item.track.artists || []).map(artist => artist.name).join(', '), + featured_artists: this.uploadFeaturedArtistsText(item), + release_title: item.track.release_title || '', + release_type: item.release_type || 'album', + release_year: item.track.release_year == null ? '' : String(item.track.release_year), + track_number: item.track.track_number == null ? '' : String(item.track.track_number), + disc_number: item.track.disc_number == null ? '' : String(item.track.disc_number), + is_hidden: !!item.is_hidden, + }; + }, + + cancelUploadEdit() { + this.uploadEditId = null; + this.uploadSavingId = null; + this.uploadDraft = null; + }, + + async saveUploadEdit() { + if (!this.uploadEditId || !this.uploadDraft) return; + const id = this.uploadEditId; + const draft = this.uploadDraft; + const artistNames = String(draft.artists || '') + .split(',') + .map(name => name.trim()) + .filter(Boolean); + this.uploadSavingId = id; + try { + const res = await fetch(`/api/player/uploads/tracks/${id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: draft.title, + artist_names: artistNames, + featured_artist_names: String(draft.featured_artists || '').split(',').map(name => name.trim()).filter(Boolean), + release_title: draft.release_title, + release_type: draft.release_type, + release_year: String(draft.release_year || ''), + track_number: String(draft.track_number || ''), + disc_number: String(draft.disc_number || ''), + is_hidden: !!draft.is_hidden, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to save track'); + this.uploadTracks = this.uploadTracks.map(item => item.track.id === id ? data : item); + this.cancelUploadEdit(); + this.loadUploads({ silent: true, preserveEditor: false }); + this._setMessage('Track metadata saved'); + } catch (err) { + this._setMessage(err.message || String(err), true); + } finally { + this.uploadSavingId = null; + } + }, + + editUploadRelease(release) { + if (!release) return; + this.cancelUploadEdit(); + this.cancelUploadReviewEdit(); + this.uploadReleaseEditId = release.id; + this.uploadReleaseDraft = { + title: release.title || '', + artists: this.uploadReleaseArtistsText(release), + release_type: release.release_type || 'album', + year: release.year == null ? '' : String(release.year), + is_hidden: !!release.is_hidden, + }; + }, + + cancelUploadReleaseEdit() { + this.uploadReleaseEditId = null; + this.uploadReleaseSavingId = null; + this.uploadReleaseDraft = null; + }, + + async saveUploadReleaseEdit() { + if (!this.uploadReleaseEditId || !this.uploadReleaseDraft) return; + const id = this.uploadReleaseEditId; + const draft = this.uploadReleaseDraft; + this.uploadReleaseSavingId = id; + try { + const res = await fetch(`/api/player/uploads/releases/${id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: draft.title, + artist_names: String(draft.artists || '').split(',').map(name => name.trim()).filter(Boolean), + release_type: draft.release_type, + year: String(draft.year || ''), + is_hidden: !!draft.is_hidden, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to save release'); + this.applyUploadPage(data); + this.cancelUploadReleaseEdit(); + this._setMessage('Release metadata saved'); + } catch (err) { + this._setMessage(err.message || String(err), true); + } finally { + this.uploadReleaseSavingId = null; + } + }, + + uploadReviewPayload() { + const draft = this.uploadReviewDraft || {}; + return { + title: draft.title, + artist: draft.artist, + album: draft.album, + year: String(draft.year || ''), + track_number: String(draft.track_number || ''), + genre: draft.genre, + featured_artists: String(draft.featured_artists || '').split(',').map(name => name.trim()).filter(Boolean), + release_type: draft.release_type || 'album', + notes: draft.notes, + }; + }, + + editUploadReview(item) { + if (!item) return; + this.cancelUploadEdit(); + this.cancelUploadReleaseEdit(); + this.uploadReviewEditId = item.id; + this.uploadReviewDraft = { + title: item.fields?.title || '', + artist: item.fields?.artist || '', + album: item.fields?.album || '', + year: item.fields?.year || '', + track_number: item.fields?.track_number || '', + genre: item.fields?.genre || '', + featured_artists: (item.fields?.featured_artists || []).join(', '), + release_type: item.fields?.release_type || 'album', + notes: item.fields?.notes || '', + }; + }, + + cancelUploadReviewEdit() { + this.uploadReviewEditId = null; + this.uploadReviewSavingId = null; + this.uploadReviewDraft = null; + }, + + async saveUploadReview() { + if (!this.uploadReviewEditId || !this.uploadReviewDraft) return; + const id = this.uploadReviewEditId; + this.uploadReviewSavingId = id; + try { + const res = await fetch(`/api/player/uploads/reviews/${id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this.uploadReviewPayload()), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to save review'); + this.uploadPending = this.uploadPending.map(item => item.id === id ? data : item); + this._setMessage('Pending metadata saved'); + } catch (err) { + this._setMessage(err.message || String(err), true); + } finally { + this.uploadReviewSavingId = null; + } + }, + + async approveUploadReview() { + if (!this.uploadReviewEditId || !this.uploadReviewDraft) return; + const id = this.uploadReviewEditId; + this.uploadReviewSavingId = id; + try { + const res = await fetch(`/api/player/uploads/reviews/${id}/approve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this.uploadReviewPayload()), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to approve review'); + this.applyUploadPage(data); + this.cancelUploadReviewEdit(); + this._setMessage('Track approved and imported'); + } catch (err) { + this._setMessage(err.message || String(err), true); + } finally { + this.uploadReviewSavingId = null; + } + }, + + async saveUploadBulkEdit() { + const trackIds = [...this.selectedUploadTracks]; + if (trackIds.length === 0) return; + const draft = this.uploadBulkDraft; + const hidden = draft.hidden === '' ? null : draft.hidden === 'true'; + const artists = String(draft.artists || '').split(',').map(name => name.trim()).filter(Boolean); + const featured = String(draft.featured_artists || '').split(',').map(name => name.trim()).filter(Boolean); + this.uploadBulkSaving = true; + try { + const res = await fetch('/api/player/uploads/bulk-tracks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + track_ids: trackIds, + artist_names: artists.length ? artists : null, + featured_artist_names: featured.length ? featured : null, + release_title: draft.release_title || null, + release_type: draft.release_type || null, + release_year: draft.release_year === '' ? null : String(draft.release_year), + is_hidden: hidden, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to update selected tracks'); + this.applyUploadPage(data); + this.clearUploadSelection(); + this._setMessage('Selected tracks updated'); + } catch (err) { + this._setMessage(err.message || String(err), true); + } finally { + this.uploadBulkSaving = false; + } + }, + + applyUploadPage(data) { + this.uploadTracks = Array.isArray(data.tracks) ? data.tracks : []; + this.uploadReleases = Array.isArray(data.releases) ? data.releases : []; + this.uploadPending = Array.isArray(data.pending) ? data.pending : []; + this.uploadQueued = Array.isArray(data.queued) ? data.queued : []; + this.uploadPendingTotal = Number(data.pending_total || this.uploadPending.length || 0); + this.uploadQueuedTotal = Number(data.queued_total || this.uploadQueued.length || 0); + this.pruneUploadSelection(); + }, + _startRefresh() { this._stopRefresh(); this._refreshTimer = setInterval(() => { if (!this.modal) return; - this.loadSessions(); + if (this.activeTab === 'uploads') { + this.loadUploads({ silent: true }); + } + else this.loadSessions(); this.loadAgentStatus(); }, 5000); }, diff --git a/templates/player/shell.html b/templates/player/shell.html index aab8f79..35ec093 100644 --- a/templates/player/shell.html +++ b/templates/player/shell.html @@ -762,7 +762,7 @@