From 5f925be29b45416bd76202be85fa362c3fc9d8a4 Mon Sep 17 00:00:00 2001 From: AB Date: Mon, 25 May 2026 23:04:58 +0300 Subject: [PATCH] Added user attribution --- Cargo.lock | 2 +- src/agent/cover_art.rs | 2 + src/jobs/inbox_discover.rs | 6 +- src/jobs/inbox_process.rs | 21 +- src/jobs/mod.rs | 70 +++++ src/music/mod.rs | 46 +++ src/player/dto.rs | 195 +++++++++++++ src/player/helpers.rs | 48 ++++ src/player/mod.rs | 559 ++++++++----------------------------- src/player/queries.rs | 72 +++++ src/player/rows.rs | 185 ++++++++++++ src/torrents.rs | 16 +- templates/player.html | 122 ++++++++ 13 files changed, 901 insertions(+), 443 deletions(-) create mode 100644 src/player/dto.rs create mode 100644 src/player/helpers.rs create mode 100644 src/player/queries.rs create mode 100644 src/player/rows.rs diff --git a/Cargo.lock b/Cargo.lock index 5c79e86..3f975db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.1.6" +version = "0.1.7" dependencies = [ "anyhow", "async-trait", diff --git a/src/agent/cover_art.rs b/src/agent/cover_art.rs index b70f6a7..61c9ec8 100644 --- a/src/agent/cover_art.rs +++ b/src/agent/cover_art.rs @@ -360,6 +360,8 @@ pub async fn save_cover_to_storage( None, None, None, + None, + Some("UFO"), ) .await .map_err(|e| anyhow::anyhow!("failed to create cover MediaFile: {e}"))?; diff --git a/src/jobs/inbox_discover.rs b/src/jobs/inbox_discover.rs index 8968173..782edce 100644 --- a/src/jobs/inbox_discover.rs +++ b/src/jobs/inbox_discover.rs @@ -140,7 +140,9 @@ impl Job for InboxDiscoverJob { // Parse path hints let relative = file_path.strip_prefix(inbox).unwrap_or(file_path); - let hints = crate::agent::path_hints::parse(relative); + let uploader = crate::jobs::uploader_from_relative_path(&ctx.pool, relative).await; + let hinted_relative = crate::jobs::strip_user_upload_prefix(relative); + let hints = crate::agent::path_hints::parse(&hinted_relative); // Build context JSON let context = serde_json::json!({ @@ -156,6 +158,8 @@ impl Job for InboxDiscoverJob { "audio_bitrate": raw_meta.audio_bitrate, "audio_sample_rate": raw_meta.audio_sample_rate, "audio_bit_depth": raw_meta.audio_bit_depth, + "uploaded_by_user_id": uploader.user_id, + "uploader_name": uploader.name, "path_title": hints.title, "path_artist": hints.artist, "path_album": hints.album, diff --git a/src/jobs/inbox_process.rs b/src/jobs/inbox_process.rs index 78acf23..b40d5fd 100644 --- a/src/jobs/inbox_process.rs +++ b/src/jobs/inbox_process.rs @@ -337,7 +337,9 @@ async fn process_folder_batch( // Parse path hints let relative = file_path.strip_prefix(inbox_path).unwrap_or(file_path); - let hints = crate::agent::path_hints::parse(relative); + let uploader = crate::jobs::uploader_from_relative_path(pool, relative).await; + let hinted_relative = crate::jobs::strip_user_upload_prefix(relative); + let hints = crate::agent::path_hints::parse(&hinted_relative); if let Some(context_obj) = context.as_object_mut() { context_obj.insert( "audio_bitrate".to_owned(), @@ -351,6 +353,15 @@ async fn process_folder_batch( "audio_bit_depth".to_owned(), serde_json::json!(raw_meta.audio_bit_depth), ); + if !context_obj.contains_key("uploaded_by_user_id") { + context_obj.insert( + "uploaded_by_user_id".to_owned(), + serde_json::json!(uploader.user_id), + ); + } + if !context_obj.contains_key("uploader_name") { + context_obj.insert("uploader_name".to_owned(), serde_json::json!(uploader.name)); + } } prepared.push(PreparedFile { @@ -737,6 +748,12 @@ pub async fn finalize_approved( .get("audio_bit_depth") .and_then(|v| v.as_i64()) .and_then(|v| i32::try_from(v).ok()); + let uploaded_by_user_id = context.get("uploaded_by_user_id").and_then(|v| v.as_i64()); + let uploader_name = context + .get("uploader_name") + .and_then(|v| v.as_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("UFO"); let source_path = Path::new(input_path_str); let original_filename = source_path @@ -805,6 +822,8 @@ pub async fn finalize_approved( audio_bitrate, audio_sample_rate, audio_bit_depth, + uploaded_by_user_id, + Some(uploader_name), ) .await .map_err(|e| anyhow::anyhow!("failed to create media file: {e}"))?; diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs index a6c8e75..75b5436 100644 --- a/src/jobs/mod.rs +++ b/src/jobs/mod.rs @@ -4,3 +4,73 @@ pub mod cover_backfill; pub mod inbox_discover; pub mod inbox_process; pub mod metadata_backfill; + +use std::path::{Component, Path, PathBuf}; + +#[derive(Debug, Clone)] +pub struct UploaderAttribution { + pub user_id: Option, + pub name: String, +} + +impl UploaderAttribution { + pub fn unknown() -> Self { + Self { + user_id: None, + name: "UFO".to_string(), + } + } +} + +pub fn strip_user_upload_prefix(relative_path: &Path) -> PathBuf { + let components: Vec<_> = relative_path.components().collect(); + if components.len() >= 3 + && matches!(components[0], Component::Normal(value) if value == "user_uploads") + { + components[2..].iter().collect() + } else { + relative_path.to_path_buf() + } +} + +pub async fn uploader_from_relative_path( + pool: &sqlx::PgPool, + relative_path: &Path, +) -> UploaderAttribution { + let components: Vec<_> = relative_path.components().collect(); + let Some(Component::Normal(root)) = components.first() else { + return UploaderAttribution::unknown(); + }; + if *root != "user_uploads" { + return UploaderAttribution::unknown(); + } + + let Some(Component::Normal(user_id_os)) = components.get(1) else { + return UploaderAttribution::unknown(); + }; + let Some(user_id_str) = user_id_os.to_str() else { + return UploaderAttribution::unknown(); + }; + let Ok(user_id) = user_id_str.parse::() else { + return UploaderAttribution::unknown(); + }; + + let name: Option = sqlx::query_scalar( + r#"SELECT COALESCE(NULLIF(display_name, ''), username)::text + FROM furumusic__user + WHERE id = $1 AND is_active = true"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + .ok() + .flatten(); + + match name { + Some(name) if !name.trim().is_empty() => UploaderAttribution { + user_id: Some(user_id), + name, + }, + _ => UploaderAttribution::unknown(), + } +} diff --git a/src/music/mod.rs b/src/music/mod.rs index df0d786..a768029 100644 --- a/src/music/mod.rs +++ b/src/music/mod.rs @@ -36,6 +36,10 @@ pub struct MediaFile { pub audio_sample_rate: Option, /// Bit depth (16, 24, 32) pub audio_bit_depth: Option, + /// FK -> user who imported/uploaded the source, NULL when unknown. + pub uploaded_by_user_id: Option, + /// Stable display label for the uploader. Unknown uploads are stored as "UFO". + pub uploader_name: LimitedString<255>, pub created_at: LimitedString<32>, } @@ -607,8 +611,13 @@ impl MediaFile { audio_bitrate: Option, audio_sample_rate: Option, audio_bit_depth: Option, + uploaded_by_user_id: Option, + uploader_name: Option<&str>, ) -> cot::db::Result { let now = now_iso(); + let uploader_name = uploader_name + .filter(|name| !name.trim().is_empty()) + .unwrap_or("UFO"); let mut mf = Self { id: Auto::auto(), file_type: LimitedString::new(file_type).unwrap(), @@ -621,6 +630,8 @@ impl MediaFile { audio_bitrate, audio_sample_rate, audio_bit_depth, + uploaded_by_user_id, + uploader_name: LimitedString::new(uploader_name).unwrap(), created_at: now, }; mf.insert(db).await?; @@ -1533,6 +1544,40 @@ pub mod db_migrations { const OPERATIONS: &'static [Operation] = &[Operation::custom(add_playback_volume).build()]; } + // -- M0030: add uploader attribution to media_file ------------------------ + + #[cot::db::migrations::migration_op] + async fn add_media_file_uploader(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> { + ctx.db + .raw("ALTER TABLE furumusic__media_file ADD COLUMN uploaded_by_user_id BIGINT DEFAULT NULL") + .await?; + ctx.db + .raw("ALTER TABLE furumusic__media_file ADD COLUMN uploader_name VARCHAR(255) NOT NULL DEFAULT 'UFO'") + .await?; + ctx.db + .raw("CREATE INDEX IF NOT EXISTS idx_media_file_uploaded_by_user ON furumusic__media_file (uploaded_by_user_id)") + .await?; + ctx.db + .raw("CREATE INDEX IF NOT EXISTS idx_media_file_uploader_name ON furumusic__media_file (uploader_name)") + .await?; + Ok(()) + } + + #[derive(Debug, Copy, Clone)] + pub struct M0030AddMediaFileUploader; + + impl migrations::Migration for M0030AddMediaFileUploader { + const APP_NAME: &'static str = "furumusic"; + const MIGRATION_NAME: &'static str = "m_0030_add_media_file_uploader"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "furumusic", + "m_0029_add_playback_volume", + )]; + const OPERATIONS: &'static [Operation] = + &[Operation::custom(add_media_file_uploader).build()]; + } + pub const MIGRATIONS: &[&SyncDynMigration] = &[ &M0006CreateMediaFile, &M0007CreateArtist, @@ -1553,5 +1598,6 @@ pub mod db_migrations { &M0022CreateTrackTrgmIndex, &M0028AddModelNameColumns, &M0029AddPlaybackVolume, + &M0030AddMediaFileUploader, ]; } diff --git a/src/player/dto.rs b/src/player/dto.rs new file mode 100644 index 0000000..b7a74e1 --- /dev/null +++ b/src/player/dto.rs @@ -0,0 +1,195 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct ArtistCard { + pub(super) id: i64, + pub(super) name: String, + pub(super) image_url: Option, + pub(super) release_count: i64, + pub(super) track_count: i64, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct Paginated { + pub(super) items: Vec, + pub(super) total: i64, + pub(super) page: i32, + pub(super) per_page: i32, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct ReleaseCard { + pub(super) id: i64, + pub(super) title: String, + pub(super) release_type: String, + pub(super) year: Option, + pub(super) cover_url: Option, + pub(super) track_count: i64, + pub(super) uploaders: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct ArtistDetail { + pub(super) id: i64, + pub(super) name: String, + pub(super) image_url: Option, + pub(super) total_track_count: i64, + pub(super) total_play_count: i64, + pub(super) releases: Vec, + pub(super) featured_tracks: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct ArtistRef { + pub(super) id: i64, + pub(super) name: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct TrackItem { + 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) artists: Vec, + pub(super) featured_artists: Vec, + pub(super) cover_url: Option, + pub(super) stream_url: String, + 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, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct ArtistAppearanceTrack { + pub(super) id: i64, + pub(super) title: String, + pub(super) release_id: i64, + pub(super) release_title: String, + pub(super) duration_seconds: f64, + pub(super) artists: Vec, + pub(super) featured_artists: Vec, + pub(super) cover_url: Option, + pub(super) stream_url: String, + 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, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct ReleaseDetail { + pub(super) id: i64, + pub(super) title: String, + pub(super) release_type: String, + pub(super) year: Option, + pub(super) cover_url: Option, + pub(super) artists: Vec, + pub(super) tracks: Vec, + pub(super) uploaders: Vec, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub(super) struct UploaderSummary { + pub(super) name: String, + pub(super) track_count: i64, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct PlaylistCard { + pub(super) id: i64, + pub(super) title: String, + pub(super) track_count: i64, + pub(super) is_own: bool, + pub(super) kind: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub(super) struct PlaybackStateDto { + pub(super) current_track_id: Option, + pub(super) position_ms: i32, + pub(super) queue: Vec, + pub(super) queue_position: i32, + pub(super) shuffle: bool, + pub(super) repeat_mode: String, + pub(super) volume: f64, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct PlaylistDetail { + pub(super) id: i64, + pub(super) title: String, + pub(super) description: Option, + pub(super) is_own: bool, + pub(super) kind: String, + pub(super) tracks: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct SearchResults { + pub(super) artists: Vec, + pub(super) releases: Vec, + pub(super) tracks: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct UserStats { + pub(super) liked_tracks: i64, + pub(super) playlists: i64, + pub(super) plays: i64, + pub(super) listened_minutes: i64, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct UserProfile { + pub(super) name: String, + pub(super) role: String, + pub(super) stats: UserStats, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct PlayHistoryItem { + pub(super) id: i64, + pub(super) track_id: i64, + pub(super) track_title: String, + pub(super) release_title: Option, + pub(super) played_at: String, + pub(super) duration_listened: Option, + pub(super) completed: bool, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct PlayHistoryPage { + pub(super) items: Vec, + pub(super) total: i64, + pub(super) page: i32, + pub(super) per_page: i32, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct LikeStatus { + pub(super) liked: bool, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct LikedIds { + pub(super) track_ids: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct FollowStatus { + pub(super) followed: bool, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct FollowedArtists { + pub(super) artist_ids: Vec, + pub(super) artists: Vec, +} diff --git a/src/player/helpers.rs b/src/player/helpers.rs new file mode 100644 index 0000000..4de91e1 --- /dev/null +++ b/src/player/helpers.rs @@ -0,0 +1,48 @@ +use crate::player::dto::UploaderSummary; +use crate::player::rows::ReleaseUploaderRow; + +pub(super) fn cover_url(file_id: Option) -> Option { + file_id.map(|id| format!("/api/player/cover/{id}")) +} + +pub(super) fn track_cover_url( + track_cover: Option, + release_cover: Option, +) -> Option { + cover_url(track_cover.or(release_cover)) +} + +pub(super) async fn load_release_uploaders( + pool: &sqlx::PgPool, + release_ids: &[i64], +) -> Result>, sqlx::Error> { + if release_ids.is_empty() { + return Ok(std::collections::HashMap::new()); + } + + let rows = sqlx::query_as::<_, ReleaseUploaderRow>( + r#"SELECT t.release_id, + COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name, + COUNT(*)::bigint AS track_count + FROM furumusic__track t + LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id + WHERE t.release_id = ANY($1) AND t.is_hidden = false + GROUP BY t.release_id, COALESCE(mf.uploader_name, 'UFO') + ORDER BY t.release_id, track_count DESC, uploader_name"#, + ) + .bind(release_ids) + .fetch_all(pool) + .await?; + + let mut map: std::collections::HashMap> = + std::collections::HashMap::new(); + for row in rows { + map.entry(row.release_id) + .or_default() + .push(UploaderSummary { + name: row.uploader_name, + track_count: row.track_count, + }); + } + Ok(map) +} diff --git a/src/player/mod.rs b/src/player/mod.rs index d7452dc..8b00953 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -10,8 +10,6 @@ use cot::router::method::{get, post}; use cot::router::{Route, Router}; use cot::session::Session; use cot::{App, Body, Template}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; use crate::auth; use crate::config::AppConfig; @@ -19,6 +17,16 @@ use crate::i18n::Translations; use crate::scheduler::SchedulerHandle; use crate::torrents::{TorrentPreviewRequest, TorrentService, TorrentStartRequest}; +mod dto; +mod helpers; +mod queries; +mod rows; + +use dto::*; +use helpers::{cover_url, load_release_uploaders, track_cover_url}; +use queries::*; +use rows::*; + // --------------------------------------------------------------------------- // JSON error helper // --------------------------------------------------------------------------- @@ -32,429 +40,6 @@ fn json_error(status: StatusCode, message: &str) -> cot::response::Response { .expect("valid response") } -// --------------------------------------------------------------------------- -// DTO structs -// --------------------------------------------------------------------------- - -#[derive(Debug, Serialize, JsonSchema)] -struct ArtistCard { - id: i64, - name: String, - image_url: Option, - release_count: i64, - track_count: i64, -} - -#[derive(Debug, Serialize, JsonSchema)] -struct Paginated { - items: Vec, - total: i64, - page: i32, - per_page: i32, -} - -#[derive(Debug, Serialize, JsonSchema)] -struct ReleaseCard { - id: i64, - title: String, - release_type: String, - year: Option, - cover_url: Option, - track_count: i64, -} - -#[derive(Debug, Serialize, JsonSchema)] -struct ArtistDetail { - id: i64, - name: String, - image_url: Option, - total_track_count: i64, - total_play_count: i64, - releases: Vec, - featured_tracks: Vec, -} - -#[derive(Debug, Serialize, JsonSchema)] -struct ArtistRef { - id: i64, - name: String, -} - -#[derive(Debug, Serialize, JsonSchema)] -struct TrackItem { - id: i64, - title: String, - track_number: Option, - disc_number: Option, - duration_seconds: f64, - artists: Vec, - featured_artists: Vec, - cover_url: Option, - stream_url: String, -} - -#[derive(Debug, Serialize, JsonSchema)] -struct ArtistAppearanceTrack { - id: i64, - title: String, - release_id: i64, - release_title: String, - duration_seconds: f64, - artists: Vec, - featured_artists: Vec, - cover_url: Option, - stream_url: String, -} - -#[derive(Debug, Serialize, JsonSchema)] -struct ReleaseDetail { - id: i64, - title: String, - release_type: String, - year: Option, - cover_url: Option, - artists: Vec, - tracks: Vec, -} - -#[derive(Debug, Serialize, JsonSchema)] -struct PlaylistCard { - id: i64, - title: String, - track_count: i64, - is_own: bool, - kind: String, // "user" or "likes" -} - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -struct PlaybackStateDto { - current_track_id: Option, - position_ms: i32, - queue: Vec, - queue_position: i32, - shuffle: bool, - repeat_mode: String, - volume: f64, -} - -#[derive(Debug, Serialize, JsonSchema)] -struct PlaylistDetail { - id: i64, - title: String, - description: Option, - is_own: bool, - kind: String, - tracks: Vec, -} - -#[derive(Debug, Serialize, JsonSchema)] -struct SearchResults { - artists: Vec, - releases: Vec, - tracks: Vec, -} - -#[derive(Debug, Serialize, JsonSchema)] -struct UserStats { - liked_tracks: i64, - playlists: i64, - plays: i64, - listened_minutes: i64, -} - -#[derive(Debug, Serialize, JsonSchema)] -struct UserProfile { - name: String, - role: String, - stats: UserStats, -} - -#[derive(Debug, Serialize, JsonSchema)] -struct PlayHistoryItem { - id: i64, - track_id: i64, - track_title: String, - release_title: Option, - played_at: String, - duration_listened: Option, - completed: bool, -} - -#[derive(Debug, Serialize, JsonSchema)] -struct PlayHistoryPage { - items: Vec, - total: i64, - page: i32, - per_page: i32, -} - -#[derive(Debug, Deserialize)] -struct HistoryEntry { - track_id: i64, - duration_listened: Option, - completed: bool, -} - -#[derive(Debug, Deserialize)] -struct HistoryQuery { - page: Option, - limit: Option, -} - -#[derive(Debug, Deserialize)] -struct TracksByIdsRequest { - ids: Vec, -} - -#[derive(Debug, Deserialize)] -struct CreatePlaylistRequest { - title: String, -} - -#[derive(Debug, Deserialize)] -struct UpdatePlaylistRequest { - title: Option, - description: Option, -} - -#[derive(Debug, Deserialize)] -struct AddTracksRequest { - track_ids: Vec, -} - -#[derive(Debug, Deserialize)] -struct RemoveTrackRequest { - track_id: i64, -} - -#[derive(Debug, Serialize, JsonSchema)] -struct LikeStatus { - liked: bool, -} - -#[derive(Debug, Serialize, JsonSchema)] -struct LikedIds { - track_ids: Vec, -} - -#[derive(Debug, Serialize, JsonSchema)] -struct FollowStatus { - followed: bool, -} - -#[derive(Debug, Serialize, JsonSchema)] -struct FollowedArtists { - artist_ids: Vec, - artists: Vec, -} - -// --------------------------------------------------------------------------- -// Query helpers -// --------------------------------------------------------------------------- - -#[derive(Debug, Deserialize)] -struct PaginationQuery { - page: Option, - limit: Option, -} - -#[derive(Debug, Deserialize)] -struct PathId { - id: i64, -} - -#[derive(Debug, Deserialize)] -struct PathStringId { - id: String, -} - -#[derive(Debug, Deserialize)] -struct SearchQuery { - q: String, - limit: Option, -} - -#[derive(Debug, Deserialize)] -struct PathTrackId { - track_id: i64, -} - -#[derive(Debug, Deserialize)] -struct PathMediaFileId { - media_file_id: i64, -} - -// --------------------------------------------------------------------------- -// sqlx row types -// --------------------------------------------------------------------------- - -#[derive(sqlx::FromRow)] -struct ArtistRow { - id: i64, - name: String, - image_file_id: Option, - release_count: i64, - track_count: i64, -} - -#[derive(sqlx::FromRow)] -struct CountRow { - count: i64, -} - -#[derive(sqlx::FromRow)] -struct ReleaseRow { - id: i64, - title: String, - release_type: String, - year: Option, - cover_file_id: Option, - track_count: i64, -} - -#[derive(sqlx::FromRow)] -struct ArtistBriefRow { - id: i64, - name: String, -} - -#[derive(sqlx::FromRow)] -struct TrackRow { - id: i64, - title: String, - track_number: Option, - disc_number: Option, - duration_seconds: f64, - cover_file_id: Option, - release_cover_file_id: Option, -} - -#[derive(sqlx::FromRow)] -struct TrackArtistRow { - track_id: i64, - artist_id: i64, - artist_name: String, - role: String, -} - -#[derive(sqlx::FromRow)] -struct MediaFileRow { - file_path: String, - mime_type: String, - file_size_bytes: i64, -} - -#[derive(sqlx::FromRow)] -struct PlaybackStateRow { - current_track_id: Option, - position_ms: i32, - queue_json: String, - queue_position: i32, - shuffle: bool, - repeat_mode: String, - volume: f64, -} - -#[derive(sqlx::FromRow)] -struct PlaylistRow { - id: i64, - title: String, - track_count: i64, - is_own: bool, -} - -#[derive(sqlx::FromRow)] -struct PlaylistInfoRow { - id: i64, - title: String, - description: Option, - owner_id: i64, -} - -#[derive(sqlx::FromRow)] -struct PlaylistTrackRow { - id: i64, - title: String, - track_number: Option, - disc_number: Option, - duration_seconds: f64, - cover_file_id: Option, - release_cover_file_id: Option, -} - -#[derive(sqlx::FromRow)] -struct AppearanceTrackRow { - id: i64, - title: String, - release_id: i64, - release_title: String, - duration_seconds: f64, - cover_file_id: Option, - release_cover_file_id: Option, -} - -#[derive(sqlx::FromRow)] -struct SearchArtistRow { - id: i64, - name: String, - image_file_id: Option, - release_count: i64, - track_count: i64, -} - -#[derive(sqlx::FromRow)] -struct SearchReleaseRow { - id: i64, - title: String, - release_type: String, - year: Option, - cover_file_id: Option, - track_count: i64, -} - -#[derive(sqlx::FromRow)] -struct SearchTrackRow { - id: i64, - title: String, - track_number: Option, - disc_number: Option, - duration_seconds: f64, - cover_file_id: Option, - release_cover_file_id: Option, -} - -#[derive(sqlx::FromRow)] -struct PlayHistoryRow { - id: i64, - track_id: i64, - track_title: String, - release_title: Option, - played_at: String, - duration_listened: Option, - completed: bool, -} - -#[derive(sqlx::FromRow)] -struct ReleaseInfoRow { - id: i64, - title: String, - release_type: String, - year: Option, - cover_file_id: Option, -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn cover_url(file_id: Option) -> Option { - file_id.map(|id| format!("/api/player/cover/{id}")) -} - -fn track_cover_url(track_cover: Option, release_cover: Option) -> Option { - cover_url(track_cover.or(release_cover)) -} - // --------------------------------------------------------------------------- // SPA shell // --------------------------------------------------------------------------- @@ -643,6 +228,11 @@ async fn artist_detail_handler( .await .map_err(|e| cot::Error::internal(e.to_string()))?; + let release_ids: Vec = releases.iter().map(|r| r.id).collect(); + let mut release_uploaders = load_release_uploaders(pool, &release_ids) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let release_cards: Vec = releases .into_iter() .map(|r| ReleaseCard { @@ -652,6 +242,7 @@ async fn artist_detail_handler( year: r.year, cover_url: cover_url(r.cover_file_id), track_count: r.track_count, + uploaders: release_uploaders.remove(&r.id).unwrap_or_default(), }) .collect(); @@ -676,10 +267,17 @@ async fn artist_detail_handler( r.title::text AS release_title, t.duration_seconds, t.cover_file_id, - r.cover_file_id AS release_cover_file_id + r.cover_file_id AS release_cover_file_id, + 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 FROM furumusic__track_artist ta JOIN furumusic__track t ON t.id = ta.track_id JOIN furumusic__release r ON r.id = t.release_id + LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id WHERE ta.artist_id = $1 AND ta.role = 'featuring' AND t.is_hidden = false @@ -745,6 +343,12 @@ async fn artist_detail_handler( featured_artists: featured_feat_artists.remove(&tid).unwrap_or_default(), cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id), stream_url: format!("/api/player/stream/{tid}"), + uploader_name: t.uploader_name, + audio_format: t.audio_format, + audio_bitrate: t.audio_bitrate, + audio_sample_rate: t.audio_sample_rate, + audio_bit_depth: t.audio_bit_depth, + file_size_bytes: t.file_size_bytes, } }) .collect(); @@ -807,9 +411,16 @@ async fn release_detail_handler( let tracks = sqlx::query_as::<_, TrackRow>( 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.cover_file_id as release_cover_file_id, + 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 FROM furumusic__track t JOIN furumusic__release r ON r.id = t.release_id + LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id WHERE t.release_id = $1 AND t.is_hidden = false ORDER BY t.disc_number NULLS FIRST, t.track_number NULLS LAST"#, ) @@ -875,9 +486,20 @@ async fn release_detail_handler( featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id), stream_url: format!("/api/player/stream/{tid}"), + uploader_name: t.uploader_name, + audio_format: t.audio_format, + audio_bitrate: t.audio_bitrate, + audio_sample_rate: t.audio_sample_rate, + audio_bit_depth: t.audio_bit_depth, + file_size_bytes: t.file_size_bytes, } }) .collect(); + let uploaders = load_release_uploaders(pool, &[release.id]) + .await + .map_err(|e| cot::Error::internal(e.to_string()))? + .remove(&release.id) + .unwrap_or_default(); Json(ReleaseDetail { id: release.id, @@ -893,6 +515,7 @@ async fn release_detail_handler( }) .collect(), tracks: track_items, + uploaders, }) .into_response() } @@ -988,10 +611,17 @@ async fn playlist_detail_handler( let tracks = sqlx::query_as::<_, PlaylistTrackRow>( 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.cover_file_id as release_cover_file_id, + 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 FROM furumusic__playlist_track pt JOIN furumusic__track t ON t.id = pt.track_id JOIN furumusic__release r ON r.id = t.release_id + LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id WHERE pt.playlist_id = $1 AND t.is_hidden = false ORDER BY pt.position"#, ) @@ -1073,6 +703,12 @@ async fn build_track_items( featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id), stream_url: format!("/api/player/stream/{tid}"), + uploader_name: t.uploader_name, + audio_format: t.audio_format, + audio_bitrate: t.audio_bitrate, + audio_sample_rate: t.audio_sample_rate, + audio_bit_depth: t.audio_bit_depth, + file_size_bytes: t.file_size_bytes, } }) .collect()) @@ -1086,10 +722,17 @@ async fn likes_playlist_handler( let tracks = sqlx::query_as::<_, PlaylistTrackRow>( 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.cover_file_id as release_cover_file_id, + 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 FROM furumusic__user_liked_track ult JOIN furumusic__track t ON t.id = ult.track_id JOIN furumusic__release r ON r.id = t.release_id + LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id WHERE ult.user_id = $1 AND t.is_hidden = false ORDER BY ult.created_at DESC"#, ) @@ -1543,9 +1186,16 @@ async fn search_handler( let t = sqlx::query_as::<_, SearchTrackRow>( r#"SELECT t.id, t.title::text AS title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, - rel.cover_file_id AS release_cover_file_id + rel.cover_file_id AS release_cover_file_id, + 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 FROM furumusic__track t JOIN furumusic__release rel ON rel.id = t.release_id + LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id WHERE t.is_hidden = false AND t.title_sort ILIKE '%' || $1 || '%' ORDER BY t.title_sort LIMIT $2"#, ) @@ -1605,22 +1255,32 @@ async fn search_handler( .fetch_all(pool); let t = sqlx::query_as::<_, SearchTrackRow>( - r#"SELECT id, title, track_number, disc_number, duration_seconds, cover_file_id, release_cover_file_id FROM ( + r#"SELECT id, title, track_number, disc_number, duration_seconds, cover_file_id, + release_cover_file_id, uploader_name, audio_format, audio_bitrate, + audio_sample_rate, audio_bit_depth, file_size_bytes FROM ( SELECT t.id, t.title::text AS title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, rel.cover_file_id AS release_cover_file_id, + 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, MAX(sim) AS similarity FROM ( - SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, + SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id, similarity(title_sort, $1) AS sim FROM furumusic__track WHERE is_hidden = false AND title_sort % $1 UNION ALL - SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, + SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id, 0.01::real AS sim FROM furumusic__track WHERE is_hidden = false AND title_sort ILIKE '%' || $1 || '%' ) t JOIN furumusic__release rel ON rel.id = t.release_id - GROUP BY t.id, t.title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, rel.cover_file_id + LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id + GROUP BY t.id, t.title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, rel.cover_file_id, + mf.uploader_name, mf.audio_format, mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, mf.file_size_bytes ORDER BY similarity DESC LIMIT $2 ) sub"#, @@ -1685,6 +1345,11 @@ async fn search_handler( }) .collect(); + let release_ids: Vec = release_rows.iter().map(|r| r.id).collect(); + let mut release_uploaders = load_release_uploaders(pool, &release_ids) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let releases: Vec = release_rows .into_iter() .map(|r| ReleaseCard { @@ -1694,6 +1359,7 @@ async fn search_handler( year: r.year, cover_url: cover_url(r.cover_file_id), track_count: r.track_count, + uploaders: release_uploaders.remove(&r.id).unwrap_or_default(), }) .collect(); @@ -1711,6 +1377,12 @@ async fn search_handler( featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id), stream_url: format!("/api/player/stream/{tid}"), + uploader_name: t.uploader_name, + audio_format: t.audio_format, + audio_bitrate: t.audio_bitrate, + audio_sample_rate: t.audio_sample_rate, + audio_bit_depth: t.audio_bit_depth, + file_size_bytes: t.file_size_bytes, } }) .collect(); @@ -2265,9 +1937,16 @@ async fn tracks_by_ids_handler( let tracks = sqlx::query_as::<_, TrackRow>( 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.cover_file_id as release_cover_file_id, + 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 FROM furumusic__track t JOIN furumusic__release r ON r.id = t.release_id + LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id WHERE t.id = ANY($1) AND t.is_hidden = false"#, ) .bind(&ids) @@ -2332,6 +2011,12 @@ async fn tracks_by_ids_handler( featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id), stream_url: format!("/api/player/stream/{tid}"), + uploader_name: t.uploader_name, + audio_format: t.audio_format, + audio_bitrate: t.audio_bitrate, + audio_sample_rate: t.audio_sample_rate, + audio_bit_depth: t.audio_bit_depth, + file_size_bytes: t.file_size_bytes, }, ); } @@ -2448,8 +2133,7 @@ impl App for PlayerApp { let torrent_service = Arc::clone(&torrent_service); let scheduler_handle = Arc::clone(&scheduler_handle); async move { - let Some(_user) = auth::get_session_user(&session, &db).await - else { + let Some(user) = auth::get_session_user(&session, &db).await else { return Ok(json_error( StatusCode::UNAUTHORIZED, "not authenticated", @@ -2466,6 +2150,7 @@ impl App for PlayerApp { &path.0.id, json.0.selected_files, live_config.agent_inbox_dir, + user.id, ) .await { diff --git a/src/player/queries.rs b/src/player/queries.rs new file mode 100644 index 0000000..07c4f4a --- /dev/null +++ b/src/player/queries.rs @@ -0,0 +1,72 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub(super) struct HistoryEntry { + pub(super) track_id: i64, + pub(super) duration_listened: Option, + pub(super) completed: bool, +} + +#[derive(Debug, Deserialize)] +pub(super) struct HistoryQuery { + pub(super) page: Option, + pub(super) limit: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct TracksByIdsRequest { + pub(super) ids: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct CreatePlaylistRequest { + pub(super) title: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct UpdatePlaylistRequest { + pub(super) title: Option, + pub(super) description: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct AddTracksRequest { + pub(super) track_ids: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct RemoveTrackRequest { + pub(super) track_id: i64, +} + +#[derive(Debug, Deserialize)] +pub(super) struct PaginationQuery { + pub(super) page: Option, + pub(super) limit: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct PathId { + pub(super) id: i64, +} + +#[derive(Debug, Deserialize)] +pub(super) struct PathStringId { + pub(super) id: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct SearchQuery { + pub(super) q: String, + pub(super) limit: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct PathTrackId { + pub(super) track_id: i64, +} + +#[derive(Debug, Deserialize)] +pub(super) struct PathMediaFileId { + pub(super) media_file_id: i64, +} diff --git a/src/player/rows.rs b/src/player/rows.rs new file mode 100644 index 0000000..feca5c2 --- /dev/null +++ b/src/player/rows.rs @@ -0,0 +1,185 @@ +#[derive(sqlx::FromRow)] +pub(super) struct ArtistRow { + pub(super) id: i64, + pub(super) name: String, + pub(super) image_file_id: Option, + pub(super) release_count: i64, + pub(super) track_count: i64, +} + +#[derive(sqlx::FromRow)] +pub(super) struct CountRow { + pub(super) count: i64, +} + +#[derive(sqlx::FromRow)] +pub(super) struct ReleaseRow { + pub(super) id: i64, + pub(super) title: String, + pub(super) release_type: String, + pub(super) year: Option, + pub(super) cover_file_id: Option, + pub(super) track_count: i64, +} + +#[derive(sqlx::FromRow)] +pub(super) struct ArtistBriefRow { + pub(super) id: i64, + pub(super) name: String, +} + +#[derive(sqlx::FromRow)] +pub(super) struct TrackRow { + 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) 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, +} + +#[derive(sqlx::FromRow)] +pub(super) struct TrackArtistRow { + pub(super) track_id: i64, + pub(super) artist_id: i64, + pub(super) artist_name: String, + pub(super) role: String, +} + +#[derive(sqlx::FromRow)] +pub(super) struct MediaFileRow { + pub(super) file_path: String, + pub(super) mime_type: String, + pub(super) file_size_bytes: i64, +} + +#[derive(sqlx::FromRow)] +pub(super) struct PlaybackStateRow { + pub(super) current_track_id: Option, + pub(super) position_ms: i32, + pub(super) queue_json: String, + pub(super) queue_position: i32, + pub(super) shuffle: bool, + pub(super) repeat_mode: String, + pub(super) volume: f64, +} + +#[derive(sqlx::FromRow)] +pub(super) struct PlaylistRow { + pub(super) id: i64, + pub(super) title: String, + pub(super) track_count: i64, + pub(super) is_own: bool, +} + +#[derive(sqlx::FromRow)] +pub(super) struct PlaylistInfoRow { + pub(super) id: i64, + pub(super) title: String, + pub(super) description: Option, + pub(super) owner_id: i64, +} + +#[derive(sqlx::FromRow)] +pub(super) struct PlaylistTrackRow { + 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) 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, +} + +#[derive(sqlx::FromRow)] +pub(super) struct AppearanceTrackRow { + pub(super) id: i64, + pub(super) title: String, + pub(super) release_id: i64, + pub(super) release_title: String, + pub(super) duration_seconds: f64, + pub(super) cover_file_id: Option, + pub(super) release_cover_file_id: Option, + 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, +} + +#[derive(sqlx::FromRow)] +pub(super) struct SearchArtistRow { + pub(super) id: i64, + pub(super) name: String, + pub(super) image_file_id: Option, + pub(super) release_count: i64, + pub(super) track_count: i64, +} + +#[derive(sqlx::FromRow)] +pub(super) struct SearchReleaseRow { + pub(super) id: i64, + pub(super) title: String, + pub(super) release_type: String, + pub(super) year: Option, + pub(super) cover_file_id: Option, + pub(super) track_count: i64, +} + +#[derive(sqlx::FromRow)] +pub(super) struct SearchTrackRow { + 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) 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, +} + +#[derive(sqlx::FromRow)] +pub(super) struct ReleaseUploaderRow { + pub(super) release_id: i64, + pub(super) uploader_name: String, + pub(super) track_count: i64, +} + +#[derive(sqlx::FromRow)] +pub(super) struct PlayHistoryRow { + pub(super) id: i64, + pub(super) track_id: i64, + pub(super) track_title: String, + pub(super) release_title: Option, + pub(super) played_at: String, + pub(super) duration_listened: Option, + pub(super) completed: bool, +} + +#[derive(sqlx::FromRow)] +pub(super) struct ReleaseInfoRow { + pub(super) id: i64, + pub(super) title: String, + pub(super) release_type: String, + pub(super) year: Option, + pub(super) cover_file_id: Option, +} diff --git a/src/torrents.rs b/src/torrents.rs index df7bed8..313ab8f 100644 --- a/src/torrents.rs +++ b/src/torrents.rs @@ -316,6 +316,7 @@ impl TorrentService { id: &str, selected_files: Vec, inbox_dir: String, + uploader_user_id: i64, ) -> anyhow::Result { if selected_files.is_empty() { bail!("select at least one file"); @@ -371,7 +372,10 @@ impl TorrentService { return; } service.stop_torrent(&handle).await; - if let Err(err) = service.finalize_completed(&id, &inbox_dir).await { + if let Err(err) = service + .finalize_completed(&id, &inbox_dir, uploader_user_id) + .await + { service.fail_job(&id, err.to_string()).await; } }); @@ -400,7 +404,12 @@ impl TorrentService { } } - async fn finalize_completed(&self, id: &str, inbox_dir: &Path) -> anyhow::Result<()> { + async fn finalize_completed( + &self, + id: &str, + inbox_dir: &Path, + uploader_user_id: i64, + ) -> anyhow::Result<()> { let (name, files, selected_files, output_dir) = { let mut jobs = self.jobs.lock().await; let job = jobs.get_mut(id).context("torrent job not found")?; @@ -414,7 +423,8 @@ impl TorrentService { }; let destination_root = inbox_dir - .join("torrents") + .join("user_uploads") + .join(uploader_user_id.to_string()) .join(sanitize_path_component(&name)); tokio::fs::create_dir_all(&destination_root).await?; diff --git a/templates/player.html b/templates/player.html index 06faffc..ad8bb4f 100644 --- a/templates/player.html +++ b/templates/player.html @@ -610,6 +610,47 @@ button.user-stat:hover { .track-action-btn.play-btn:hover { color: var(--accent); } .track-action-btn svg { width: 16px; height: 16px; } +.info-btn { + color: var(--text-subdued); +} + +.info-btn:hover { + color: var(--text-primary); +} + +.card-info-btn { + position: absolute; + top: 8px; + right: 8px; + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid var(--border-color); + background: rgba(18,18,18,0.78); + color: var(--text-primary); + cursor: help; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s, background 0.15s; + box-shadow: 0 2px 8px rgba(0,0,0,0.35); +} + +.card:hover .card-info-btn, +.search-release-card:hover .card-info-btn { + opacity: 1; +} + +.card-info-btn:hover { + background: var(--bg-hover); +} + +.card-info-btn svg { + width: 15px; + height: 15px; +} + /* Card enqueue button (next to play button on release cards) */ .card-enqueue-btn { position: absolute; @@ -1826,6 +1867,7 @@ button.user-stat:hover { .card-subtitle { font-size: 11px; } .card-play-btn, .card-enqueue-btn, + .card-info-btn, .artist-follow-card-btn, .track-actions, .playlist-item-actions, @@ -2461,6 +2503,9 @@ button.user-stat:hover { +
@@ -2501,6 +2546,9 @@ button.user-stat:hover {
+ @@ -2620,6 +2668,9 @@ button.user-stat:hover { + @@ -2669,6 +2720,9 @@ button.user-stat:hover {
+ @@ -2727,6 +2781,13 @@ button.user-stat:hover {
+
+ @@ -2833,6 +2897,9 @@ button.user-stat:hover {
+ @@ -2902,6 +2969,9 @@ button.user-stat:hover {
+ @@ -3827,6 +3897,58 @@ document.addEventListener('alpine:init', () => { return [...main, ...featured]; }, + bytes(value) { + if (!value) return 'unknown size'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let size = Number(value); + let idx = 0; + while (size >= 1024 && idx < units.length - 1) { + size /= 1024; + idx++; + } + return (idx === 0 ? size.toFixed(0) : size.toFixed(1)) + ' ' + units[idx]; + }, + + uploadersInfo(uploaders) { + const rows = uploaders || []; + if (!rows.length) return 'UFO'; + return rows + .map(row => `${row.name || 'UFO'} (${row.track_count} track${row.track_count === 1 ? '' : 's'})`) + .join(', '); + }, + + releaseInfo(release) { + if (!release) return ''; + const lines = [ + release.title || 'Unknown release', + `Type: ${release.release_type || 'unknown'}`, + `Year: ${release.year || 'unknown'}`, + `Tracks: ${release.track_count || release.tracks?.length || 0}`, + `Uploaders: ${this.uploadersInfo(release.uploaders || [])}`, + ]; + return lines.join('\n'); + }, + + trackInfo(track) { + if (!track) return ''; + const artists = this.trackArtistLinks(track).map(artist => artist.label).join(', ') || 'unknown'; + const audio = [ + track.audio_format || null, + track.audio_bitrate ? `${track.audio_bitrate} kbps` : null, + track.audio_sample_rate ? `${track.audio_sample_rate} Hz` : null, + track.audio_bit_depth ? `${track.audio_bit_depth}-bit` : null, + ].filter(Boolean).join(' ยท ') || 'unknown audio details'; + const lines = [ + track.title || 'Unknown track', + `Artists: ${artists}`, + `Duration: ${formatTime(track.duration_seconds)}`, + `Audio: ${audio}`, + `Size: ${this.bytes(track.file_size_bytes)}`, + `Uploader: ${track.uploader_name || 'UFO'}`, + ]; + return lines.join('\n'); + }, + async openRelease(id) { this.searchQuery = ''; this.searchResults = null;