diff --git a/Cargo.toml b/Cargo.toml index 08cb619..ea2eab7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.2.9" +version = "0.2.10" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/admin/mod.rs b/src/admin/mod.rs index efa5862..0ca6d32 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -282,6 +282,32 @@ impl App for AdminApp { }, "admin_v2_jobs", ), + Route::with_handler_and_name( + "/v2/api/jobs/metadata_backfill/run-options", + cot::router::method::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("admin pool") + }) + .await; + v2::run_metadata_backfill(session, db, pg_pool, json).await + } + } + }), + "admin_v2_metadata_backfill_run_options", + ), Route::with_handler_and_name( "/v2/api/jobs/{name}/run", cot::router::method::post({ diff --git a/src/admin/v2.rs b/src/admin/v2.rs index 0e7fb32..ab2ac03 100644 --- a/src/admin/v2.rs +++ b/src/admin/v2.rs @@ -17,7 +17,7 @@ use crate::agent; use crate::auth::{self, AuthenticatedUser, Role}; use crate::config::{AppConfig, ConfigEntry, ConfigSources}; use crate::i18n::{I18n, Translations}; -use crate::scheduler::{JobRegistry, ScheduledJob}; +use crate::scheduler::{self, JobRegistry, JobRun, ScheduledJob}; #[derive(Debug, Template)] #[template(path = "admin/v2.html")] @@ -61,6 +61,24 @@ pub(super) struct BulkLibraryRequest { filter: Option, } +#[derive(Debug, Deserialize)] +pub struct MetadataBackfillRunRequest { + #[serde(default = "default_true")] + audio_bitrate: bool, + #[serde(default = "default_true")] + audio_sample_rate: bool, + #[serde(default = "default_true")] + audio_bit_depth: bool, + #[serde(default = "default_true")] + duration_seconds: bool, + #[serde(default = "default_true")] + local_genres: bool, + #[serde(default = "default_true")] + lastfm_tags: bool, + #[serde(default)] + overwrite: bool, +} + #[derive(Debug, Deserialize)] pub(super) struct UpdateLibraryItemRequest { kind: String, @@ -161,6 +179,14 @@ struct TagDto { kind: String, } +#[derive(Debug, Serialize, JsonSchema)] +struct MetadataTagDto { + name: String, + source: String, + weight: f64, + updated_at: String, +} + #[derive(Debug, Serialize, JsonSchema)] struct ReviewPageDto { items: Vec, @@ -399,6 +425,7 @@ struct LibraryItemDetailDto { artists: Vec, releases: Vec, available_covers: Vec, + metadata_tags: Vec, } #[derive(Debug, Serialize, JsonSchema)] @@ -889,6 +916,68 @@ pub async fn run_job( } } +pub async fn run_metadata_backfill( + session: Session, + db: Database, + pool: &PgPool, + Json(body): Json, +) -> cot::Result { + if let Err(response) = require_admin_json(&session, &db).await { + return Ok(response); + } + + let options = crate::jobs::metadata_backfill::MetadataBackfillOptions { + audio_bitrate: body.audio_bitrate, + audio_sample_rate: body.audio_sample_rate, + audio_bit_depth: body.audio_bit_depth, + duration_seconds: body.duration_seconds, + local_genres: body.local_genres, + lastfm_tags: body.lastfm_tags, + overwrite: body.overwrite, + }; + if !options.any_field() { + return Ok(json_error( + StatusCode::BAD_REQUEST, + "select at least one metadata field", + )); + } + + let mut run = JobRun::create_running(&db, "metadata_backfill", "manual") + .await + .map_err(|e| cot::Error::internal(format!("failed to create job run: {e}")))?; + let run_id = run.id_val(); + let (live_config, _) = AppConfig::load_with_db(&db).await; + let db_for_task = db.clone(); + let pool_for_task = pool.clone(); + + tokio::spawn(async move { + let start = std::time::Instant::now(); + let ctx = scheduler::JobContext { + config: std::sync::Arc::new(live_config), + db: db_for_task.clone(), + pool: pool_for_task.clone(), + run_id, + registry: std::sync::Arc::new(JobRegistry::new()), + }; + let mut log = scheduler::JobLog::with_live_flush(pool_for_task.clone(), run_id); + let result = + crate::jobs::metadata_backfill::run_with_options(&ctx, &mut log, options).await; + let duration_ms = start.elapsed().as_millis() as i64; + match result { + Ok(()) => { + let _ = run.set_completed(&db_for_task, duration_ms, &log.output()).await; + } + Err(err) => { + let _ = run + .set_failed(&db_for_task, duration_ms, &log.output(), &err.to_string()) + .await; + } + } + }); + + Json(JobRunStartedDto { ok: true, run_id }).into_response() +} + pub async fn toggle_job( session: Session, db: Database, @@ -1974,6 +2063,7 @@ async fn load_library_item_detail( artists: Vec::new(), releases: Vec::new(), available_covers: Vec::new(), + metadata_tags: load_metadata_tags(pool, kind, item.id).await?, item, }; @@ -2044,6 +2134,97 @@ async fn load_library_item_detail( Ok(detail) } +async fn load_metadata_tags( + pool: &PgPool, + kind: &str, + id: i64, +) -> anyhow::Result> { + let entity_kind = match kind { + "artists" => "artist", + "releases" => "release", + "tracks" => "track", + _ => return Ok(Vec::new()), + }; + let rows = sqlx::query_as::<_, (String, String, f64, String)>( + r#"SELECT name, source, weight, updated_at + FROM ( + SELECT g.name::text AS name, + egt.source::text AS source, + egt.weight, + egt.updated_at::text AS updated_at + FROM furumusic__entity_genre_tag egt + JOIN furumusic__genre g ON g.id = egt.genre_id + WHERE egt.entity_kind = $1 AND egt.entity_id = $2 + UNION ALL + SELECT g.name::text AS name, + 'track_genre'::text AS source, + 1.0::double precision AS weight, + ''::text AS updated_at + FROM furumusic__track_genre tg + JOIN furumusic__genre g ON g.id = tg.genre_id + WHERE $1 = 'track' + AND tg.track_id = $2 + AND NOT EXISTS ( + SELECT 1 + FROM furumusic__entity_genre_tag egt + WHERE egt.entity_kind = 'track' + AND egt.entity_id = tg.track_id + AND egt.genre_id = tg.genre_id + ) + UNION ALL + SELECT g.name::text AS name, + ('release_' || egt.source)::text AS source, + egt.weight, + egt.updated_at::text AS updated_at + FROM furumusic__track t + JOIN furumusic__entity_genre_tag egt + ON egt.entity_kind = 'release' + AND egt.entity_id = t.release_id + JOIN furumusic__genre g ON g.id = egt.genre_id + WHERE $1 = 'track' + AND t.id = $2 + AND NOT EXISTS ( + SELECT 1 + FROM furumusic__entity_genre_tag direct_egt + WHERE direct_egt.entity_kind = 'track' + AND direct_egt.entity_id = t.id + AND direct_egt.genre_id = egt.genre_id + ) + AND NOT EXISTS ( + SELECT 1 + FROM furumusic__track_genre tg + WHERE tg.track_id = t.id + AND tg.genre_id = egt.genre_id + ) + ) tags + ORDER BY CASE source + WHEN 'lastfm' THEN 0 + WHEN 'release_lastfm' THEN 1 + WHEN 'review' THEN 2 + WHEN 'release_review' THEN 3 + WHEN 'file' THEN 4 + WHEN 'release_file' THEN 5 + WHEN 'track_genre' THEN 6 + ELSE 7 + END, + weight DESC, + name ASC"#, + ) + .bind(entity_kind) + .bind(id) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|(name, source, weight, updated_at)| MetadataTagDto { + name, + source, + weight, + updated_at, + }) + .collect()) +} + async fn load_artist_options(pool: &PgPool) -> anyhow::Result> { let rows = sqlx::query_as::<_, (i64, String)>( "SELECT id, name::text FROM furumusic__artist ORDER BY name ASC", @@ -2655,6 +2836,10 @@ fn tag(label: impl Into, kind: impl Into) -> TagDto { } } +fn default_true() -> bool { + true +} + fn optional_job_time(value: &str) -> Option { if value.is_empty() { None diff --git a/src/admin/views.rs b/src/admin/views.rs index 29c6622..82c1fc0 100644 --- a/src/admin/views.rs +++ b/src/admin/views.rs @@ -1402,6 +1402,8 @@ pub struct MetadataBackfillForm { audio_sample_rate: Option, audio_bit_depth: Option, duration_seconds: Option, + local_genres: Option, + lastfm_tags: Option, mode: Option, } @@ -1468,6 +1470,8 @@ pub async fn metadata_backfill_run( audio_sample_rate: data.audio_sample_rate.is_some(), audio_bit_depth: data.audio_bit_depth.is_some(), duration_seconds: data.duration_seconds.is_some(), + local_genres: data.local_genres.is_some(), + lastfm_tags: data.lastfm_tags.is_some(), overwrite: data.mode.as_deref() == Some("overwrite"), }; diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index f52a048..9799ebf 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -346,6 +346,11 @@ translations! { player_lastfm_connect_failed: "Could not open Last.fm connection" , "Не удалось открыть подключение Last.fm"; player_lastfm_disconnect_failed: "Could not disconnect Last.fm" , "Не удалось отвязать Last.fm"; player_play: "Play" , "Играть"; + player_listen: "Listen" , "Слушать"; + player_listen_artist: "Listen to artist" , "Слушать артиста"; + player_start_radio: "Start radio" , "Запустить радио"; + player_radio_failed: "Could not start radio" , "Не удалось запустить радио"; + player_played_at: "Played" , "Прослушано"; player_like: "Like" , "Лайк"; player_add_to_queue: "Add to queue" , "Добавить в очередь"; player_add_to_end_queue: "Add to end of queue" , "Добавить в конец очереди"; diff --git a/src/jobs/inbox_process.rs b/src/jobs/inbox_process.rs index 6dd0a47..c4445f3 100644 --- a/src/jobs/inbox_process.rs +++ b/src/jobs/inbox_process.rs @@ -878,6 +878,26 @@ pub async fn finalize_approved( .await; } + let approved_genre = normalized + .genre + .as_deref() + .or_else(|| context.get("raw_genre").and_then(|v| v.as_str())) + .map(str::trim) + .filter(|value| !value.is_empty()); + if let Some(genre) = approved_genre { + if let Err(err) = + crate::jobs::metadata_backfill::save_approved_track_genres(pool, track.id_val(), genre) + .await + { + tracing::warn!( + track_id = track.id_val(), + genre, + error = %err, + "failed to save approved track genre metadata" + ); + } + } + // Cover art: if the release has no cover yet, try to find one if release.cover_file_id.is_none() { let source_folder = Path::new(input_path_str).parent().unwrap_or(Path::new(".")); diff --git a/src/jobs/metadata_backfill.rs b/src/jobs/metadata_backfill.rs index 93c7cbe..79e8bdc 100644 --- a/src/jobs/metadata_backfill.rs +++ b/src/jobs/metadata_backfill.rs @@ -1,11 +1,20 @@ +use std::time::Duration; + +use serde_json::Value; + use crate::scheduler::{Job, JobContext, JobLog}; +const LASTFM_TAG_REQUEST_DELAY: Duration = Duration::from_millis(1200); +const LASTFM_TAG_LIMIT: usize = 12; + #[derive(Debug, Clone, Copy)] pub struct MetadataBackfillOptions { pub audio_bitrate: bool, pub audio_sample_rate: bool, pub audio_bit_depth: bool, pub duration_seconds: bool, + pub local_genres: bool, + pub lastfm_tags: bool, pub overwrite: bool, } @@ -15,6 +24,16 @@ impl MetadataBackfillOptions { || self.audio_sample_rate || self.audio_bit_depth || self.duration_seconds + || self.local_genres + || self.lastfm_tags + } + + fn needs_file_scan(self) -> bool { + self.audio_bitrate + || self.audio_sample_rate + || self.audio_bit_depth + || self.duration_seconds + || self.local_genres } } @@ -29,6 +48,42 @@ struct BackfillRow { duration_seconds: Option, } +#[derive(sqlx::FromRow)] +struct LastfmArtistTagRow { + id: i64, + name: String, +} + +#[derive(sqlx::FromRow)] +struct LastfmReleaseTagRow { + id: i64, + title: String, + artist_name: Option, +} + +#[derive(sqlx::FromRow)] +struct LastfmTrackTagRow { + id: i64, + title: String, + artist_name: Option, +} + +#[derive(Debug, Clone)] +struct TagCandidate { + name: String, + weight: f64, +} + +#[derive(Debug, Default)] +struct LastfmTagStats { + considered: u64, + updated_entities: u64, + tags_saved: u64, + skipped_existing: u64, + not_found: u64, + failed: u64, +} + pub struct MetadataBackfillJob; #[async_trait::async_trait] @@ -38,7 +93,7 @@ impl Job for MetadataBackfillJob { } fn description(&self) -> &'static str { - "Backfill technical audio metadata from existing files" + "Backfill technical audio metadata, local genres, and Last.fm tags" } fn default_cron(&self) -> &'static str { @@ -54,6 +109,8 @@ impl Job for MetadataBackfillJob { audio_sample_rate: true, audio_bit_depth: true, duration_seconds: true, + local_genres: true, + lastfm_tags: true, overwrite: false, }, ) @@ -71,21 +128,19 @@ pub async fn run_with_options( return Ok(()); } - let rows = sqlx::query_as::<_, BackfillRow>( - "SELECT mf.id AS media_file_id, mf.file_path, \ - mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, \ - t.id AS track_id, t.duration_seconds \ - FROM furumusic__media_file mf \ - LEFT JOIN furumusic__track t ON t.audio_file_id = mf.id \ - WHERE mf.file_type = 'audio' \ - ORDER BY mf.id", - ) - .fetch_all(&ctx.pool) - .await?; + let mut scanned = 0u64; + let mut media_updated = 0u64; + let mut track_updated = 0u64; + let mut local_tags_updated = 0u64; + let mut unchanged = 0u64; + let mut missing = 0u64; + let mut failed = 0u64; log.info(&format!( - "Metadata backfill started: {} audio file(s), mode={}", - rows.len(), + "Metadata backfill options: file_scan={}, local_genres={}, lastfm_tags={}, mode={}", + options.needs_file_scan(), + options.local_genres, + options.lastfm_tags, if options.overwrite { "overwrite" } else { @@ -93,126 +148,795 @@ pub async fn run_with_options( } )); - let mut scanned = 0u64; - let mut media_updated = 0u64; - let mut track_updated = 0u64; - let mut unchanged = 0u64; - let mut missing = 0u64; - let mut failed = 0u64; + if options.needs_file_scan() { + let rows = sqlx::query_as::<_, BackfillRow>( + "SELECT mf.id AS media_file_id, mf.file_path, \ + mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, \ + t.id AS track_id, t.duration_seconds \ + FROM furumusic__media_file mf \ + LEFT JOIN furumusic__track t ON t.audio_file_id = mf.id \ + WHERE mf.file_type = 'audio' \ + ORDER BY mf.id", + ) + .fetch_all(&ctx.pool) + .await?; - for row in rows { - scanned += 1; - let path = crate::media_paths::resolve_media_file_path( - &ctx.config.agent_storage_dir, - &row.file_path, - ); - if !path.exists() { - missing += 1; - log.warn(&format!("missing file: {}", row.file_path)); - continue; - } + log.info(&format!( + "Metadata file backfill started: {} audio file(s), mode={}", + rows.len(), + if options.overwrite { + "overwrite" + } else { + "fill_missing" + } + )); - let extract_path = path.clone(); - let raw_meta = match tokio::task::spawn_blocking(move || { - crate::agent::metadata::extract(&extract_path) - }) - .await - { - Ok(Ok(meta)) => meta, - Ok(Err(e)) => { - failed += 1; - log.warn(&format!("metadata error for {}: {e}", path.display())); + for row in rows { + scanned += 1; + let path = crate::media_paths::resolve_media_file_path( + &ctx.config.agent_storage_dir, + &row.file_path, + ); + if !path.exists() { + missing += 1; + log.warn(&format!("missing file: {}", row.file_path)); continue; } - Err(e) => { - failed += 1; - log.warn(&format!("metadata task failed for {}: {e}", path.display())); - continue; - } - }; - let mut changed_media = false; - let mut next_bitrate = row.audio_bitrate; - let mut next_sample_rate = row.audio_sample_rate; - let mut next_bit_depth = row.audio_bit_depth; + let extract_path = path.clone(); + let raw_meta = match tokio::task::spawn_blocking(move || { + crate::agent::metadata::extract(&extract_path) + }) + .await + { + Ok(Ok(meta)) => meta, + Ok(Err(e)) => { + failed += 1; + log.warn(&format!("metadata error for {}: {e}", path.display())); + continue; + } + Err(e) => { + failed += 1; + log.warn(&format!("metadata task failed for {}: {e}", path.display())); + continue; + } + }; - if options.audio_bitrate && should_update(row.audio_bitrate, options.overwrite) { - if let Some(value) = raw_meta.audio_bitrate { - next_bitrate = Some(value); - changed_media = next_bitrate != row.audio_bitrate || changed_media; - } - } - if options.audio_sample_rate && should_update(row.audio_sample_rate, options.overwrite) { - if let Some(value) = raw_meta.audio_sample_rate { - next_sample_rate = Some(value); - changed_media = next_sample_rate != row.audio_sample_rate || changed_media; - } - } - if options.audio_bit_depth && should_update(row.audio_bit_depth, options.overwrite) { - if let Some(value) = raw_meta.audio_bit_depth { - next_bit_depth = Some(value); - changed_media = next_bit_depth != row.audio_bit_depth || changed_media; - } - } + let mut changed_media = false; + let mut next_bitrate = row.audio_bitrate; + let mut next_sample_rate = row.audio_sample_rate; + let mut next_bit_depth = row.audio_bit_depth; - let mut changed_track = false; - let mut next_duration = row.duration_seconds; - if options.duration_seconds - && row.track_id.is_some() - && should_update_duration(row.duration_seconds, options.overwrite) - { - if let Some(value) = raw_meta.duration_secs { - next_duration = Some(value); - changed_track = row - .duration_seconds - .map(|current| (current - value).abs() > 0.001) - .unwrap_or(true); + if options.audio_bitrate && should_update(row.audio_bitrate, options.overwrite) { + if let Some(value) = raw_meta.audio_bitrate { + next_bitrate = Some(value); + changed_media = next_bitrate != row.audio_bitrate || changed_media; + } + } + if options.audio_sample_rate && should_update(row.audio_sample_rate, options.overwrite) + { + if let Some(value) = raw_meta.audio_sample_rate { + next_sample_rate = Some(value); + changed_media = next_sample_rate != row.audio_sample_rate || changed_media; + } + } + if options.audio_bit_depth && should_update(row.audio_bit_depth, options.overwrite) { + if let Some(value) = raw_meta.audio_bit_depth { + next_bit_depth = Some(value); + changed_media = next_bit_depth != row.audio_bit_depth || changed_media; + } } - } - if changed_media { - sqlx::query( - "UPDATE furumusic__media_file \ - SET audio_bitrate = $1, audio_sample_rate = $2, audio_bit_depth = $3 \ - WHERE id = $4", - ) - .bind(next_bitrate) - .bind(next_sample_rate) - .bind(next_bit_depth) - .bind(row.media_file_id) - .execute(&ctx.pool) - .await?; - media_updated += 1; - } + let mut changed_track = false; + let mut next_duration = row.duration_seconds; + if options.duration_seconds + && row.track_id.is_some() + && should_update_duration(row.duration_seconds, options.overwrite) + { + if let Some(value) = raw_meta.duration_secs { + next_duration = Some(value); + changed_track = row + .duration_seconds + .map(|current| (current - value).abs() > 0.001) + .unwrap_or(true); + } + } - if changed_track { - if let (Some(track_id), Some(duration)) = (row.track_id, next_duration) { - sqlx::query("UPDATE furumusic__track SET duration_seconds = $1 WHERE id = $2") - .bind(duration) - .bind(track_id) - .execute(&ctx.pool) + let mut changed_tags = false; + if options.local_genres { + if let (Some(track_id), Some(genre)) = (row.track_id, raw_meta.genre.as_deref()) { + let saved = save_track_tag_text( + &ctx.pool, + track_id, + genre, + "file", + options.overwrite, + ) .await?; - track_updated += 1; + if saved > 0 { + local_tags_updated += saved; + changed_tags = true; + } + } } - } - if !changed_media && !changed_track { - unchanged += 1; - } + if changed_media { + sqlx::query( + "UPDATE furumusic__media_file \ + SET audio_bitrate = $1, audio_sample_rate = $2, audio_bit_depth = $3 \ + WHERE id = $4", + ) + .bind(next_bitrate) + .bind(next_sample_rate) + .bind(next_bit_depth) + .bind(row.media_file_id) + .execute(&ctx.pool) + .await?; + media_updated += 1; + } - if scanned % 100 == 0 { - log.info(&format!( - "Progress: {scanned} scanned, {media_updated} media updated, {track_updated} tracks updated, {unchanged} unchanged, {missing} missing, {failed} failed" - )); + if changed_track { + if let (Some(track_id), Some(duration)) = (row.track_id, next_duration) { + sqlx::query("UPDATE furumusic__track SET duration_seconds = $1 WHERE id = $2") + .bind(duration) + .bind(track_id) + .execute(&ctx.pool) + .await?; + track_updated += 1; + } + } + + if !changed_media && !changed_track && !changed_tags { + unchanged += 1; + } + + if scanned % 100 == 0 { + log.info(&format!( + "Progress: {scanned} scanned, {media_updated} media updated, {track_updated} tracks updated, {local_tags_updated} local tags saved, {unchanged} unchanged, {missing} missing, {failed} failed" + )); + } } } + let lastfm_stats = if options.lastfm_tags { + log.info("Metadata file backfill finished; starting Last.fm tag backfill"); + backfill_lastfm_tags(ctx, log, options.overwrite).await? + } else { + log.info("Last.fm tag backfill disabled for this run"); + LastfmTagStats::default() + }; + log.info(&format!( - "Metadata backfill complete: {scanned} scanned, {media_updated} media updated, {track_updated} tracks updated, {unchanged} unchanged, {missing} missing, {failed} failed" + "Metadata backfill complete: {scanned} scanned, {media_updated} media updated, {track_updated} tracks updated, {local_tags_updated} local tags saved, {unchanged} unchanged, {missing} missing, {failed} failed; Last.fm tags: considered={}, updated_entities={}, tags_saved={}, skipped_existing={}, not_found={}, failed={}", + lastfm_stats.considered, + lastfm_stats.updated_entities, + lastfm_stats.tags_saved, + lastfm_stats.skipped_existing, + lastfm_stats.not_found, + lastfm_stats.failed, )); Ok(()) } +pub async fn save_approved_track_genres( + pool: &sqlx::PgPool, + track_id: i64, + genre_text: &str, +) -> anyhow::Result { + save_track_tag_text(pool, track_id, genre_text, "review", false).await +} + +async fn backfill_lastfm_tags( + ctx: &JobContext, + log: &mut JobLog, + overwrite: bool, +) -> anyhow::Result { + let api_key = ctx.config.lastfm_api_key.trim(); + if api_key.is_empty() { + log.warn("lastfm_api_key is not configured, skipping Last.fm tag backfill"); + return Ok(LastfmTagStats::default()); + } + + log.info("Last.fm tag backfill started"); + + let client = reqwest::Client::builder() + .user_agent("furumusic-metadata-backfill/0.1") + .timeout(Duration::from_secs(15)) + .build()?; + + let mut stats = LastfmTagStats::default(); + backfill_lastfm_artist_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?; + backfill_lastfm_release_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?; + backfill_lastfm_track_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?; + Ok(stats) +} + +async fn backfill_lastfm_artist_tags( + ctx: &JobContext, + log: &mut JobLog, + client: &reqwest::Client, + api_key: &str, + overwrite: bool, + stats: &mut LastfmTagStats, +) -> anyhow::Result<()> { + let rows = sqlx::query_as::<_, LastfmArtistTagRow>( + r#"SELECT DISTINCT a.id, a.name::text AS name + FROM furumusic__artist a + JOIN furumusic__track_artist ta ON ta.artist_id = a.id + JOIN furumusic__track t ON t.id = ta.track_id + WHERE a.is_hidden = false AND t.is_hidden = false + ORDER BY a.id"#, + ) + .fetch_all(&ctx.pool) + .await?; + + log.info(&format!( + "Last.fm artist tag pass: checking {} artist(s)", + rows.len() + )); + let total = rows.len(); + for (index, row) in rows.into_iter().enumerate() { + if should_skip_lastfm_entity(&ctx.pool, "artist", row.id, overwrite).await? { + stats.skipped_existing += 1; + if should_log_lastfm_progress(index + 1, total, 25) { + log.info(&format!( + "Last.fm artist tags progress: {}/{}", + index + 1, + total + )); + } + continue; + } + stats.considered += 1; + match fetch_lastfm_artist_tags(client, api_key, &row.name).await { + Ok(tags) if !tags.is_empty() => { + let saved = + replace_entity_tags(&ctx.pool, "artist", row.id, &tags, "lastfm", false) + .await?; + stats.tags_saved += saved; + stats.updated_entities += 1; + } + Ok(_) => { + stats.not_found += 1; + } + Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => { + return Err(err); + } + Err(err) => { + stats.failed += 1; + log.warn(&format!( + "Last.fm artist tags failed for artist {} \"{}\": {err}", + row.id, row.name + )); + } + } + if should_log_lastfm_progress(index + 1, total, 25) { + log.info(&format!( + "Last.fm artist tags progress: {}/{}", + index + 1, + total + )); + } + tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await; + } + Ok(()) +} + +async fn backfill_lastfm_release_tags( + ctx: &JobContext, + log: &mut JobLog, + client: &reqwest::Client, + api_key: &str, + overwrite: bool, + stats: &mut LastfmTagStats, +) -> anyhow::Result<()> { + let rows = sqlx::query_as::<_, LastfmReleaseTagRow>( + r#"SELECT r.id, + r.title::text AS title, + ( + SELECT a.name::text + FROM furumusic__release_artist ra + JOIN furumusic__artist a ON a.id = ra.artist_id + WHERE ra.release_id = r.id + ORDER BY ra.position + LIMIT 1 + ) AS artist_name + FROM furumusic__release r + WHERE r.is_hidden = false + ORDER BY r.id"#, + ) + .fetch_all(&ctx.pool) + .await?; + + log.info(&format!( + "Last.fm release tag pass: checking {} release(s)", + rows.len() + )); + let total = rows.len(); + for (index, row) in rows.into_iter().enumerate() { + if should_skip_lastfm_entity(&ctx.pool, "release", row.id, overwrite).await? { + stats.skipped_existing += 1; + if should_log_lastfm_progress(index + 1, total, 25) { + log.info(&format!( + "Last.fm release tags progress: {}/{}", + index + 1, + total + )); + } + continue; + } + let Some(artist) = row.artist_name.as_deref().filter(|value| !value.trim().is_empty()) + else { + stats.not_found += 1; + if should_log_lastfm_progress(index + 1, total, 25) { + log.info(&format!( + "Last.fm release tags progress: {}/{}", + index + 1, + total + )); + } + continue; + }; + stats.considered += 1; + match fetch_lastfm_album_tags(client, api_key, artist, &row.title).await { + Ok(tags) if !tags.is_empty() => { + let saved = + replace_entity_tags(&ctx.pool, "release", row.id, &tags, "lastfm", false) + .await?; + stats.tags_saved += saved; + stats.updated_entities += 1; + } + Ok(_) => { + stats.not_found += 1; + } + Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => { + return Err(err); + } + Err(err) => { + stats.failed += 1; + log.warn(&format!( + "Last.fm release tags failed for release {} \"{}\" / \"{}\": {err}", + row.id, artist, row.title + )); + } + } + if should_log_lastfm_progress(index + 1, total, 25) { + log.info(&format!( + "Last.fm release tags progress: {}/{}", + index + 1, + total + )); + } + tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await; + } + Ok(()) +} + +async fn backfill_lastfm_track_tags( + ctx: &JobContext, + log: &mut JobLog, + client: &reqwest::Client, + api_key: &str, + overwrite: bool, + stats: &mut LastfmTagStats, +) -> anyhow::Result<()> { + let rows = sqlx::query_as::<_, LastfmTrackTagRow>( + r#"SELECT t.id, + t.title::text AS title, + ( + SELECT a.name::text + FROM furumusic__track_artist ta + JOIN furumusic__artist a ON a.id = ta.artist_id + WHERE ta.track_id = t.id AND ta.role <> 'featuring' + ORDER BY ta.position + LIMIT 1 + ) AS artist_name + FROM furumusic__track t + WHERE t.is_hidden = false + ORDER BY t.id"#, + ) + .fetch_all(&ctx.pool) + .await?; + + log.info(&format!( + "Last.fm track tag pass: checking {} track(s)", + rows.len() + )); + let total = rows.len(); + for (index, row) in rows.into_iter().enumerate() { + if should_skip_lastfm_entity(&ctx.pool, "track", row.id, overwrite).await? { + stats.skipped_existing += 1; + if should_log_lastfm_progress(index + 1, total, 50) { + log.info(&format!( + "Last.fm track tags progress: {}/{}", + index + 1, + total + )); + } + continue; + } + let Some(artist) = row.artist_name.as_deref().filter(|value| !value.trim().is_empty()) + else { + stats.not_found += 1; + if should_log_lastfm_progress(index + 1, total, 50) { + log.info(&format!( + "Last.fm track tags progress: {}/{}", + index + 1, + total + )); + } + continue; + }; + stats.considered += 1; + match fetch_lastfm_track_tags(client, api_key, artist, &row.title).await { + Ok(tags) if !tags.is_empty() => { + let saved = + replace_entity_tags(&ctx.pool, "track", row.id, &tags, "lastfm", true).await?; + stats.tags_saved += saved; + stats.updated_entities += 1; + } + Ok(_) => { + stats.not_found += 1; + } + Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => { + return Err(err); + } + Err(err) => { + stats.failed += 1; + log.warn(&format!( + "Last.fm track tags failed for track {} \"{}\" / \"{}\": {err}", + row.id, artist, row.title + )); + } + } + if should_log_lastfm_progress(index + 1, total, 50) { + log.info(&format!( + "Last.fm track tags progress: {}/{}", + index + 1, + total + )); + } + tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await; + } + Ok(()) +} + +fn should_log_lastfm_progress(done: usize, total: usize, every: usize) -> bool { + total > 0 && (done == total || done % every == 0) +} + +async fn should_skip_lastfm_entity( + pool: &sqlx::PgPool, + entity_kind: &str, + entity_id: i64, + overwrite: bool, +) -> anyhow::Result { + if overwrite { + return Ok(false); + } + let exists: Option = sqlx::query_scalar( + r#"SELECT 1 + FROM furumusic__entity_genre_tag + WHERE entity_kind = $1 AND entity_id = $2 AND source = 'lastfm' + LIMIT 1"#, + ) + .bind(entity_kind) + .bind(entity_id) + .fetch_optional(pool) + .await?; + Ok(exists.is_some()) +} + +async fn fetch_lastfm_artist_tags( + client: &reqwest::Client, + api_key: &str, + artist: &str, +) -> anyhow::Result> { + fetch_lastfm_top_tags( + client, + &[ + ("method", "artist.getTopTags"), + ("api_key", api_key), + ("artist", artist), + ("autocorrect", "1"), + ("format", "json"), + ], + ) + .await +} + +async fn fetch_lastfm_album_tags( + client: &reqwest::Client, + api_key: &str, + artist: &str, + album: &str, +) -> anyhow::Result> { + fetch_lastfm_top_tags( + client, + &[ + ("method", "album.getTopTags"), + ("api_key", api_key), + ("artist", artist), + ("album", album), + ("autocorrect", "1"), + ("format", "json"), + ], + ) + .await +} + +async fn fetch_lastfm_track_tags( + client: &reqwest::Client, + api_key: &str, + artist: &str, + track: &str, +) -> anyhow::Result> { + fetch_lastfm_top_tags( + client, + &[ + ("method", "track.getTopTags"), + ("api_key", api_key), + ("artist", artist), + ("track", track), + ("autocorrect", "1"), + ("format", "json"), + ], + ) + .await +} + +async fn fetch_lastfm_top_tags( + client: &reqwest::Client, + query: &[(&str, &str)], +) -> anyhow::Result> { + let response = client + .get("https://ws.audioscrobbler.com/2.0/") + .query(query) + .send() + .await?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(Vec::new()); + } + let response = response.error_for_status()?; + let body: Value = response.json().await?; + if let Some(code) = body.get("error").and_then(|value| value.as_i64()) { + if code == 29 { + anyhow::bail!("Last.fm rate limit exceeded"); + } + if code == 6 || code == 7 { + return Ok(Vec::new()); + } + anyhow::bail!( + "Last.fm API error {code}: {}", + body.get("message") + .and_then(|value| value.as_str()) + .unwrap_or("unknown error") + ); + } + + let Some(tag_value) = body.get("toptags").and_then(|value| value.get("tag")) else { + return Ok(Vec::new()); + }; + let mut tags = match tag_value { + Value::Array(values) => values.iter().filter_map(tag_from_value).collect::>(), + Value::Object(_) => tag_from_value(tag_value).into_iter().collect::>(), + _ => Vec::new(), + }; + tags.sort_by(|a, b| b.weight.total_cmp(&a.weight).then_with(|| a.name.cmp(&b.name))); + tags.truncate(LASTFM_TAG_LIMIT); + Ok(tags) +} + +fn tag_from_value(value: &Value) -> Option { + let name = value.get("name")?.as_str()?.trim(); + let name = clean_tag_name(name)?; + let weight = value + .get("count") + .and_then(lastfm_count_to_f64) + .unwrap_or(1.0) + .max(1.0); + Some(TagCandidate { name, weight }) +} + +fn lastfm_count_to_f64(value: &Value) -> Option { + value + .as_f64() + .or_else(|| value.as_str().and_then(|text| text.parse::().ok())) +} + +async fn save_track_tag_text( + pool: &sqlx::PgPool, + track_id: i64, + tag_text: &str, + source: &str, + replace_source: bool, +) -> anyhow::Result { + let tags = tags_from_text(tag_text); + save_entity_tags(pool, "track", track_id, &tags, source, replace_source, true).await +} + +async fn replace_entity_tags( + pool: &sqlx::PgPool, + entity_kind: &str, + entity_id: i64, + tags: &[TagCandidate], + source: &str, + mirror_track_genre: bool, +) -> anyhow::Result { + save_entity_tags( + pool, + entity_kind, + entity_id, + tags, + source, + true, + mirror_track_genre, + ) + .await +} + +async fn save_entity_tags( + pool: &sqlx::PgPool, + entity_kind: &str, + entity_id: i64, + tags: &[TagCandidate], + source: &str, + replace_source: bool, + mirror_track_genre: bool, +) -> anyhow::Result { + if tags.is_empty() { + return Ok(0); + } + if replace_source { + sqlx::query( + r#"DELETE FROM furumusic__entity_genre_tag + WHERE entity_kind = $1 AND entity_id = $2 AND source = $3"#, + ) + .bind(entity_kind) + .bind(entity_id) + .bind(source) + .execute(pool) + .await?; + + if mirror_track_genre && entity_kind == "track" { + sqlx::query( + r#"DELETE FROM furumusic__track_genre tg + WHERE tg.track_id = $1 + AND NOT EXISTS ( + SELECT 1 + FROM furumusic__entity_genre_tag egt + WHERE egt.entity_kind = 'track' + AND egt.entity_id = tg.track_id + AND egt.genre_id = tg.genre_id + )"#, + ) + .bind(entity_id) + .execute(pool) + .await?; + } + } + + let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + let mut saved = 0u64; + for tag in tags { + let Some(genre_id) = ensure_genre(pool, &tag.name).await? else { + continue; + }; + let result = sqlx::query( + r#"INSERT INTO furumusic__entity_genre_tag + (entity_kind, entity_id, genre_id, source, weight, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (entity_kind, entity_id, genre_id, source) DO NOTHING"#, + ) + .bind(entity_kind) + .bind(entity_id) + .bind(genre_id) + .bind(source) + .bind(tag.weight) + .bind(&now) + .execute(pool) + .await?; + saved += result.rows_affected(); + + if mirror_track_genre && entity_kind == "track" { + let result = sqlx::query( + r#"INSERT INTO furumusic__track_genre (track_id, genre_id) + VALUES ($1, $2) + ON CONFLICT (track_id, genre_id) DO NOTHING"#, + ) + .bind(entity_id) + .bind(genre_id) + .execute(pool) + .await?; + saved += result.rows_affected(); + } + } + Ok(saved) +} + +async fn ensure_genre(pool: &sqlx::PgPool, name: &str) -> anyhow::Result> { + let Some(name) = clean_tag_name(name) else { + return Ok(None); + }; + let normalized = normalize_tag_name(&name); + if normalized.is_empty() || is_ignored_tag(&normalized) { + return Ok(None); + } + + let existing: Option = sqlx::query_scalar( + r#"SELECT id FROM furumusic__genre + WHERE name_normalized = $1 + ORDER BY id + LIMIT 1"#, + ) + .bind(&normalized) + .fetch_optional(pool) + .await?; + if existing.is_some() { + return Ok(existing); + } + + let id = sqlx::query_scalar::<_, i64>( + r#"INSERT INTO furumusic__genre (name, name_normalized) + VALUES ($1, $2) + ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name + RETURNING id"#, + ) + .bind(&name) + .bind(&normalized) + .fetch_one(pool) + .await?; + Ok(Some(id)) +} + +fn tags_from_text(value: &str) -> Vec { + let normalized_separators = value.replace(" / ", ";").replace('|', ";"); + let mut tags = Vec::new(); + for raw in normalized_separators.split([';', ',']) { + if let Some(name) = clean_tag_name(raw) { + if !is_ignored_tag(&normalize_tag_name(&name)) + && !tags + .iter() + .any(|tag: &TagCandidate| normalize_tag_name(&tag.name) == normalize_tag_name(&name)) + { + tags.push(TagCandidate { name, weight: 1.0 }); + } + } + } + tags +} + +fn clean_tag_name(value: &str) -> Option { + let cleaned = value.trim().trim_matches('"').trim_matches('\'').trim(); + if cleaned.is_empty() { + return None; + } + let cleaned = cleaned.chars().take(100).collect::(); + let cleaned = cleaned.split_whitespace().collect::>().join(" "); + if cleaned.is_empty() { + None + } else { + Some(cleaned) + } +} + +fn normalize_tag_name(value: &str) -> String { + value + .trim() + .to_lowercase() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn is_ignored_tag(normalized: &str) -> bool { + matches!( + normalized, + "" | "unknown" | "undefined" | "none" | "n/a" | "na" | "other" | "misc" | "various" + ) +} + fn should_update(current: Option, overwrite: bool) -> bool { overwrite || current.is_none() } diff --git a/src/music/mod.rs b/src/music/mod.rs index 9215fa1..3a87e3f 100644 --- a/src/music/mod.rs +++ b/src/music/mod.rs @@ -1815,6 +1815,56 @@ pub mod db_migrations { &[Operation::custom(create_artwork_lookup_state).build()]; } + // -- M0035: Weighted metadata tags for tracks, releases, and artists ---- + + #[cot::db::migrations::migration_op] + async fn create_entity_genre_tags( + ctx: migrations::MigrationContext<'_>, + ) -> cot::db::Result<()> { + ctx.db + .raw( + "CREATE TABLE IF NOT EXISTS furumusic__entity_genre_tag ( + id BIGSERIAL PRIMARY KEY, + entity_kind VARCHAR(32) NOT NULL, + entity_id BIGINT NOT NULL, + genre_id BIGINT NOT NULL, + source VARCHAR(32) NOT NULL, + weight DOUBLE PRECISION NOT NULL DEFAULT 1, + updated_at VARCHAR(32) NOT NULL, + UNIQUE(entity_kind, entity_id, genre_id, source) + )", + ) + .await?; + ctx.db + .raw( + "CREATE INDEX IF NOT EXISTS idx_entity_genre_tag_entity + ON furumusic__entity_genre_tag (entity_kind, entity_id, source)", + ) + .await?; + ctx.db + .raw( + "CREATE INDEX IF NOT EXISTS idx_entity_genre_tag_genre + ON furumusic__entity_genre_tag (genre_id, entity_kind)", + ) + .await?; + Ok(()) + } + + #[derive(Debug, Copy, Clone)] + pub struct M0035CreateEntityGenreTags; + + impl migrations::Migration for M0035CreateEntityGenreTags { + const APP_NAME: &'static str = "furumusic"; + const MIGRATION_NAME: &'static str = "m_0035_create_entity_genre_tags"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "furumusic", + "m_0034_create_artwork_lookup_state", + )]; + const OPERATIONS: &'static [Operation] = + &[Operation::custom(create_entity_genre_tags).build()]; + } + pub const MIGRATIONS: &[&SyncDynMigration] = &[ &M0006CreateMediaFile, &M0007CreateArtist, @@ -1840,5 +1890,6 @@ pub mod db_migrations { &M0032CreateLastfmTrackPopularity, &M0033CreateLastfmScrobbling, &M0034CreateArtworkLookupState, + &M0035CreateEntityGenreTags, ]; } diff --git a/src/player/dto.rs b/src/player/dto.rs index 92c543a..167c588 100644 --- a/src/player/dto.rs +++ b/src/player/dto.rs @@ -36,6 +36,7 @@ pub(super) struct ArtistDetail { pub(super) image_url: Option, pub(super) total_track_count: i64, pub(super) total_play_count: i64, + pub(super) top_tracks: Vec, pub(super) releases: Vec, pub(super) featured_tracks: Vec, } @@ -463,6 +464,7 @@ pub(super) struct PlayHistoryItem { pub(super) track_id: i64, pub(super) track_title: String, pub(super) release_title: Option, + pub(super) track: TrackItem, pub(super) played_at: String, pub(super) duration_listened: Option, pub(super) completed: bool, diff --git a/src/player/mod.rs b/src/player/mod.rs index 32d2bc0..6aa5740 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -56,6 +56,9 @@ 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; +const PLAYER_RADIO_TRACK_LIMIT: usize = 40; +const PLAYER_RADIO_CANDIDATE_LIMIT: i64 = 220; +const PLAYER_RADIO_RELEASE_SEED_TRACKS: i64 = 4; #[derive(Debug, Clone)] struct PlayerDevice { @@ -3023,12 +3026,56 @@ async fn artist_detail_handler( }) .collect(); + let top_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.id as release_id, + r.title::text as release_title, + r.year as release_year, + 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 + 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.is_hidden = false + AND r.is_hidden = false + AND EXISTS ( + SELECT 1 + FROM furumusic__track_artist ta + WHERE ta.track_id = t.id + AND ta.artist_id = $1 + AND ta.role <> 'featuring' + ) + ORDER BY COALESCE(t.lastfm_rating, 0) DESC, + COALESCE(t.lastfm_playcount, 0) DESC, + COALESCE(t.lastfm_listeners, 0) DESC, + r.year DESC NULLS LAST, + t.track_number NULLS LAST, + t.id + LIMIT 50"#, + ) + .bind(artist_id) + .fetch_all(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let top_tracks = build_track_items(top_tracks, pool).await?; + Json(ArtistDetail { id: artist.id, name: artist.name, image_url: cover_variant_url(image_file_id, "large"), total_track_count, total_play_count, + top_tracks, releases: release_cards, featured_tracks, }) @@ -3077,8 +3124,7 @@ async fn release_detail_handler( .await .map_err(|e| cot::Error::internal(e.to_string()))?; - // Tracks - let tracks = sqlx::query_as::<_, TrackRow>( + 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, @@ -3106,83 +3152,7 @@ async fn release_detail_handler( .await .map_err(|e| cot::Error::internal(e.to_string()))?; - let track_ids: Vec = tracks.iter().map(|t| t.id).collect(); - - // Track artists (batch) - 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()))? - }; - - // Group track artists - let mut track_main_artists: std::collections::HashMap> = - std::collections::HashMap::new(); - let mut track_feat_artists: std::collections::HashMap> = - std::collections::HashMap::new(); - - for ta in &track_artists { - let artist_ref = ArtistRef { - id: ta.artist_id, - name: ta.artist_name.clone(), - }; - if ta.role == "featuring" { - track_feat_artists - .entry(ta.track_id) - .or_default() - .push(artist_ref); - } else { - track_main_artists - .entry(ta.track_id) - .or_default() - .push(artist_ref); - } - } - - let track_items: Vec = tracks - .into_iter() - .map(|t| { - let tid = t.id; - TrackItem { - id: t.id, - title: t.title, - track_number: t.track_number, - disc_number: t.disc_number, - duration_seconds: t.duration_seconds, - artists: track_main_artists.remove(&tid).unwrap_or_default(), - featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), - release_id: t.release_id, - release_title: t.release_title, - release_year: t.release_year, - cover_url: track_cover_variant_url( - t.cover_file_id, - t.release_cover_file_id, - "medium", - ), - 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, - lastfm_listeners: t.lastfm_listeners, - lastfm_playcount: t.lastfm_playcount, - lastfm_rating: t.lastfm_rating, - lastfm_updated_at: t.lastfm_updated_at, - } - }) - .collect(); + let track_items = build_track_items(tracks, pool).await?; let uploaders = load_release_uploaders(pool, &[release.id]) .await .map_err(|e| cot::Error::internal(e.to_string()))? @@ -3451,6 +3421,50 @@ async fn build_track_items( .collect()) } +async fn load_track_items_by_ids( + pool: &sqlx::PgPool, + ids: &[i64], +) -> cot::Result> { + if ids.is_empty() { + return Ok(Vec::new()); + } + + 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.id as release_id, + r.title::text as release_title, + r.year as release_year, + 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 + 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 AND r.is_hidden = false"#, + ) + .bind(ids) + .fetch_all(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + let mut track_map: HashMap = build_track_items(tracks, pool) + .await? + .into_iter() + .map(|track| (track.id, track)) + .collect(); + + Ok(ids.iter().filter_map(|id| track_map.remove(id)).collect()) +} + /// Return the virtual "Likes" playlist for a given user. async fn likes_playlist_handler( user_id: i64, @@ -4321,17 +4335,35 @@ async fn history_list_handler( .await .map_err(|e| cot::Error::internal(e.to_string()))?; - let rows = sqlx::query_as::<_, PlayHistoryRow>( - r#"SELECT ph.id, - ph.track_id, - t.title::text AS track_title, - r.title::text AS release_title, + let rows = sqlx::query_as::<_, PlayHistoryTrackRow>( + r#"SELECT ph.id AS history_id, ph.played_at::text AS played_at, ph.duration_listened, - ph.completed + ph.completed, + 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, + t.release_id, + COALESCE(r.title::text, '') as release_title, + r.year as release_year, + 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 FROM furumusic__play_history ph JOIN furumusic__track t ON t.id = ph.track_id LEFT JOIN furumusic__release r ON r.id = t.release_id + LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id WHERE ph.user_id = $1 ORDER BY ph.played_at DESC, ph.id DESC LIMIT $2 OFFSET $3"#, @@ -4343,14 +4375,86 @@ async fn history_list_handler( .await .map_err(|e| cot::Error::internal(e.to_string()))?; + let track_ids: Vec = rows.iter().map(|t| t.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 track_main_artists: HashMap> = HashMap::new(); + let mut track_feat_artists: HashMap> = HashMap::new(); + for ta in &track_artists { + let artist_ref = ArtistRef { + id: ta.artist_id, + name: ta.artist_name.clone(), + }; + if ta.role == "featuring" { + track_feat_artists + .entry(ta.track_id) + .or_default() + .push(artist_ref); + } else { + track_main_artists + .entry(ta.track_id) + .or_default() + .push(artist_ref); + } + } + Json(PlayHistoryPage { items: rows .into_iter() .map(|row| PlayHistoryItem { - id: row.id, - track_id: row.track_id, - track_title: row.track_title, - release_title: row.release_title, + id: row.history_id, + track_id: row.id, + track_title: row.title.clone(), + release_title: if row.release_title.trim().is_empty() { + None + } else { + Some(row.release_title.clone()) + }, + track: { + let tid = row.id; + TrackItem { + id: row.id, + title: row.title, + track_number: row.track_number, + disc_number: row.disc_number, + duration_seconds: row.duration_seconds, + artists: track_main_artists.remove(&tid).unwrap_or_default(), + featured_artists: track_feat_artists.remove(&tid).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/{tid}"), + 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, + } + }, played_at: row.played_at, duration_listened: row.duration_listened, completed: row.completed, @@ -5248,6 +5352,471 @@ async fn toggle_follow_artist_handler( } } +// --------------------------------------------------------------------------- +// GET /api/player/radio/{kind}/{id} +// --------------------------------------------------------------------------- + +fn append_unique_track_ids(track_ids: &mut Vec, candidates: Vec, limit: usize) { + let mut seen: HashSet = track_ids.iter().copied().collect(); + for candidate in candidates { + if track_ids.len() >= limit { + break; + } + if seen.insert(candidate) { + track_ids.push(candidate); + } + } +} + +async fn track_primary_artist_ids(pool: &sqlx::PgPool, track_id: i64) -> cot::Result> { + sqlx::query_scalar::<_, i64>( + r#"SELECT artist_id + FROM furumusic__track_artist + WHERE track_id = $1 AND role <> 'featuring' + ORDER BY position, artist_id"#, + ) + .bind(track_id) + .fetch_all(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string())) +} + +async fn release_primary_artist_ids(pool: &sqlx::PgPool, release_id: i64) -> cot::Result> { + sqlx::query_scalar::<_, i64>( + r#"SELECT artist_id + FROM furumusic__release_artist + WHERE release_id = $1 + ORDER BY position, artist_id"#, + ) + .bind(release_id) + .fetch_all(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string())) +} + +async fn fallback_radio_track_ids( + pool: &sqlx::PgPool, + user_id: i64, + artist_ids: &[i64], + excluded_ids: &[i64], + limit: i64, +) -> cot::Result> { + if limit <= 0 { + return Ok(Vec::new()); + } + + sqlx::query_scalar::<_, i64>( + r#"SELECT t.id + FROM furumusic__track t + JOIN furumusic__release r ON r.id = t.release_id + WHERE t.is_hidden = false + AND r.is_hidden = false + AND NOT (t.id = ANY($3::bigint[])) + ORDER BY ( + CASE WHEN EXISTS ( + SELECT 1 FROM furumusic__user_liked_track ult + WHERE ult.user_id = $1 AND ult.track_id = t.id + ) THEN 9.0 ELSE 0.0 END + + CASE WHEN EXISTS ( + SELECT 1 FROM furumusic__track_artist ta + WHERE ta.track_id = t.id + AND ta.role <> 'featuring' + AND ta.artist_id = ANY($2::bigint[]) + ) THEN 5.0 ELSE 0.0 END + + COALESCE(t.lastfm_rating, 0.0) * 0.7 + + ln(COALESCE(t.lastfm_playcount, 0)::double precision + 1.0) * 0.04 + + ln(COALESCE(t.lastfm_listeners, 0)::double precision + 1.0) * 0.03 + + random() * 2.0 + ) DESC, t.id + LIMIT $4"#, + ) + .bind(user_id) + .bind(artist_ids) + .bind(excluded_ids) + .bind(limit) + .fetch_all(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string())) +} + +async fn track_radio_candidate_ids( + pool: &sqlx::PgPool, + user_id: i64, + track_id: i64, + limit: i64, +) -> cot::Result> { + sqlx::query_scalar::<_, i64>( + r#"WITH seed_track AS ( + SELECT t.id, t.release_id + FROM furumusic__track t + JOIN furumusic__release r ON r.id = t.release_id + WHERE t.id = $1 AND t.is_hidden = false AND r.is_hidden = false + ), + seed_artists AS ( + SELECT ta.artist_id + FROM furumusic__track_artist ta + JOIN seed_track st ON st.id = ta.track_id + WHERE ta.role <> 'featuring' + ), + seed_tag_sources AS ( + SELECT egt.genre_id, + ln(greatest(COALESCE(egt.weight, 1.0), 1.0) + 1.0) AS weight + FROM furumusic__entity_genre_tag egt + JOIN seed_track st ON egt.entity_kind = 'track' AND egt.entity_id = st.id + UNION ALL + SELECT tg.genre_id, 1.0 AS weight + FROM furumusic__track_genre tg + JOIN seed_track st ON st.id = tg.track_id + UNION ALL + SELECT egt.genre_id, + ln(greatest(COALESCE(egt.weight, 1.0), 1.0) + 1.0) AS weight + FROM furumusic__entity_genre_tag egt + JOIN seed_track st ON egt.entity_kind = 'release' AND egt.entity_id = st.release_id + ), + seed_tags AS ( + SELECT genre_id, max(weight) AS seed_weight + FROM seed_tag_sources + GROUP BY genre_id + ), + candidate_tag_sources AS ( + SELECT egt.entity_id AS track_id, + egt.genre_id, + ln(greatest(COALESCE(egt.weight, 1.0), 1.0) + 1.0) AS weight + FROM furumusic__entity_genre_tag egt + WHERE egt.entity_kind = 'track' + UNION ALL + SELECT tg.track_id, tg.genre_id, 1.0 AS weight + FROM furumusic__track_genre tg + UNION ALL + SELECT t.id AS track_id, + egt.genre_id, + ln(greatest(COALESCE(egt.weight, 1.0), 1.0) + 1.0) AS weight + FROM furumusic__track t + JOIN furumusic__entity_genre_tag egt + ON egt.entity_kind = 'release' AND egt.entity_id = t.release_id + ), + candidate_tags AS ( + SELECT track_id, genre_id, max(weight) AS weight + FROM candidate_tag_sources + GROUP BY track_id, genre_id + ), + tag_scores AS ( + SELECT ct.track_id, sum(st.seed_weight * ct.weight) AS tag_score + FROM candidate_tags ct + JOIN seed_tags st ON st.genre_id = ct.genre_id + GROUP BY ct.track_id + ) + SELECT t.id + FROM furumusic__track t + JOIN furumusic__release r ON r.id = t.release_id + LEFT JOIN tag_scores score ON score.track_id = t.id + WHERE t.is_hidden = false + AND r.is_hidden = false + AND t.id <> $1 + AND ( + COALESCE(score.tag_score, 0.0) > 0.0 + OR EXISTS ( + SELECT 1 + FROM furumusic__track_artist ta + JOIN seed_artists sa ON sa.artist_id = ta.artist_id + WHERE ta.track_id = t.id AND ta.role <> 'featuring' + ) + OR EXISTS ( + SELECT 1 FROM furumusic__user_liked_track ult + WHERE ult.user_id = $2 AND ult.track_id = t.id + ) + ) + ORDER BY ( + COALESCE(score.tag_score, 0.0) * 12.0 + + CASE + WHEN EXISTS ( + SELECT 1 FROM furumusic__user_liked_track ult + WHERE ult.user_id = $2 AND ult.track_id = t.id + ) AND COALESCE(score.tag_score, 0.0) > 0.0 THEN 12.0 + WHEN EXISTS ( + SELECT 1 FROM furumusic__user_liked_track ult + WHERE ult.user_id = $2 AND ult.track_id = t.id + ) THEN 3.0 + ELSE 0.0 + END + + CASE WHEN EXISTS ( + SELECT 1 + FROM furumusic__track_artist ta + JOIN seed_artists sa ON sa.artist_id = ta.artist_id + WHERE ta.track_id = t.id AND ta.role <> 'featuring' + ) THEN 4.0 ELSE 0.0 END + + COALESCE(t.lastfm_rating, 0.0) * 0.65 + + ln(COALESCE(t.lastfm_playcount, 0)::double precision + 1.0) * 0.04 + + ln(COALESCE(t.lastfm_listeners, 0)::double precision + 1.0) * 0.03 + + random() * 1.6 + ) DESC, t.id + LIMIT $3"#, + ) + .bind(track_id) + .bind(user_id) + .bind(limit) + .fetch_all(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string())) +} + +async fn release_radio_candidate_ids( + pool: &sqlx::PgPool, + user_id: i64, + release_id: i64, + excluded_ids: &[i64], + limit: i64, +) -> cot::Result> { + sqlx::query_scalar::<_, i64>( + r#"WITH seed_release AS ( + SELECT id + FROM furumusic__release + WHERE id = $1 AND is_hidden = false + ), + seed_artists AS ( + SELECT ra.artist_id + FROM furumusic__release_artist ra + JOIN seed_release sr ON sr.id = ra.release_id + ), + seed_tag_sources AS ( + SELECT egt.genre_id, + ln(greatest(COALESCE(egt.weight, 1.0), 1.0) + 1.0) AS weight + FROM furumusic__entity_genre_tag egt + JOIN seed_release sr ON egt.entity_kind = 'release' AND egt.entity_id = sr.id + UNION ALL + SELECT egt.genre_id, + ln(greatest(COALESCE(egt.weight, 1.0), 1.0) + 1.0) AS weight + FROM furumusic__track t + JOIN seed_release sr ON sr.id = t.release_id + JOIN furumusic__entity_genre_tag egt + ON egt.entity_kind = 'track' AND egt.entity_id = t.id + UNION ALL + SELECT tg.genre_id, 1.0 AS weight + FROM furumusic__track t + JOIN seed_release sr ON sr.id = t.release_id + JOIN furumusic__track_genre tg ON tg.track_id = t.id + ), + seed_tags AS ( + SELECT genre_id, max(weight) AS seed_weight + FROM seed_tag_sources + GROUP BY genre_id + ), + candidate_release_tag_sources AS ( + SELECT egt.entity_id AS release_id, + egt.genre_id, + ln(greatest(COALESCE(egt.weight, 1.0), 1.0) + 1.0) AS weight + FROM furumusic__entity_genre_tag egt + WHERE egt.entity_kind = 'release' + UNION ALL + SELECT t.release_id, + egt.genre_id, + ln(greatest(COALESCE(egt.weight, 1.0), 1.0) + 1.0) AS weight + FROM furumusic__track t + JOIN furumusic__entity_genre_tag egt + ON egt.entity_kind = 'track' AND egt.entity_id = t.id + UNION ALL + SELECT t.release_id, tg.genre_id, 1.0 AS weight + FROM furumusic__track t + JOIN furumusic__track_genre tg ON tg.track_id = t.id + ), + candidate_release_tags AS ( + SELECT release_id, genre_id, max(weight) AS weight + FROM candidate_release_tag_sources + GROUP BY release_id, genre_id + ), + release_scores AS ( + SELECT crt.release_id, sum(st.seed_weight * crt.weight) AS tag_score + FROM candidate_release_tags crt + JOIN seed_tags st ON st.genre_id = crt.genre_id + GROUP BY crt.release_id + ), + candidate_tracks AS ( + SELECT t.id, + t.release_id, + COALESCE(score.tag_score, 0.0) AS tag_score, + EXISTS ( + SELECT 1 FROM furumusic__user_liked_track ult + WHERE ult.user_id = $2 AND ult.track_id = t.id + ) AS liked, + EXISTS ( + SELECT 1 + FROM furumusic__release_artist ra + JOIN seed_artists sa ON sa.artist_id = ra.artist_id + WHERE ra.release_id = t.release_id + ) AS same_artist, + COALESCE(t.lastfm_rating, 0.0) AS rating, + COALESCE(t.lastfm_playcount, 0)::double precision AS playcount, + COALESCE(t.lastfm_listeners, 0)::double precision AS listeners + FROM furumusic__track t + JOIN furumusic__release r ON r.id = t.release_id + LEFT JOIN release_scores score ON score.release_id = t.release_id + WHERE t.is_hidden = false + AND r.is_hidden = false + AND t.release_id <> $1 + AND NOT (t.id = ANY($3::bigint[])) + AND ( + COALESCE(score.tag_score, 0.0) > 0.0 + OR EXISTS ( + SELECT 1 + FROM furumusic__release_artist ra + JOIN seed_artists sa ON sa.artist_id = ra.artist_id + WHERE ra.release_id = t.release_id + ) + OR EXISTS ( + SELECT 1 FROM furumusic__user_liked_track ult + WHERE ult.user_id = $2 AND ult.track_id = t.id + ) + ) + ), + ranked_tracks AS ( + SELECT *, + row_number() OVER ( + PARTITION BY release_id + ORDER BY rating DESC, playcount DESC, listeners DESC, random() DESC, id + ) AS release_rank + FROM candidate_tracks + ) + SELECT id + FROM ranked_tracks + WHERE release_rank <= 4 + ORDER BY ( + tag_score * 12.0 + + CASE + WHEN liked AND tag_score > 0.0 THEN 11.0 + WHEN liked THEN 3.0 + ELSE 0.0 + END + + CASE WHEN same_artist THEN 3.5 ELSE 0.0 END + + rating * 0.65 + + ln(playcount + 1.0) * 0.04 + + ln(listeners + 1.0) * 0.03 + + random() * 1.6 + ) DESC, id + LIMIT $4"#, + ) + .bind(release_id) + .bind(user_id) + .bind(excluded_ids) + .bind(limit) + .fetch_all(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string())) +} + +async fn build_track_radio_ids( + pool: &sqlx::PgPool, + user_id: i64, + track_id: i64, +) -> cot::Result>> { + let seed_track = sqlx::query_scalar::<_, i64>( + r#"SELECT t.id + FROM furumusic__track t + JOIN furumusic__release r ON r.id = t.release_id + WHERE t.id = $1 AND t.is_hidden = false AND r.is_hidden = false"#, + ) + .bind(track_id) + .fetch_optional(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + if seed_track.is_none() { + return Ok(None); + } + + let mut ids = vec![track_id]; + let candidate_ids = + track_radio_candidate_ids(pool, user_id, track_id, PLAYER_RADIO_CANDIDATE_LIMIT).await?; + append_unique_track_ids(&mut ids, candidate_ids, PLAYER_RADIO_TRACK_LIMIT); + + let artist_ids = track_primary_artist_ids(pool, track_id).await?; + let remaining = PLAYER_RADIO_TRACK_LIMIT.saturating_sub(ids.len()) as i64; + let fallback_ids = fallback_radio_track_ids(pool, user_id, &artist_ids, &ids, remaining).await?; + append_unique_track_ids(&mut ids, fallback_ids, PLAYER_RADIO_TRACK_LIMIT); + + Ok(Some(ids)) +} + +async fn build_release_radio_ids( + pool: &sqlx::PgPool, + user_id: i64, + release_id: i64, +) -> cot::Result>> { + let seed_release = sqlx::query_scalar::<_, i64>( + r#"SELECT id FROM furumusic__release WHERE id = $1 AND is_hidden = false"#, + ) + .bind(release_id) + .fetch_optional(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + if seed_release.is_none() { + return Ok(None); + } + + let mut ids = sqlx::query_scalar::<_, i64>( + r#"SELECT t.id + FROM furumusic__track t + JOIN furumusic__release r ON r.id = t.release_id + WHERE t.release_id = $1 + AND t.is_hidden = false + AND r.is_hidden = false + ORDER BY COALESCE(t.lastfm_rating, 0.0) DESC, + COALESCE(t.lastfm_playcount, 0) DESC, + COALESCE(t.lastfm_listeners, 0) DESC, + t.disc_number NULLS FIRST, + t.track_number NULLS LAST, + t.id + LIMIT $2"#, + ) + .bind(release_id) + .bind(PLAYER_RADIO_RELEASE_SEED_TRACKS) + .fetch_all(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + let candidate_ids = release_radio_candidate_ids( + pool, + user_id, + release_id, + &ids, + PLAYER_RADIO_CANDIDATE_LIMIT, + ) + .await?; + append_unique_track_ids(&mut ids, candidate_ids, PLAYER_RADIO_TRACK_LIMIT); + + let artist_ids = release_primary_artist_ids(pool, release_id).await?; + let remaining = PLAYER_RADIO_TRACK_LIMIT.saturating_sub(ids.len()) as i64; + let fallback_ids = fallback_radio_track_ids(pool, user_id, &artist_ids, &ids, remaining).await?; + append_unique_track_ids(&mut ids, fallback_ids, PLAYER_RADIO_TRACK_LIMIT); + + Ok(Some(ids)) +} + +async fn radio_handler( + session: Session, + db: Database, + pool: &sqlx::PgPool, + path: Path, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + + let seed = path.0; + let ids = match seed.kind.as_str() { + "track" => build_track_radio_ids(pool, user.id, seed.id).await?, + "release" => build_release_radio_ids(pool, user.id, seed.id).await?, + _ => return Ok(json_error(StatusCode::BAD_REQUEST, "unknown radio seed")), + }; + + let Some(ids) = ids else { + return Ok(json_error(StatusCode::NOT_FOUND, "radio seed not found")); + }; + + let tracks = load_track_items_by_ids(pool, &ids).await?; + Json(tracks).into_response() +} + // --------------------------------------------------------------------------- // POST /api/player/tracks-by-ids // --------------------------------------------------------------------------- @@ -5266,117 +5835,8 @@ async fn tracks_by_ids_handler( return Json(Vec::::new()).into_response(); } - // Limit to 500 IDs to prevent abuse let ids: Vec = body.ids.into_iter().take(500).collect(); - - 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.id as release_id, - r.title::text as release_title, - r.year as release_year, - 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 - 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) - .fetch_all(pool) - .await - .map_err(|e| cot::Error::internal(e.to_string()))?; - - let track_ids: Vec = tracks.iter().map(|t| t.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 track_main_artists: std::collections::HashMap> = - std::collections::HashMap::new(); - let mut track_feat_artists: std::collections::HashMap> = - std::collections::HashMap::new(); - - for ta in &track_artists { - let artist_ref = ArtistRef { - id: ta.artist_id, - name: ta.artist_name.clone(), - }; - if ta.role == "featuring" { - track_feat_artists - .entry(ta.track_id) - .or_default() - .push(artist_ref); - } else { - track_main_artists - .entry(ta.track_id) - .or_default() - .push(artist_ref); - } - } - - // Build a map from id -> TrackItem - let mut track_map: std::collections::HashMap = std::collections::HashMap::new(); - for t in tracks { - let tid = t.id; - track_map.insert( - tid, - TrackItem { - id: t.id, - title: t.title, - track_number: t.track_number, - disc_number: t.disc_number, - duration_seconds: t.duration_seconds, - artists: track_main_artists.remove(&tid).unwrap_or_default(), - featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), - release_id: t.release_id, - release_title: t.release_title, - release_year: t.release_year, - cover_url: track_cover_variant_url( - t.cover_file_id, - t.release_cover_file_id, - "medium", - ), - 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, - lastfm_listeners: t.lastfm_listeners, - lastfm_playcount: t.lastfm_playcount, - lastfm_rating: t.lastfm_rating, - lastfm_updated_at: t.lastfm_updated_at, - }, - ); - } - - // Reorder results to match input order - let result: Vec = ids.iter().filter_map(|id| track_map.remove(id)).collect(); - + let result = load_track_items_by_ids(pool, &ids).await?; Json(result).into_response() } @@ -6224,6 +6684,30 @@ impl App for PlayerApp { }, "player_release_detail", ), + Route::with_handler_and_name( + "/radio/{kind}/{id}", + { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + get(move |session: Session, db: Database, path: Path| { + 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; + radio_handler(session, db, pg_pool, path).await + } + }) + }, + "player_radio", + ), // -- Playlists (list + create) -- Route::with_handler_and_name( "/playlists", diff --git a/src/player/queries.rs b/src/player/queries.rs index a3990e6..970142b 100644 --- a/src/player/queries.rs +++ b/src/player/queries.rs @@ -61,6 +61,12 @@ pub(super) struct PathStringId { pub(super) id: String, } +#[derive(Debug, Deserialize)] +pub(super) struct PathRadioSeed { + pub(super) kind: String, + pub(super) id: i64, +} + #[derive(Debug, Deserialize)] pub(super) struct SearchQuery { pub(super) q: String, diff --git a/src/player/rows.rs b/src/player/rows.rs index 3406fe2..9fab092 100644 --- a/src/player/rows.rs +++ b/src/player/rows.rs @@ -36,30 +36,6 @@ pub(super) struct ArtistBriefRow { 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) release_id: i64, - pub(super) release_title: String, - pub(super) release_year: 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, - pub(super) lastfm_listeners: Option, - pub(super) lastfm_playcount: Option, - pub(super) lastfm_rating: Option, - pub(super) lastfm_updated_at: Option, -} - #[derive(sqlx::FromRow)] pub(super) struct TrackArtistRow { pub(super) track_id: i64, @@ -276,14 +252,31 @@ pub(super) struct ReleaseUploaderRow { } #[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) struct PlayHistoryTrackRow { + pub(super) history_id: i64, pub(super) played_at: String, pub(super) duration_listened: Option, pub(super) completed: bool, + 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_year: 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, + pub(super) lastfm_listeners: Option, + pub(super) lastfm_playcount: Option, + pub(super) lastfm_rating: Option, + pub(super) lastfm_updated_at: Option, } #[derive(sqlx::FromRow)] diff --git a/templates/admin/job_detail.html b/templates/admin/job_detail.html index 5dee35f..69d87ff 100644 --- a/templates/admin/job_detail.html +++ b/templates/admin/job_detail.html @@ -46,6 +46,12 @@ + +