From 4b8797bb2e7070e56abfdba40af7c7c7cbdaa9c4 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Tue, 26 May 2026 18:16:34 +0300 Subject: [PATCH] Added lastfm statistics --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/admin/mod.rs | 14 ++ src/admin/v2.rs | 45 +++++- src/config.rs | 7 + src/i18n/phrases.rs | 5 + src/jobs/lastfm_popularity.rs | 249 ++++++++++++++++++++++++++++++++++ src/jobs/mod.rs | 1 + src/main.rs | 1 + src/music/mod.rs | 56 ++++++++ src/oidc.rs | 6 +- src/player/dto.rs | 8 ++ src/player/mod.rs | 204 ++++++++++++++++++---------- src/player/rows.rs | 16 +++ src/scheduler/mod.rs | 2 +- src/torrents.rs | 22 +-- templates/admin/v2.html | 96 +++++++++++++ templates/player/scripts.html | 14 ++ 18 files changed, 657 insertions(+), 93 deletions(-) create mode 100644 src/jobs/lastfm_popularity.rs diff --git a/Cargo.lock b/Cargo.lock index 903929d..654aec6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.1.13" +version = "0.1.14" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 639d2b7..aab5697 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.1.14" +version = "0.1.15" 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 807c9f1..064a2fe 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -264,6 +264,20 @@ impl App for AdminApp { }), "admin_v2_job_run", ), + Route::with_handler_and_name( + "/v2/api/settings", + get(move |session: Session, db: Database| async move { + v2::settings(session, db).await + }) + .post( + move |session: Session, + db: Database, + json: Json| async move { + v2::update_settings(session, db, json).await + }, + ), + "admin_v2_settings", + ), Route::with_handler_and_name( "/v2/api/jobs/{name}/toggle", cot::router::method::post({ diff --git a/src/admin/v2.rs b/src/admin/v2.rs index 9a7102d..3a20143 100644 --- a/src/admin/v2.rs +++ b/src/admin/v2.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use cot::db::Database; +use cot::db::{Database, Model}; use cot::html::Html; use cot::http::StatusCode; use cot::http::header::CONTENT_TYPE; @@ -14,6 +14,7 @@ use sqlx::{PgPool, Postgres, QueryBuilder}; use super::BUILD_INFO; use crate::auth::{self, AuthenticatedUser, Role}; +use crate::config::{AppConfig, ConfigEntry}; use crate::i18n::{I18n, Translations}; use crate::scheduler::{JobRegistry, ScheduledJob}; @@ -214,6 +215,17 @@ struct MutationResponse { affected: u64, } +#[derive(Debug, Serialize, JsonSchema)] +struct AdminSettingsDto { + lastfm_api_key: String, + lastfm_api_key_configured: bool, +} + +#[derive(Debug, Deserialize)] +pub(super) struct UpdateSettingsRequest { + lastfm_api_key: String, +} + #[derive(Debug, Serialize, JsonSchema)] struct LibraryOverviewDto { artists: i64, @@ -458,6 +470,37 @@ pub async fn jobs( Json(jobs).into_response() } +pub async fn settings(session: Session, db: Database) -> cot::Result { + if let Err(response) = require_admin_json(&session, &db).await { + return Ok(response); + } + let (config, _) = AppConfig::load_with_db(&db).await; + Json(AdminSettingsDto { + lastfm_api_key_configured: !config.lastfm_api_key.trim().is_empty(), + lastfm_api_key: config.lastfm_api_key, + }) + .into_response() +} + +pub async fn update_settings( + session: Session, + db: Database, + Json(body): Json, +) -> cot::Result { + if let Err(response) = require_admin_json(&session, &db).await { + return Ok(response); + } + let mut entry = ConfigEntry::new( + "lastfm_api_key".to_string(), + body.lastfm_api_key.trim().to_string(), + ); + entry + .save(&db) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + Json(serde_json::json!({ "ok": true })).into_response() +} + pub async fn run_job( session: Session, db: Database, diff --git a/src/config.rs b/src/config.rs index 1182730..261e28a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -133,6 +133,7 @@ pub struct ConfigSources { pub agent_confidence_threshold: ConfigSource, pub agent_context_limit: ConfigSource, pub agent_concurrency: ConfigSource, + pub lastfm_api_key: ConfigSource, } impl Default for ConfigSources { @@ -158,6 +159,7 @@ impl Default for ConfigSources { agent_confidence_threshold: ConfigSource::Default, agent_context_limit: ConfigSource::Default, agent_concurrency: ConfigSource::Default, + lastfm_api_key: ConfigSource::Default, } } } @@ -262,6 +264,8 @@ pub struct AppConfig { pub agent_context_limit: u64, /// Number of files to process in parallel via the LLM. pub agent_concurrency: u64, + /// Last.fm API key for weekly popularity enrichment. + pub lastfm_api_key: String, } impl Default for AppConfig { @@ -287,6 +291,7 @@ impl Default for AppConfig { agent_confidence_threshold: 0.85, agent_context_limit: 8192, agent_concurrency: 2, + lastfm_api_key: String::new(), } } } @@ -313,6 +318,7 @@ impl_env_overrides!( agent_confidence_threshold, agent_context_limit, agent_concurrency, + lastfm_api_key, ); impl AppConfig { @@ -396,6 +402,7 @@ impl AppConfig { apply_db_field!(agent_confidence_threshold); apply_db_field!(agent_context_limit); apply_db_field!(agent_concurrency); + apply_db_field!(lastfm_api_key); } } diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index 0875a45..26a3733 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -321,6 +321,11 @@ translations! { player_audio: "Audio" , "Аудио"; player_size: "Size" , "Размер"; player_uploader: "Uploader" , "Загрузил"; + player_lastfm_rating: "Last.fm popularity" , "Популярность Last.fm"; + player_lastfm_listeners: "Last.fm listeners" , "Слушатели Last.fm"; + player_lastfm_playcount: "Last.fm plays" , "Прослушивания Last.fm"; + player_lastfm_updated: "Last.fm updated" , "Last.fm обновлён"; + player_lastfm_not_loaded: "not loaded yet" , "ещё не загружено"; player_play: "Play" , "Играть"; player_like: "Like" , "Лайк"; player_add_to_queue: "Add to queue" , "Добавить в очередь"; diff --git a/src/jobs/lastfm_popularity.rs b/src/jobs/lastfm_popularity.rs new file mode 100644 index 0000000..8056697 --- /dev/null +++ b/src/jobs/lastfm_popularity.rs @@ -0,0 +1,249 @@ +use serde::Deserialize; + +use crate::scheduler::{Job, JobContext, JobLog}; + +pub struct LastfmPopularityJob; + +const LASTFM_REQUEST_DELAY: std::time::Duration = std::time::Duration::from_millis(1200); + +#[derive(Debug, sqlx::FromRow)] +struct TrackLookupRow { + id: i64, + title: String, + artist_name: Option, + lastfm_updated_at: Option, +} + +#[derive(Debug, Deserialize)] +struct LastfmTrackInfoResponse { + track: Option, + error: Option, + message: Option, +} + +#[derive(Debug, Deserialize)] +struct LastfmTrack { + listeners: Option, + playcount: Option, +} + +#[async_trait::async_trait] +impl Job for LastfmPopularityJob { + fn name(&self) -> &'static str { + "lastfm_popularity" + } + + fn description(&self) -> &'static str { + "Update Last.fm playcount/listener popularity for library tracks" + } + + fn default_cron(&self) -> &'static str { + // Sundays at 04:15 + "0 15 4 * * Sun" + } + + async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> 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 popularity update"); + return Ok(()); + } + + let tracks = sqlx::query_as::<_, TrackLookupRow>( + r#"SELECT t.id, + t.title::text AS title, + t.lastfm_updated_at::text AS lastfm_updated_at, + ( + 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.lastfm_updated_at IS NOT NULL, t.lastfm_updated_at ASC, t.id ASC"#, + ) + .fetch_all(&ctx.pool) + .await?; + + if tracks.is_empty() { + log.info("No visible tracks found for Last.fm popularity update"); + return Ok(()); + } + + log.info(&format!( + "Starting Last.fm popularity update for {} visible tracks; oldest or missing ratings are processed first; request delay is {} ms; rating formula is ln(playcount + 1) * ln(listeners + 1)", + tracks.len(), + LASTFM_REQUEST_DELAY.as_millis() + )); + + let client = reqwest::Client::builder() + .user_agent("furumusic-lastfm-popularity/0.1") + .timeout(std::time::Duration::from_secs(15)) + .build()?; + let mut updated = 0u64; + let mut skipped = 0u64; + let mut failed = 0u64; + + for (index, track) in tracks.iter().enumerate() { + let Some(artist) = track + .artist_name + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + else { + skipped += 1; + log.warn(&format!( + "Skipping track {} \"{}\": no primary artist", + track.id, track.title + )); + continue; + }; + + log.info(&format!( + "Last.fm lookup {}/{}: track {} \"{}\" by \"{}\" (previous update: {})", + index + 1, + tracks.len(), + track.id, + track.title, + artist, + track.lastfm_updated_at.as_deref().unwrap_or("never") + )); + let result = fetch_track_info(&client, api_key, artist, &track.title).await; + match result { + Ok(Some((listeners, playcount))) => { + let rating = popularity_rating(listeners, playcount); + let fetched_at = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + sqlx::query( + r#"UPDATE furumusic__track + SET lastfm_listeners = $2, + lastfm_playcount = $3, + lastfm_rating = $4, + lastfm_updated_at = $5 + WHERE id = $1"#, + ) + .bind(track.id) + .bind(listeners) + .bind(playcount) + .bind(rating) + .bind(&fetched_at) + .execute(&ctx.pool) + .await?; + sqlx::query( + r#"INSERT INTO furumusic__track_popularity_history + (track_id, source, listeners, playcount, rating, fetched_at) + VALUES ($1, 'lastfm', $2, $3, $4, $5)"#, + ) + .bind(track.id) + .bind(listeners) + .bind(playcount) + .bind(rating) + .bind(&fetched_at) + .execute(&ctx.pool) + .await?; + updated += 1; + log.info(&format!( + "Updated track {} \"{}\" by \"{}\": listeners={listeners}, playcount={playcount}, rating={rating:.4}", + track.id, track.title, artist + )); + } + Ok(None) => { + skipped += 1; + log.warn(&format!( + "Last.fm has no usable match for track {} \"{}\" by \"{}\"", + track.id, track.title, artist + )); + } + Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => { + failed += 1; + log.error("Last.fm rate limit exceeded; stopping this run early"); + break; + } + Err(err) => { + failed += 1; + log.warn(&format!( + "Last.fm lookup failed for track {} \"{}\" / \"{}\": {err}", + track.id, artist, track.title + )); + } + } + + if (index + 1) % 50 == 0 { + log.info(&format!( + "Last.fm progress: {}/{} tracks, {updated} updated, {skipped} skipped, {failed} failed", + index + 1, + tracks.len() + )); + } + tokio::time::sleep(LASTFM_REQUEST_DELAY).await; + } + + log.info(&format!( + "Last.fm popularity update finished: {updated} updated, {skipped} skipped, {failed} failed, {} considered", + tracks.len() + )); + Ok(()) + } +} + +async fn fetch_track_info( + client: &reqwest::Client, + api_key: &str, + artist: &str, + track: &str, +) -> anyhow::Result> { + let response = client + .get("https://ws.audioscrobbler.com/2.0/") + .query(&[ + ("method", "track.getInfo"), + ("api_key", api_key), + ("artist", artist), + ("track", track), + ("autocorrect", "1"), + ("format", "json"), + ]) + .send() + .await?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + let response = response.error_for_status()?; + let body: LastfmTrackInfoResponse = response.json().await?; + if let Some(code) = body.error { + if code == 29 { + anyhow::bail!("Last.fm rate limit exceeded"); + } + if code == 6 || code == 7 { + return Ok(None); + } + anyhow::bail!( + "Last.fm API error {code}: {}", + body.message.unwrap_or_else(|| "unknown error".to_string()) + ); + } + let Some(info) = body.track else { + return Ok(None); + }; + let listeners = info + .listeners + .as_deref() + .unwrap_or("0") + .parse::() + .unwrap_or(0); + let playcount = info + .playcount + .as_deref() + .unwrap_or("0") + .parse::() + .unwrap_or(0); + Ok(Some((listeners.max(0), playcount.max(0)))) +} + +fn popularity_rating(listeners: i64, playcount: i64) -> f64 { + let listeners = listeners.max(0) as f64; + let playcount = playcount.max(0) as f64; + playcount.ln_1p() * listeners.ln_1p() +} diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs index 75b5436..eb84ce9 100644 --- a/src/jobs/mod.rs +++ b/src/jobs/mod.rs @@ -3,6 +3,7 @@ pub mod artist_track_image_backfill; pub mod cover_backfill; pub mod inbox_discover; pub mod inbox_process; +pub mod lastfm_popularity; pub mod metadata_backfill; use std::path::{Component, Path, PathBuf}; diff --git a/src/main.rs b/src/main.rs index 675cfd6..c794613 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,6 +53,7 @@ fn build_registry() -> Arc { registry.register(jobs::artist_image_backfill::ArtistImageBackfillJob); registry.register(jobs::artist_track_image_backfill::ArtistTrackImageBackfillJob); registry.register(jobs::metadata_backfill::MetadataBackfillJob); + registry.register(jobs::lastfm_popularity::LastfmPopularityJob); Arc::new(registry) } diff --git a/src/music/mod.rs b/src/music/mod.rs index 816675b..f352445 100644 --- a/src/music/mod.rs +++ b/src/music/mod.rs @@ -1637,6 +1637,61 @@ pub mod db_migrations { &[Operation::custom(create_torrent_session).build()]; } + // -- M0032: Last.fm track popularity ------------------------------------ + + #[cot::db::migrations::migration_op] + async fn create_lastfm_track_popularity( + ctx: migrations::MigrationContext<'_>, + ) -> cot::db::Result<()> { + ctx.db + .raw("ALTER TABLE furumusic__track ADD COLUMN lastfm_listeners BIGINT") + .await?; + ctx.db + .raw("ALTER TABLE furumusic__track ADD COLUMN lastfm_playcount BIGINT") + .await?; + ctx.db + .raw("ALTER TABLE furumusic__track ADD COLUMN lastfm_rating DOUBLE PRECISION") + .await?; + ctx.db + .raw("ALTER TABLE furumusic__track ADD COLUMN lastfm_updated_at VARCHAR(32)") + .await?; + ctx.db + .raw( + "CREATE TABLE IF NOT EXISTS furumusic__track_popularity_history ( + id BIGSERIAL PRIMARY KEY, + track_id BIGINT NOT NULL, + source VARCHAR(32) NOT NULL, + listeners BIGINT NOT NULL, + playcount BIGINT NOT NULL, + rating DOUBLE PRECISION NOT NULL, + fetched_at VARCHAR(32) NOT NULL + )", + ) + .await?; + ctx.db + .raw( + "CREATE INDEX IF NOT EXISTS idx_track_popularity_history_track + ON furumusic__track_popularity_history (track_id, fetched_at DESC)", + ) + .await?; + Ok(()) + } + + #[derive(Debug, Copy, Clone)] + pub struct M0032CreateLastfmTrackPopularity; + + impl migrations::Migration for M0032CreateLastfmTrackPopularity { + const APP_NAME: &'static str = "furumusic"; + const MIGRATION_NAME: &'static str = "m_0032_create_lastfm_track_popularity"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "furumusic", + "m_0031_create_torrent_session", + )]; + const OPERATIONS: &'static [Operation] = + &[Operation::custom(create_lastfm_track_popularity).build()]; + } + pub const MIGRATIONS: &[&SyncDynMigration] = &[ &M0006CreateMediaFile, &M0007CreateArtist, @@ -1659,5 +1714,6 @@ pub mod db_migrations { &M0029AddPlaybackVolume, &M0030AddMediaFileUploader, &M0031CreateTorrentSession, + &M0032CreateLastfmTrackPopularity, ]; } diff --git a/src/oidc.rs b/src/oidc.rs index 7581671..b5ebbc4 100644 --- a/src/oidc.rs +++ b/src/oidc.rs @@ -389,11 +389,7 @@ pub async fn oidc_callback_handler( config.oidc_user_groups, ); - if !is_allowed_by_groups( - &groups, - &config.oidc_user_groups, - &config.oidc_admin_groups, - ) { + if !is_allowed_by_groups(&groups, &config.oidc_user_groups, &config.oidc_admin_groups) { tracing::warn!( "OIDC login denied by group allowlist: sub={sub}, groups={groups:?}, user_groups={:?}, admin_groups={:?}", config.oidc_user_groups, diff --git a/src/player/dto.rs b/src/player/dto.rs index 9018e13..8eff855 100644 --- a/src/player/dto.rs +++ b/src/player/dto.rs @@ -64,6 +64,10 @@ pub(super) struct TrackItem { 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(Debug, Serialize, JsonSchema)] @@ -84,6 +88,10 @@ pub(super) struct ArtistAppearanceTrack { 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(Debug, Serialize, JsonSchema)] diff --git a/src/player/mod.rs b/src/player/mod.rs index 4df96fe..deba36d 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -313,7 +313,11 @@ async fn artist_detail_handler( mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, - mf.file_size_bytes + mf.file_size_bytes, + t.lastfm_listeners, + t.lastfm_playcount, + t.lastfm_rating, + t.lastfm_updated_at 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 @@ -390,6 +394,10 @@ async fn artist_detail_handler( 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(); @@ -459,7 +467,11 @@ async fn release_detail_handler( mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, - mf.file_size_bytes + 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 @@ -535,6 +547,10 @@ async fn release_detail_handler( 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(); @@ -689,7 +705,11 @@ async fn playlist_detail_handler( mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, - mf.file_size_bytes + mf.file_size_bytes, + t.lastfm_listeners, + t.lastfm_playcount, + t.lastfm_rating, + t.lastfm_updated_at 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 @@ -785,6 +805,10 @@ async fn build_track_items( 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()) @@ -962,7 +986,10 @@ async fn local_upload_handler( .await .map_err(|err| cot::Error::internal(err.to_string()))?; if bytes.is_empty() { - return Ok(json_error(StatusCode::BAD_REQUEST, "uploaded file is empty")); + return Ok(json_error( + StatusCode::BAD_REQUEST, + "uploaded file is empty", + )); } let upload_dir = inbox_root @@ -1404,7 +1431,11 @@ async fn search_handler( mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, - mf.file_size_bytes + mf.file_size_bytes, + t.lastfm_listeners, + t.lastfm_playcount, + t.lastfm_rating, + t.lastfm_updated_at 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 @@ -1469,7 +1500,7 @@ async fn search_handler( let t = sqlx::query_as::<_, SearchTrackRow>( r#"SELECT id, title, track_number, disc_number, duration_seconds, cover_file_id, release_cover_file_id, release_year, uploader_name, audio_format, audio_bitrate, - audio_sample_rate, audio_bit_depth, file_size_bytes FROM ( + audio_sample_rate, audio_bit_depth, file_size_bytes, lastfm_listeners, lastfm_playcount, lastfm_rating, lastfm_updated_at 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, @@ -1480,20 +1511,27 @@ async fn search_handler( 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, MAX(sim) AS similarity FROM ( SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id, + lastfm_listeners, lastfm_playcount, lastfm_rating, lastfm_updated_at, 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, audio_file_id, + lastfm_listeners, lastfm_playcount, lastfm_rating, lastfm_updated_at, 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 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, rel.year, - mf.uploader_name, mf.audio_format, mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, mf.file_size_bytes + mf.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 ORDER BY similarity DESC LIMIT $2 ) sub"#, @@ -1597,6 +1635,10 @@ async fn search_handler( 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(); @@ -2161,7 +2203,11 @@ async fn tracks_by_ids_handler( mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, - mf.file_size_bytes + 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 @@ -2236,6 +2282,10 @@ async fn tracks_by_ids_handler( 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, }, ); } @@ -2408,9 +2458,7 @@ impl App for PlayerApp { .await; let service = torrent_service .get_or_init(|| async { - Arc::new(TorrentService::new(Arc::clone( - &scheduler_handle, - ))) + Arc::new(TorrentService::new(Arc::clone(&scheduler_handle))) }) .await; match service.details(pg_pool, user.id, &path.0.id).await { @@ -2422,40 +2470,44 @@ impl App for PlayerApp { } } }) - .delete(move |session: Session, db: Database, path: Path| { - let pool = Arc::clone(&pool); - let pool_config = Arc::clone(&pool_config); - 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 { - return Ok(json_error( - StatusCode::UNAUTHORIZED, - "not authenticated", - )); - }; - 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; - let service = torrent_service - .get_or_init(|| async { - Arc::new(TorrentService::new(Arc::clone(&scheduler_handle))) - }) - .await; - match service.remove(pg_pool, user.id, &path.0.id).await { - Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(), - Err(err) => { - Ok(json_error(StatusCode::NOT_FOUND, &err.to_string())) + .delete( + move |session: Session, db: Database, path: Path| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + 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 { + return Ok(json_error( + StatusCode::UNAUTHORIZED, + "not authenticated", + )); + }; + 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; + let service = torrent_service + .get_or_init(|| async { + Arc::new(TorrentService::new(Arc::clone(&scheduler_handle))) + }) + .await; + match service.remove(pg_pool, user.id, &path.0.id).await { + Ok(()) => { + Json(serde_json::json!({ "ok": true })).into_response() + } + Err(err) => { + Ok(json_error(StatusCode::NOT_FOUND, &err.to_string())) + } } } - } - }) + }, + ) }, "player_torrent_detail", ), @@ -2594,40 +2646,42 @@ impl App for PlayerApp { let pool_config = Arc::clone(&pool_config); let torrent_service = Arc::clone(&torrent_service); let scheduler_handle = Arc::clone(&self.scheduler_handle); - post(move |session: Session, db: Database, path: Path| { - let pool = Arc::clone(&pool); - let pool_config = Arc::clone(&pool_config); - 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 { - return Ok(json_error( - StatusCode::UNAUTHORIZED, - "not authenticated", - )); - }; - 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; - let service = torrent_service - .get_or_init(|| async { - Arc::new(TorrentService::new(Arc::clone(&scheduler_handle))) - }) - .await; - match service.pause(pg_pool, user.id, &path.0.id).await { - Ok(job) => Json(job).into_response(), - Err(err) => { - Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string())) + post( + move |session: Session, db: Database, path: Path| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + 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 { + return Ok(json_error( + StatusCode::UNAUTHORIZED, + "not authenticated", + )); + }; + 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; + let service = torrent_service + .get_or_init(|| async { + Arc::new(TorrentService::new(Arc::clone(&scheduler_handle))) + }) + .await; + match service.pause(pg_pool, user.id, &path.0.id).await { + Ok(job) => Json(job).into_response(), + Err(err) => { + Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string())) + } } } - } - }) + }, + ) }, "player_torrent_pause", ), diff --git a/src/player/rows.rs b/src/player/rows.rs index fdb8082..df59e9b 100644 --- a/src/player/rows.rs +++ b/src/player/rows.rs @@ -44,6 +44,10 @@ pub(super) struct TrackRow { 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)] @@ -110,6 +114,10 @@ pub(super) struct PlaylistTrackRow { 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)] @@ -128,6 +136,10 @@ pub(super) struct AppearanceTrackRow { 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)] @@ -165,6 +177,10 @@ pub(super) struct SearchTrackRow { 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/src/scheduler/mod.rs b/src/scheduler/mod.rs index d0380da..a96f6dc 100644 --- a/src/scheduler/mod.rs +++ b/src/scheduler/mod.rs @@ -1347,7 +1347,7 @@ async fn run_scheduled_job( // Check agent_enabled (re-read from DB every run) let (live_config, _) = AppConfig::load_with_db(db).await; - if !live_config.agent_enabled { + if !live_config.agent_enabled && job_name != "lastfm_popularity" { tracing::warn!(job = job_name, "Skipping: agent_enabled=false"); return; } diff --git a/src/torrents.rs b/src/torrents.rs index f47809c..d822a05 100644 --- a/src/torrents.rs +++ b/src/torrents.rs @@ -354,7 +354,7 @@ impl TorrentJob { 100.0 } else { progress_percent(downloaded_bytes, total_bytes) - .unwrap_or(self.progress_percent) + .unwrap_or(self.progress_percent) .clamp(0.0, 100.0) }, download_speed_mbps: live.map(|l| l.download_speed.mbps), @@ -746,13 +746,12 @@ impl TorrentService { self.stop_torrent(&handle).await; } - let result = sqlx::query( - "DELETE FROM furumusic__torrent_session WHERE id = $1 AND user_id = $2", - ) - .bind(id) - .bind(user_id) - .execute(pool) - .await?; + let result = + sqlx::query("DELETE FROM furumusic__torrent_session WHERE id = $1 AND user_id = $2") + .bind(id) + .bind(user_id) + .execute(pool) + .await?; if result.rows_affected() == 0 { bail!("torrent session not found"); @@ -784,7 +783,12 @@ impl TorrentService { if job.user_id != uploader_user_id { bail!("torrent job not found"); } - if job.handle.is_some() && matches!(job.status, TorrentJobStatus::Downloading | TorrentJobStatus::Moving) { + if job.handle.is_some() + && matches!( + job.status, + TorrentJobStatus::Downloading | TorrentJobStatus::Moving + ) + { bail!("torrent job is already running"); } validate_selection(&job.files, &selected_files)?; diff --git a/templates/admin/v2.html b/templates/admin/v2.html index 8bdc14d..f5e3a7f 100644 --- a/templates/admin/v2.html +++ b/templates/admin/v2.html @@ -667,6 +667,24 @@ tbody tr:hover { display: block; } +.settings-page { + display: grid; + grid-template-columns: minmax(560px, 760px) minmax(260px, 1fr); + gap: 14px; + align-items: start; +} + +.settings-card { + padding: 14px; +} + +.settings-note { + padding: 14px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.55; +} + .library-row { display: grid; grid-template-columns: 38px minmax(0, 1fr) 300px 130px; @@ -818,6 +836,11 @@ tbody tr:hover { Future Tools + + +
+
+
+
+
+ External APIs + Keys used by scheduled enrichment jobs +
+ +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ Last.fm Popularity + Weekly track rating refresh +
+
+
+ The scheduler uses Last.fm track.getInfo for each track, stores listeners, playcount, current rating, and a history row. The job processes tracks with missing or oldest ratings first and waits between requests to avoid Last.fm API limits. +
+
+
+