Added lastfm statistics
Build and Publish / Build and Publish Docker Image (push) Successful in 2m58s

This commit is contained in:
Ultradesu
2026-05-26 18:16:34 +03:00
parent d425bf3087
commit 4b8797bb2e
18 changed files with 657 additions and 93 deletions
Generated
+1 -1
View File
@@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]] [[package]]
name = "furumusic" name = "furumusic"
version = "0.1.13" version = "0.1.14"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "furumusic" name = "furumusic"
version = "0.1.14" version = "0.1.15"
edition = "2024" edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
+14
View File
@@ -264,6 +264,20 @@ impl App for AdminApp {
}), }),
"admin_v2_job_run", "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<v2::UpdateSettingsRequest>| async move {
v2::update_settings(session, db, json).await
},
),
"admin_v2_settings",
),
Route::with_handler_and_name( Route::with_handler_and_name(
"/v2/api/jobs/{name}/toggle", "/v2/api/jobs/{name}/toggle",
cot::router::method::post({ cot::router::method::post({
+44 -1
View File
@@ -1,6 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use cot::db::Database; use cot::db::{Database, Model};
use cot::html::Html; use cot::html::Html;
use cot::http::StatusCode; use cot::http::StatusCode;
use cot::http::header::CONTENT_TYPE; use cot::http::header::CONTENT_TYPE;
@@ -14,6 +14,7 @@ use sqlx::{PgPool, Postgres, QueryBuilder};
use super::BUILD_INFO; use super::BUILD_INFO;
use crate::auth::{self, AuthenticatedUser, Role}; use crate::auth::{self, AuthenticatedUser, Role};
use crate::config::{AppConfig, ConfigEntry};
use crate::i18n::{I18n, Translations}; use crate::i18n::{I18n, Translations};
use crate::scheduler::{JobRegistry, ScheduledJob}; use crate::scheduler::{JobRegistry, ScheduledJob};
@@ -214,6 +215,17 @@ struct MutationResponse {
affected: u64, 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)] #[derive(Debug, Serialize, JsonSchema)]
struct LibraryOverviewDto { struct LibraryOverviewDto {
artists: i64, artists: i64,
@@ -458,6 +470,37 @@ pub async fn jobs(
Json(jobs).into_response() Json(jobs).into_response()
} }
pub async fn settings(session: Session, db: Database) -> cot::Result<cot::response::Response> {
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<UpdateSettingsRequest>,
) -> cot::Result<cot::response::Response> {
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( pub async fn run_job(
session: Session, session: Session,
db: Database, db: Database,
+7
View File
@@ -133,6 +133,7 @@ pub struct ConfigSources {
pub agent_confidence_threshold: ConfigSource, pub agent_confidence_threshold: ConfigSource,
pub agent_context_limit: ConfigSource, pub agent_context_limit: ConfigSource,
pub agent_concurrency: ConfigSource, pub agent_concurrency: ConfigSource,
pub lastfm_api_key: ConfigSource,
} }
impl Default for ConfigSources { impl Default for ConfigSources {
@@ -158,6 +159,7 @@ impl Default for ConfigSources {
agent_confidence_threshold: ConfigSource::Default, agent_confidence_threshold: ConfigSource::Default,
agent_context_limit: ConfigSource::Default, agent_context_limit: ConfigSource::Default,
agent_concurrency: ConfigSource::Default, agent_concurrency: ConfigSource::Default,
lastfm_api_key: ConfigSource::Default,
} }
} }
} }
@@ -262,6 +264,8 @@ pub struct AppConfig {
pub agent_context_limit: u64, pub agent_context_limit: u64,
/// Number of files to process in parallel via the LLM. /// Number of files to process in parallel via the LLM.
pub agent_concurrency: u64, pub agent_concurrency: u64,
/// Last.fm API key for weekly popularity enrichment.
pub lastfm_api_key: String,
} }
impl Default for AppConfig { impl Default for AppConfig {
@@ -287,6 +291,7 @@ impl Default for AppConfig {
agent_confidence_threshold: 0.85, agent_confidence_threshold: 0.85,
agent_context_limit: 8192, agent_context_limit: 8192,
agent_concurrency: 2, agent_concurrency: 2,
lastfm_api_key: String::new(),
} }
} }
} }
@@ -313,6 +318,7 @@ impl_env_overrides!(
agent_confidence_threshold, agent_confidence_threshold,
agent_context_limit, agent_context_limit,
agent_concurrency, agent_concurrency,
lastfm_api_key,
); );
impl AppConfig { impl AppConfig {
@@ -396,6 +402,7 @@ impl AppConfig {
apply_db_field!(agent_confidence_threshold); apply_db_field!(agent_confidence_threshold);
apply_db_field!(agent_context_limit); apply_db_field!(agent_context_limit);
apply_db_field!(agent_concurrency); apply_db_field!(agent_concurrency);
apply_db_field!(lastfm_api_key);
} }
} }
+5
View File
@@ -321,6 +321,11 @@ translations! {
player_audio: "Audio" , "Аудио"; player_audio: "Audio" , "Аудио";
player_size: "Size" , "Размер"; player_size: "Size" , "Размер";
player_uploader: "Uploader" , "Загрузил"; 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_play: "Play" , "Играть";
player_like: "Like" , "Лайк"; player_like: "Like" , "Лайк";
player_add_to_queue: "Add to queue" , "Добавить в очередь"; player_add_to_queue: "Add to queue" , "Добавить в очередь";
+249
View File
@@ -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<String>,
lastfm_updated_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct LastfmTrackInfoResponse {
track: Option<LastfmTrack>,
error: Option<i32>,
message: Option<String>,
}
#[derive(Debug, Deserialize)]
struct LastfmTrack {
listeners: Option<String>,
playcount: Option<String>,
}
#[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<Option<(i64, i64)>> {
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::<i64>()
.unwrap_or(0);
let playcount = info
.playcount
.as_deref()
.unwrap_or("0")
.parse::<i64>()
.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()
}
+1
View File
@@ -3,6 +3,7 @@ pub mod artist_track_image_backfill;
pub mod cover_backfill; pub mod cover_backfill;
pub mod inbox_discover; pub mod inbox_discover;
pub mod inbox_process; pub mod inbox_process;
pub mod lastfm_popularity;
pub mod metadata_backfill; pub mod metadata_backfill;
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
+1
View File
@@ -53,6 +53,7 @@ fn build_registry() -> Arc<JobRegistry> {
registry.register(jobs::artist_image_backfill::ArtistImageBackfillJob); registry.register(jobs::artist_image_backfill::ArtistImageBackfillJob);
registry.register(jobs::artist_track_image_backfill::ArtistTrackImageBackfillJob); registry.register(jobs::artist_track_image_backfill::ArtistTrackImageBackfillJob);
registry.register(jobs::metadata_backfill::MetadataBackfillJob); registry.register(jobs::metadata_backfill::MetadataBackfillJob);
registry.register(jobs::lastfm_popularity::LastfmPopularityJob);
Arc::new(registry) Arc::new(registry)
} }
+56
View File
@@ -1637,6 +1637,61 @@ pub mod db_migrations {
&[Operation::custom(create_torrent_session).build()]; &[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] = &[ pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0006CreateMediaFile, &M0006CreateMediaFile,
&M0007CreateArtist, &M0007CreateArtist,
@@ -1659,5 +1714,6 @@ pub mod db_migrations {
&M0029AddPlaybackVolume, &M0029AddPlaybackVolume,
&M0030AddMediaFileUploader, &M0030AddMediaFileUploader,
&M0031CreateTorrentSession, &M0031CreateTorrentSession,
&M0032CreateLastfmTrackPopularity,
]; ];
} }
+1 -5
View File
@@ -389,11 +389,7 @@ pub async fn oidc_callback_handler(
config.oidc_user_groups, config.oidc_user_groups,
); );
if !is_allowed_by_groups( if !is_allowed_by_groups(&groups, &config.oidc_user_groups, &config.oidc_admin_groups) {
&groups,
&config.oidc_user_groups,
&config.oidc_admin_groups,
) {
tracing::warn!( tracing::warn!(
"OIDC login denied by group allowlist: sub={sub}, groups={groups:?}, user_groups={:?}, admin_groups={:?}", "OIDC login denied by group allowlist: sub={sub}, groups={groups:?}, user_groups={:?}, admin_groups={:?}",
config.oidc_user_groups, config.oidc_user_groups,
+8
View File
@@ -64,6 +64,10 @@ pub(super) struct TrackItem {
pub(super) audio_sample_rate: Option<i32>, pub(super) audio_sample_rate: Option<i32>,
pub(super) audio_bit_depth: Option<i32>, pub(super) audio_bit_depth: Option<i32>,
pub(super) file_size_bytes: Option<i64>, pub(super) file_size_bytes: Option<i64>,
pub(super) lastfm_listeners: Option<i64>,
pub(super) lastfm_playcount: Option<i64>,
pub(super) lastfm_rating: Option<f64>,
pub(super) lastfm_updated_at: Option<String>,
} }
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
@@ -84,6 +88,10 @@ pub(super) struct ArtistAppearanceTrack {
pub(super) audio_sample_rate: Option<i32>, pub(super) audio_sample_rate: Option<i32>,
pub(super) audio_bit_depth: Option<i32>, pub(super) audio_bit_depth: Option<i32>,
pub(super) file_size_bytes: Option<i64>, pub(super) file_size_bytes: Option<i64>,
pub(super) lastfm_listeners: Option<i64>,
pub(super) lastfm_playcount: Option<i64>,
pub(super) lastfm_rating: Option<f64>,
pub(super) lastfm_updated_at: Option<String>,
} }
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
+129 -75
View File
@@ -313,7 +313,11 @@ async fn artist_detail_handler(
mf.audio_bitrate, mf.audio_bitrate,
mf.audio_sample_rate, mf.audio_sample_rate,
mf.audio_bit_depth, 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 FROM furumusic__track_artist ta
JOIN furumusic__track t ON t.id = ta.track_id JOIN furumusic__track t ON t.id = ta.track_id
JOIN furumusic__release r ON r.id = t.release_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_sample_rate: t.audio_sample_rate,
audio_bit_depth: t.audio_bit_depth, audio_bit_depth: t.audio_bit_depth,
file_size_bytes: t.file_size_bytes, 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(); .collect();
@@ -459,7 +467,11 @@ async fn release_detail_handler(
mf.audio_bitrate, mf.audio_bitrate,
mf.audio_sample_rate, mf.audio_sample_rate,
mf.audio_bit_depth, 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 FROM furumusic__track t
JOIN furumusic__release r ON r.id = t.release_id JOIN furumusic__release r ON r.id = t.release_id
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_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_sample_rate: t.audio_sample_rate,
audio_bit_depth: t.audio_bit_depth, audio_bit_depth: t.audio_bit_depth,
file_size_bytes: t.file_size_bytes, 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(); .collect();
@@ -689,7 +705,11 @@ async fn playlist_detail_handler(
mf.audio_bitrate, mf.audio_bitrate,
mf.audio_sample_rate, mf.audio_sample_rate,
mf.audio_bit_depth, 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 FROM furumusic__playlist_track pt
JOIN furumusic__track t ON t.id = pt.track_id JOIN furumusic__track t ON t.id = pt.track_id
JOIN furumusic__release r ON r.id = t.release_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_sample_rate: t.audio_sample_rate,
audio_bit_depth: t.audio_bit_depth, audio_bit_depth: t.audio_bit_depth,
file_size_bytes: t.file_size_bytes, 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()) .collect())
@@ -962,7 +986,10 @@ async fn local_upload_handler(
.await .await
.map_err(|err| cot::Error::internal(err.to_string()))?; .map_err(|err| cot::Error::internal(err.to_string()))?;
if bytes.is_empty() { 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 let upload_dir = inbox_root
@@ -1404,7 +1431,11 @@ async fn search_handler(
mf.audio_bitrate, mf.audio_bitrate,
mf.audio_sample_rate, mf.audio_sample_rate,
mf.audio_bit_depth, 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 FROM furumusic__track t
JOIN furumusic__release rel ON rel.id = t.release_id JOIN furumusic__release rel ON rel.id = t.release_id
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_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>( let t = sqlx::query_as::<_, SearchTrackRow>(
r#"SELECT id, title, track_number, disc_number, duration_seconds, cover_file_id, 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, 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, SELECT t.id, t.title::text AS title, t.track_number, t.disc_number,
t.duration_seconds, t.cover_file_id, t.duration_seconds, t.cover_file_id,
rel.cover_file_id AS release_cover_file_id, rel.cover_file_id AS release_cover_file_id,
@@ -1480,20 +1511,27 @@ async fn search_handler(
mf.audio_sample_rate, mf.audio_sample_rate,
mf.audio_bit_depth, 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,
MAX(sim) AS similarity MAX(sim) AS similarity
FROM ( FROM (
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id, 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 similarity(title_sort, $1) AS sim
FROM furumusic__track WHERE is_hidden = false AND title_sort % $1 FROM furumusic__track WHERE is_hidden = false AND title_sort % $1
UNION ALL UNION ALL
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id, 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 0.01::real AS sim
FROM furumusic__track WHERE is_hidden = false AND title_sort ILIKE '%' || $1 || '%' FROM furumusic__track WHERE is_hidden = false AND title_sort ILIKE '%' || $1 || '%'
) t ) t
JOIN furumusic__release rel ON rel.id = t.release_id JOIN furumusic__release rel ON rel.id = t.release_id
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
GROUP BY t.id, t.title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, rel.cover_file_id, rel.year, 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 ORDER BY similarity DESC
LIMIT $2 LIMIT $2
) sub"#, ) sub"#,
@@ -1597,6 +1635,10 @@ async fn search_handler(
audio_sample_rate: t.audio_sample_rate, audio_sample_rate: t.audio_sample_rate,
audio_bit_depth: t.audio_bit_depth, audio_bit_depth: t.audio_bit_depth,
file_size_bytes: t.file_size_bytes, 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(); .collect();
@@ -2161,7 +2203,11 @@ async fn tracks_by_ids_handler(
mf.audio_bitrate, mf.audio_bitrate,
mf.audio_sample_rate, mf.audio_sample_rate,
mf.audio_bit_depth, 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 FROM furumusic__track t
JOIN furumusic__release r ON r.id = t.release_id JOIN furumusic__release r ON r.id = t.release_id
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_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_sample_rate: t.audio_sample_rate,
audio_bit_depth: t.audio_bit_depth, audio_bit_depth: t.audio_bit_depth,
file_size_bytes: t.file_size_bytes, 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; .await;
let service = torrent_service let service = torrent_service
.get_or_init(|| async { .get_or_init(|| async {
Arc::new(TorrentService::new(Arc::clone( Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
&scheduler_handle,
)))
}) })
.await; .await;
match service.details(pg_pool, user.id, &path.0.id).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<PathStringId>| { .delete(
let pool = Arc::clone(&pool); move |session: Session, db: Database, path: Path<PathStringId>| {
let pool_config = Arc::clone(&pool_config); let pool = Arc::clone(&pool);
let torrent_service = Arc::clone(&torrent_service); let pool_config = Arc::clone(&pool_config);
let scheduler_handle = Arc::clone(&scheduler_handle); let torrent_service = Arc::clone(&torrent_service);
async move { let scheduler_handle = Arc::clone(&scheduler_handle);
let Some(user) = auth::get_session_user(&session, &db).await else { async move {
return Ok(json_error( let Some(user) = auth::get_session_user(&session, &db).await else {
StatusCode::UNAUTHORIZED, return Ok(json_error(
"not authenticated", StatusCode::UNAUTHORIZED,
)); "not authenticated",
}; ));
let pg_pool = pool };
.get_or_init(|| async { let pg_pool = pool
sqlx::postgres::PgPoolOptions::new() .get_or_init(|| async {
.max_connections(5) sqlx::postgres::PgPoolOptions::new()
.connect(&pool_config.database_url) .max_connections(5)
.await .connect(&pool_config.database_url)
.expect("player pool") .await
}) .expect("player pool")
.await; })
let service = torrent_service .await;
.get_or_init(|| async { let service = torrent_service
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle))) .get_or_init(|| async {
}) Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
.await; })
match service.remove(pg_pool, user.id, &path.0.id).await { .await;
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(), match service.remove(pg_pool, user.id, &path.0.id).await {
Err(err) => { Ok(()) => {
Ok(json_error(StatusCode::NOT_FOUND, &err.to_string())) Json(serde_json::json!({ "ok": true })).into_response()
}
Err(err) => {
Ok(json_error(StatusCode::NOT_FOUND, &err.to_string()))
}
} }
} }
} },
}) )
}, },
"player_torrent_detail", "player_torrent_detail",
), ),
@@ -2594,40 +2646,42 @@ impl App for PlayerApp {
let pool_config = Arc::clone(&pool_config); let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service); let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&self.scheduler_handle); let scheduler_handle = Arc::clone(&self.scheduler_handle);
post(move |session: Session, db: Database, path: Path<PathStringId>| { post(
let pool = Arc::clone(&pool); move |session: Session, db: Database, path: Path<PathStringId>| {
let pool_config = Arc::clone(&pool_config); let pool = Arc::clone(&pool);
let torrent_service = Arc::clone(&torrent_service); let pool_config = Arc::clone(&pool_config);
let scheduler_handle = Arc::clone(&scheduler_handle); let torrent_service = Arc::clone(&torrent_service);
async move { let scheduler_handle = Arc::clone(&scheduler_handle);
let Some(user) = auth::get_session_user(&session, &db).await else { async move {
return Ok(json_error( let Some(user) = auth::get_session_user(&session, &db).await else {
StatusCode::UNAUTHORIZED, return Ok(json_error(
"not authenticated", StatusCode::UNAUTHORIZED,
)); "not authenticated",
}; ));
let pg_pool = pool };
.get_or_init(|| async { let pg_pool = pool
sqlx::postgres::PgPoolOptions::new() .get_or_init(|| async {
.max_connections(5) sqlx::postgres::PgPoolOptions::new()
.connect(&pool_config.database_url) .max_connections(5)
.await .connect(&pool_config.database_url)
.expect("player pool") .await
}) .expect("player pool")
.await; })
let service = torrent_service .await;
.get_or_init(|| async { let service = torrent_service
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle))) .get_or_init(|| async {
}) Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
.await; })
match service.pause(pg_pool, user.id, &path.0.id).await { .await;
Ok(job) => Json(job).into_response(), match service.pause(pg_pool, user.id, &path.0.id).await {
Err(err) => { Ok(job) => Json(job).into_response(),
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string())) Err(err) => {
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
}
} }
} }
} },
}) )
}, },
"player_torrent_pause", "player_torrent_pause",
), ),
+16
View File
@@ -44,6 +44,10 @@ pub(super) struct TrackRow {
pub(super) audio_sample_rate: Option<i32>, pub(super) audio_sample_rate: Option<i32>,
pub(super) audio_bit_depth: Option<i32>, pub(super) audio_bit_depth: Option<i32>,
pub(super) file_size_bytes: Option<i64>, pub(super) file_size_bytes: Option<i64>,
pub(super) lastfm_listeners: Option<i64>,
pub(super) lastfm_playcount: Option<i64>,
pub(super) lastfm_rating: Option<f64>,
pub(super) lastfm_updated_at: Option<String>,
} }
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@@ -110,6 +114,10 @@ pub(super) struct PlaylistTrackRow {
pub(super) audio_sample_rate: Option<i32>, pub(super) audio_sample_rate: Option<i32>,
pub(super) audio_bit_depth: Option<i32>, pub(super) audio_bit_depth: Option<i32>,
pub(super) file_size_bytes: Option<i64>, pub(super) file_size_bytes: Option<i64>,
pub(super) lastfm_listeners: Option<i64>,
pub(super) lastfm_playcount: Option<i64>,
pub(super) lastfm_rating: Option<f64>,
pub(super) lastfm_updated_at: Option<String>,
} }
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@@ -128,6 +136,10 @@ pub(super) struct AppearanceTrackRow {
pub(super) audio_sample_rate: Option<i32>, pub(super) audio_sample_rate: Option<i32>,
pub(super) audio_bit_depth: Option<i32>, pub(super) audio_bit_depth: Option<i32>,
pub(super) file_size_bytes: Option<i64>, pub(super) file_size_bytes: Option<i64>,
pub(super) lastfm_listeners: Option<i64>,
pub(super) lastfm_playcount: Option<i64>,
pub(super) lastfm_rating: Option<f64>,
pub(super) lastfm_updated_at: Option<String>,
} }
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@@ -165,6 +177,10 @@ pub(super) struct SearchTrackRow {
pub(super) audio_sample_rate: Option<i32>, pub(super) audio_sample_rate: Option<i32>,
pub(super) audio_bit_depth: Option<i32>, pub(super) audio_bit_depth: Option<i32>,
pub(super) file_size_bytes: Option<i64>, pub(super) file_size_bytes: Option<i64>,
pub(super) lastfm_listeners: Option<i64>,
pub(super) lastfm_playcount: Option<i64>,
pub(super) lastfm_rating: Option<f64>,
pub(super) lastfm_updated_at: Option<String>,
} }
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
+1 -1
View File
@@ -1347,7 +1347,7 @@ async fn run_scheduled_job(
// Check agent_enabled (re-read from DB every run) // Check agent_enabled (re-read from DB every run)
let (live_config, _) = AppConfig::load_with_db(db).await; 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"); tracing::warn!(job = job_name, "Skipping: agent_enabled=false");
return; return;
} }
+13 -9
View File
@@ -354,7 +354,7 @@ impl TorrentJob {
100.0 100.0
} else { } else {
progress_percent(downloaded_bytes, total_bytes) progress_percent(downloaded_bytes, total_bytes)
.unwrap_or(self.progress_percent) .unwrap_or(self.progress_percent)
.clamp(0.0, 100.0) .clamp(0.0, 100.0)
}, },
download_speed_mbps: live.map(|l| l.download_speed.mbps), download_speed_mbps: live.map(|l| l.download_speed.mbps),
@@ -746,13 +746,12 @@ impl TorrentService {
self.stop_torrent(&handle).await; self.stop_torrent(&handle).await;
} }
let result = sqlx::query( let result =
"DELETE FROM furumusic__torrent_session WHERE id = $1 AND user_id = $2", sqlx::query("DELETE FROM furumusic__torrent_session WHERE id = $1 AND user_id = $2")
) .bind(id)
.bind(id) .bind(user_id)
.bind(user_id) .execute(pool)
.execute(pool) .await?;
.await?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
bail!("torrent session not found"); bail!("torrent session not found");
@@ -784,7 +783,12 @@ impl TorrentService {
if job.user_id != uploader_user_id { if job.user_id != uploader_user_id {
bail!("torrent job not found"); 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"); bail!("torrent job is already running");
} }
validate_selection(&job.files, &selected_files)?; validate_selection(&job.files, &selected_files)?;
+96
View File
@@ -667,6 +667,24 @@ tbody tr:hover {
display: block; 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 { .library-row {
display: grid; display: grid;
grid-template-columns: 38px minmax(0, 1fr) 300px 130px; grid-template-columns: 38px minmax(0, 1fr) 300px 130px;
@@ -818,6 +836,11 @@ tbody tr:hover {
<i data-lucide="wrench"></i> <i data-lucide="wrench"></i>
<span>Future Tools</span> <span>Future Tools</span>
</button> </button>
<button class="nav-btn" :class="{active: activeView === 'settings'}" @click="activeView = 'settings'; loadSettings()">
<i data-lucide="settings"></i>
<span>Settings</span>
<span class="nav-count" x-text="settings.lastfm_api_key_configured ? 'ok' : ''"></span>
</button>
</div> </div>
<div class="nav-group"> <div class="nav-group">
@@ -1236,6 +1259,48 @@ tbody tr:hover {
</div> </div>
</section> </section>
</div> </div>
<div class="content" x-show="activeView === 'settings'">
<div class="settings-page">
<section class="panel">
<div class="panel-head">
<div class="panel-title">
<strong>External APIs</strong>
<span>Keys used by scheduled enrichment jobs</span>
</div>
<span class="badge" :class="settings.lastfm_api_key_configured ? 'ok' : 'disabled'" x-text="settings.lastfm_api_key_configured ? 'configured' : 'not configured'"></span>
</div>
<form class="settings-card" @submit.prevent="saveSettings()">
<div class="field">
<label>Last.fm API key</label>
<input type="password" x-model="settingsDraft.lastfm_api_key" autocomplete="off" placeholder="Paste Last.fm API key" />
</div>
<div class="toolbar">
<button class="btn primary" type="submit">
<i data-lucide="save"></i>
Save
</button>
<button class="btn" type="button" @click="loadSettings()">
<i data-lucide="refresh-cw"></i>
Reload
</button>
</div>
</form>
</section>
<section class="panel">
<div class="panel-head">
<div class="panel-title">
<strong>Last.fm Popularity</strong>
<span>Weekly track rating refresh</span>
</div>
</div>
<div class="settings-note">
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.
</div>
</section>
</div>
</div>
</main> </main>
<div class="modal-backdrop" x-show="reviewModalOpen && activeReview" x-transition @click.self="reviewModalOpen = false"> <div class="modal-backdrop" x-show="reviewModalOpen && activeReview" x-transition @click.self="reviewModalOpen = false">
@@ -1366,6 +1431,8 @@ function adminV2() {
activeLibraryItem: null, activeLibraryItem: null,
editorOpen: false, editorOpen: false,
editorDraft: { title: '', hidden: 'false' }, editorDraft: { title: '', hidden: 'false' },
settings: { lastfm_api_key: '', lastfm_api_key_configured: false },
settingsDraft: { lastfm_api_key: '' },
poller: null, poller: null,
async init() { async init() {
@@ -1399,6 +1466,7 @@ function adminV2() {
this.jobs = data.jobs || []; this.jobs = data.jobs || [];
this.recentRuns = data.recent_runs || []; this.recentRuns = data.recent_runs || [];
if (!this.activeJobName && this.jobs.length) this.activeJobName = this.jobs[0].name; if (!this.activeJobName && this.jobs.length) this.activeJobName = this.jobs[0].name;
await this.loadSettings(false);
await this.loadLibrary(false); await this.loadLibrary(false);
} catch (error) { } catch (error) {
this.showToast(error.message); this.showToast(error.message);
@@ -1462,6 +1530,32 @@ function adminV2() {
} }
}, },
async loadSettings(showErrors = true) {
try {
this.settings = await this.request(`${this.apiBase}/settings`);
this.settingsDraft.lastfm_api_key = this.settings.lastfm_api_key || '';
} catch (error) {
if (showErrors) this.showToast(error.message);
} finally {
this.icons();
}
},
async saveSettings() {
try {
await this.request(`${this.apiBase}/settings`, {
method: 'POST',
body: JSON.stringify({
lastfm_api_key: this.settingsDraft.lastfm_api_key || ''
})
});
await this.loadSettings(false);
this.showToast('Settings saved');
} catch (error) {
this.showToast(error.message);
}
},
setReviewStatus(status) { setReviewStatus(status) {
this.reviewFilter.status = status; this.reviewFilter.status = status;
this.loadReviews(); this.loadReviews();
@@ -1770,6 +1864,7 @@ function adminV2() {
if (this.activeView === 'library') return 'Library Workbench'; if (this.activeView === 'library') return 'Library Workbench';
if (this.activeView === 'jobs') return 'Tasks'; if (this.activeView === 'jobs') return 'Tasks';
if (this.activeView === 'tools') return 'Future Tools'; if (this.activeView === 'tools') return 'Future Tools';
if (this.activeView === 'settings') return 'Settings';
return 'Review Queue'; return 'Review Queue';
}, },
@@ -1777,6 +1872,7 @@ function adminV2() {
if (this.activeView === 'library') return 'Fast entity control surface for artists, releases, and playlists'; if (this.activeView === 'library') return 'Fast entity control surface for artists, releases, and playlists';
if (this.activeView === 'jobs') return 'Scheduler state, recent runs, and manual controls in one place'; if (this.activeView === 'jobs') return 'Scheduler state, recent runs, and manual controls in one place';
if (this.activeView === 'tools') return 'Reserved space for merge, split, enrichment, and destructive workflows'; if (this.activeView === 'tools') return 'Reserved space for merge, split, enrichment, and destructive workflows';
if (this.activeView === 'settings') return 'Application configuration and external API credentials';
return 'Full-screen review triage with filter-aware bulk actions'; return 'Full-screen review triage with filter-aware bulk actions';
}, },
+14
View File
@@ -21,6 +21,11 @@ const T = {
audio: "{{ t.player_audio }}", audio: "{{ t.player_audio }}",
size: "{{ t.player_size }}", size: "{{ t.player_size }}",
uploader: "{{ t.player_uploader }}", uploader: "{{ t.player_uploader }}",
lastfmRating: "{{ t.player_lastfm_rating }}",
lastfmListeners: "{{ t.player_lastfm_listeners }}",
lastfmPlaycount: "{{ t.player_lastfm_playcount }}",
lastfmUpdated: "{{ t.player_lastfm_updated }}",
lastfmNotLoaded: "{{ t.player_lastfm_not_loaded }}",
trackWord: "{{ t.player_tracks_count }}", trackWord: "{{ t.player_tracks_count }}",
clientIdle: "{{ t.player_client_idle }}", clientIdle: "{{ t.player_client_idle }}",
active: "{{ t.player_active }}", active: "{{ t.player_active }}",
@@ -825,6 +830,15 @@ document.addEventListener('alpine:init', () => {
`${T.size}: ${this.bytes(track.file_size_bytes)}`, `${T.size}: ${this.bytes(track.file_size_bytes)}`,
`${T.uploader}: ${track.uploader_name || 'UFO'}`, `${T.uploader}: ${track.uploader_name || 'UFO'}`,
]; ];
if (track.lastfm_rating != null || track.lastfm_listeners != null || track.lastfm_playcount != null) {
const rating = Number(track.lastfm_rating || 0);
lines.push(`${T.lastfmRating}: ${Number.isFinite(rating) ? rating.toFixed(2) : T.unknown}`);
lines.push(`${T.lastfmListeners}: ${new Intl.NumberFormat().format(track.lastfm_listeners || 0)}`);
lines.push(`${T.lastfmPlaycount}: ${new Intl.NumberFormat().format(track.lastfm_playcount || 0)}`);
if (track.lastfm_updated_at) lines.push(`${T.lastfmUpdated}: ${track.lastfm_updated_at}`);
} else {
lines.push(`${T.lastfmRating}: ${T.lastfmNotLoaded}`);
}
return lines.join('\n'); return lines.join('\n');
}, },