diff --git a/Cargo.lock b/Cargo.lock index 3cd178c..19636a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1430,6 +1430,7 @@ dependencies = [ "id3", "image", "librqbit", + "md-5", "openidconnect", "reqwest", "schemars 0.9.0", diff --git a/Cargo.toml b/Cargo.toml index 6b0f7bc..d95da8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.1.20" +version = "0.1.21" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" @@ -20,6 +20,7 @@ symphonia = { version = "0.5", default-features = false, features = ["mp3","aac" id3 = "1" encoding_rs = "0.8" sha2 = "0.10" +md-5 = "0.10" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] } sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"] } anyhow = "1.0" diff --git a/src/admin/v2.rs b/src/admin/v2.rs index 9c571b5..6a2ffd0 100644 --- a/src/admin/v2.rs +++ b/src/admin/v2.rs @@ -260,6 +260,8 @@ struct AdminSettingsDto { values: AdminSettingsValues, sources: AdminSettingsSources, lastfm_api_key_configured: bool, + lastfm_shared_secret_configured: bool, + lastfm_scrobbling_configured: bool, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -274,6 +276,7 @@ struct AdminSettingsValues { oidc_user_groups: String, swagger_enabled: bool, lastfm_api_key: String, + lastfm_shared_secret: String, agent_enabled: bool, agent_inbox_dir: String, agent_storage_dir: String, @@ -297,6 +300,7 @@ struct AdminSettingsSources { oidc_user_groups: &'static str, swagger_enabled: &'static str, lastfm_api_key: &'static str, + lastfm_shared_secret: &'static str, agent_enabled: &'static str, agent_inbox_dir: &'static str, agent_storage_dir: &'static str, @@ -320,6 +324,7 @@ pub(super) struct UpdateSettingsRequest { oidc_user_groups: String, swagger_enabled: bool, lastfm_api_key: String, + lastfm_shared_secret: String, agent_enabled: bool, agent_inbox_dir: String, agent_storage_dir: String, @@ -709,6 +714,10 @@ pub async fn update_settings( ("oidc_user_groups", body.oidc_user_groups.trim().to_string()), ("swagger_enabled", body.swagger_enabled.to_string()), ("lastfm_api_key", body.lastfm_api_key.trim().to_string()), + ( + "lastfm_shared_secret", + body.lastfm_shared_secret.trim().to_string(), + ), ("agent_enabled", body.agent_enabled.to_string()), ("agent_inbox_dir", body.agent_inbox_dir.trim().to_string()), ( @@ -785,6 +794,9 @@ pub async fn settings_probe( fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto { AdminSettingsDto { lastfm_api_key_configured: !config.lastfm_api_key.trim().is_empty(), + lastfm_shared_secret_configured: !config.lastfm_shared_secret.trim().is_empty(), + lastfm_scrobbling_configured: !config.lastfm_api_key.trim().is_empty() + && !config.lastfm_shared_secret.trim().is_empty(), values: AdminSettingsValues { auth_password_enabled: config.auth_password_enabled, auth_sso_enabled: config.auth_sso_enabled, @@ -796,6 +808,7 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto { oidc_user_groups: config.oidc_user_groups, swagger_enabled: config.swagger_enabled, lastfm_api_key: config.lastfm_api_key, + lastfm_shared_secret: config.lastfm_shared_secret, agent_enabled: config.agent_enabled, agent_inbox_dir: config.agent_inbox_dir, agent_storage_dir: config.agent_storage_dir, @@ -817,6 +830,7 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto { oidc_user_groups: sources.oidc_user_groups.code(), swagger_enabled: sources.swagger_enabled.code(), lastfm_api_key: sources.lastfm_api_key.code(), + lastfm_shared_secret: sources.lastfm_shared_secret.code(), agent_enabled: sources.agent_enabled.code(), agent_inbox_dir: sources.agent_inbox_dir.code(), agent_storage_dir: sources.agent_storage_dir.code(), diff --git a/src/admin/views.rs b/src/admin/views.rs index b85602a..29c6622 100644 --- a/src/admin/views.rs +++ b/src/admin/views.rs @@ -184,6 +184,16 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec, agent_context_limit: Option, agent_concurrency: Option, + lastfm_api_key: Option, + lastfm_shared_secret: Option, } pub async fn settings_submit( @@ -380,7 +400,9 @@ pub async fn settings_submit( let agent_confidence_threshold = data.agent_confidence_threshold.unwrap_or_default(); let agent_context_limit = data.agent_context_limit.unwrap_or_default(); let agent_concurrency = data.agent_concurrency.unwrap_or_default(); - let fields: [(&str, &str); 18] = [ + let lastfm_api_key = data.lastfm_api_key.unwrap_or_default(); + let lastfm_shared_secret = data.lastfm_shared_secret.unwrap_or_default(); + let fields: [(&str, &str); 20] = [ ("auth_password_enabled", pw_enabled), ("auth_sso_enabled", sso_enabled), ("oidc_button_text", &oidc_button_text), @@ -399,6 +421,8 @@ pub async fn settings_submit( ("agent_confidence_threshold", &agent_confidence_threshold), ("agent_context_limit", &agent_context_limit), ("agent_concurrency", &agent_concurrency), + ("lastfm_api_key", &lastfm_api_key), + ("lastfm_shared_secret", &lastfm_shared_secret), ]; for (key, value) in fields { let mut entry = ConfigEntry::new(key.to_owned(), value.to_owned()); diff --git a/src/config.rs b/src/config.rs index 261e28a..dc2ddff 100644 --- a/src/config.rs +++ b/src/config.rs @@ -134,6 +134,7 @@ pub struct ConfigSources { pub agent_context_limit: ConfigSource, pub agent_concurrency: ConfigSource, pub lastfm_api_key: ConfigSource, + pub lastfm_shared_secret: ConfigSource, } impl Default for ConfigSources { @@ -160,6 +161,7 @@ impl Default for ConfigSources { agent_context_limit: ConfigSource::Default, agent_concurrency: ConfigSource::Default, lastfm_api_key: ConfigSource::Default, + lastfm_shared_secret: ConfigSource::Default, } } } @@ -266,6 +268,8 @@ pub struct AppConfig { pub agent_concurrency: u64, /// Last.fm API key for weekly popularity enrichment. pub lastfm_api_key: String, + /// Last.fm shared secret for authenticated scrobbling calls. + pub lastfm_shared_secret: String, } impl Default for AppConfig { @@ -292,6 +296,7 @@ impl Default for AppConfig { agent_context_limit: 8192, agent_concurrency: 2, lastfm_api_key: String::new(), + lastfm_shared_secret: String::new(), } } } @@ -319,6 +324,7 @@ impl_env_overrides!( agent_context_limit, agent_concurrency, lastfm_api_key, + lastfm_shared_secret, ); impl AppConfig { @@ -403,6 +409,7 @@ impl AppConfig { apply_db_field!(agent_context_limit); apply_db_field!(agent_concurrency); apply_db_field!(lastfm_api_key); + apply_db_field!(lastfm_shared_secret); } } diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index 7b1f72d..144a747 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -95,6 +95,10 @@ translations! { settings_api: "API" , "API"; settings_swagger: "Swagger UI" , "Swagger UI"; settings_swagger_help: "Serves interactive API docs at /swagger/ (requires restart)" , "Интерактивная документация API на /swagger/ (требуется перезапуск)"; + settings_lastfm_api_key: "Last.fm API key" , "API ключ Last.fm"; + settings_lastfm_api_key_help: "Used for Last.fm popularity and account connection" , "Используется для популярности Last.fm и подключения аккаунта"; + settings_lastfm_shared_secret: "Last.fm shared secret" , "Shared secret Last.fm"; + settings_lastfm_shared_secret_help: "Required for signed Last.fm scrobbling requests" , "Нужен для подписанных запросов скробблинга Last.fm"; // OIDC login errors login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз."; @@ -327,6 +331,14 @@ translations! { player_lastfm_playcount: "Last.fm plays" , "Прослушивания Last.fm"; player_lastfm_updated: "Last.fm updated" , "Last.fm обновлён"; player_lastfm_not_loaded: "not loaded yet" , "ещё не загружено"; + player_lastfm_profile: "Last.fm" , "Last.fm"; + player_lastfm_connect: "Connect Last.fm" , "Подключить Last.fm"; + player_lastfm_connected: "Connected as {user}" , "Подключён: {user}"; + player_lastfm_reconnect: "Reconnect Last.fm" , "Переподключить Last.fm"; + player_lastfm_not_configured: "Last.fm is not configured" , "Last.fm не настроен"; + player_lastfm_disconnect_confirm: "Disconnect Last.fm account {user}?" , "Отвязать аккаунт Last.fm {user}?"; + 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_like: "Like" , "Лайк"; player_add_to_queue: "Add to queue" , "Добавить в очередь"; diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs index 84cd0c9..e47e771 100644 --- a/src/jobs/mod.rs +++ b/src/jobs/mod.rs @@ -5,6 +5,7 @@ pub mod cover_variant_backfill; pub mod inbox_discover; pub mod inbox_process; pub mod lastfm_popularity; +pub mod lastfm_scrobble; pub mod metadata_backfill; use std::path::{Component, Path, PathBuf}; diff --git a/src/main.rs b/src/main.rs index d02c342..21ddac6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod auth; mod config; mod i18n; mod jobs; +mod lastfm; mod music; mod oidc; mod player; @@ -55,6 +56,7 @@ fn build_registry() -> Arc { registry.register(jobs::cover_variant_backfill::CoverVariantBackfillJob); registry.register(jobs::metadata_backfill::MetadataBackfillJob); registry.register(jobs::lastfm_popularity::LastfmPopularityJob); + registry.register(jobs::lastfm_scrobble::LastfmScrobbleJob); Arc::new(registry) } diff --git a/src/music/mod.rs b/src/music/mod.rs index f352445..8a13b44 100644 --- a/src/music/mod.rs +++ b/src/music/mod.rs @@ -1692,6 +1692,83 @@ pub mod db_migrations { &[Operation::custom(create_lastfm_track_popularity).build()]; } + // -- M0033: Last.fm scrobbling ----------------------------------------- + + #[cot::db::migrations::migration_op] + async fn create_lastfm_scrobbling( + ctx: migrations::MigrationContext<'_>, + ) -> cot::db::Result<()> { + ctx.db + .raw( + "CREATE TABLE IF NOT EXISTS furumusic__lastfm_account ( + user_id BIGINT PRIMARY KEY, + lastfm_username VARCHAR(255) NOT NULL, + session_key TEXT NOT NULL, + connected_at VARCHAR(32) NOT NULL, + updated_at VARCHAR(32) NOT NULL, + last_error TEXT, + reauth_required BOOLEAN NOT NULL DEFAULT FALSE + )", + ) + .await?; + ctx.db + .raw( + "CREATE TABLE IF NOT EXISTS furumusic__lastfm_auth_state ( + state VARCHAR(64) PRIMARY KEY, + user_id BIGINT NOT NULL, + created_at VARCHAR(32) NOT NULL + )", + ) + .await?; + ctx.db + .raw( + "CREATE TABLE IF NOT EXISTS furumusic__lastfm_scrobble_outbox ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + track_id BIGINT NOT NULL, + started_at BIGINT NOT NULL, + listened_seconds INTEGER NOT NULL, + duration_seconds INTEGER NOT NULL, + status VARCHAR(32) NOT NULL, + attempt_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + created_at VARCHAR(32) NOT NULL, + updated_at VARCHAR(32) NOT NULL, + scrobbled_at VARCHAR(32), + dedupe_key VARCHAR(128) NOT NULL UNIQUE + )", + ) + .await?; + ctx.db + .raw( + "CREATE INDEX IF NOT EXISTS idx_lastfm_scrobble_outbox_status + ON furumusic__lastfm_scrobble_outbox (status, created_at, id)", + ) + .await?; + ctx.db + .raw( + "CREATE INDEX IF NOT EXISTS idx_lastfm_scrobble_outbox_user + ON furumusic__lastfm_scrobble_outbox (user_id, status, created_at)", + ) + .await?; + Ok(()) + } + + #[derive(Debug, Copy, Clone)] + pub struct M0033CreateLastfmScrobbling; + + impl migrations::Migration for M0033CreateLastfmScrobbling { + const APP_NAME: &'static str = "furumusic"; + const MIGRATION_NAME: &'static str = "m_0033_create_lastfm_scrobbling"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "furumusic", + "m_0032_create_lastfm_track_popularity", + )]; + const OPERATIONS: &'static [Operation] = + &[Operation::custom(create_lastfm_scrobbling).build()]; + } + pub const MIGRATIONS: &[&SyncDynMigration] = &[ &M0006CreateMediaFile, &M0007CreateArtist, @@ -1715,5 +1792,6 @@ pub mod db_migrations { &M0030AddMediaFileUploader, &M0031CreateTorrentSession, &M0032CreateLastfmTrackPopularity, + &M0033CreateLastfmScrobbling, ]; } diff --git a/src/player/dto.rs b/src/player/dto.rs index 906d7ad..1df6fa5 100644 --- a/src/player/dto.rs +++ b/src/player/dto.rs @@ -172,6 +172,35 @@ pub(super) struct UserProfile { pub(super) stats: UserStats, } +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct LastfmStatus { + pub(super) configured: bool, + pub(super) connected: bool, + pub(super) username: Option, + pub(super) reauth_required: bool, + pub(super) last_error: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct LastfmActionResponse { + pub(super) ok: bool, + pub(super) queued: bool, + pub(super) sent: bool, + pub(super) message: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub(super) struct LastfmNowPlayingRequest { + pub(super) track_id: i64, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub(super) struct LastfmScrobbleRequest { + pub(super) track_id: i64, + pub(super) started_at: Option, + pub(super) listened_seconds: i32, +} + #[derive(Debug, Serialize, JsonSchema)] pub(super) struct AgentQueueStatus { pub(super) queued_count: i64, diff --git a/src/player/mod.rs b/src/player/mod.rs index 22237d1..1bf94c6 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -16,6 +16,7 @@ use cot::{App, Body, Template}; use crate::auth; use crate::config::AppConfig; use crate::i18n::Translations; +use crate::lastfm::{LastfmClient, LastfmCredentials, LastfmTrackPayload}; use crate::scheduler::SchedulerHandle; use crate::torrents::{TorrentPreviewRequest, TorrentService, TorrentStartRequest}; @@ -49,6 +50,36 @@ struct LocalUploadResponse { size: u64, } +#[derive(Debug, sqlx::FromRow)] +struct LastfmAccountApiRow { + session_key: String, + reauth_required: bool, + last_error: Option, +} + +#[derive(Debug, sqlx::FromRow)] +struct LastfmStatusRow { + username: String, + reauth_required: bool, + last_error: Option, +} + +#[derive(Debug, sqlx::FromRow)] +struct LastfmTrackMetaRow { + title: String, + duration_seconds: f64, + track_number: Option, + album_title: Option, + artist_name: Option, + album_artist_name: Option, +} + +#[derive(Debug, serde::Deserialize)] +struct LastfmCallbackQuery { + token: Option, + state: Option, +} + // --------------------------------------------------------------------------- // SPA shell // --------------------------------------------------------------------------- @@ -114,6 +145,493 @@ async fn me_handler( .into_response() } +// --------------------------------------------------------------------------- +// Last.fm account + scrobbling +// --------------------------------------------------------------------------- + +fn redirect_response(location: &str) -> cot::response::Response { + cot::http::Response::builder() + .status(StatusCode::SEE_OTHER) + .header(cot::http::header::LOCATION, location) + .body(Body::fixed("")) + .expect("valid response") +} + +fn request_origin(request: &cot::request::Request) -> Option { + let headers = request.headers(); + let host = headers + .get("x-forwarded-host") + .or_else(|| headers.get("host"))? + .to_str() + .ok()?; + let proto = headers + .get("x-forwarded-proto") + .and_then(|value| value.to_str().ok()) + .unwrap_or("http"); + Some(format!("{proto}://{host}")) +} + +async fn lastfm_status_handler( + session: Session, + db: Database, + pool: &sqlx::PgPool, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + let (config, _) = AppConfig::load_with_db(&db).await; + let configured = crate::lastfm::is_configured(&config); + let account = sqlx::query_as::<_, LastfmStatusRow>( + r#"SELECT lastfm_username::text AS username, + reauth_required, + last_error::text AS last_error + FROM furumusic__lastfm_account + WHERE user_id = $1"#, + ) + .bind(user.id) + .fetch_optional(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + Json(LastfmStatus { + configured, + connected: account.is_some(), + username: account.as_ref().map(|row| row.username.clone()), + reauth_required: account + .as_ref() + .map(|row| row.reauth_required) + .unwrap_or(false), + last_error: account.and_then(|row| row.last_error), + }) + .into_response() +} + +async fn lastfm_connect_handler( + session: Session, + db: Database, + pool: &sqlx::PgPool, + request: cot::request::Request, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(redirect_response("/login")); + }; + let (config, _) = AppConfig::load_with_db(&db).await; + let Some(credentials) = LastfmCredentials::from_config(&config) else { + return Ok(redirect_response("/?lastfm=not_configured")); + }; + let Some(origin) = request_origin(&request) else { + return Ok(redirect_response("/?lastfm=bad_origin")); + }; + + let state = uuid::Uuid::new_v4().simple().to_string(); + let now = chrono::Utc::now(); + let stale = (now - chrono::Duration::hours(1)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string(); + sqlx::query("DELETE FROM furumusic__lastfm_auth_state WHERE created_at < $1") + .bind(stale) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + sqlx::query( + r#"INSERT INTO furumusic__lastfm_auth_state (state, user_id, created_at) + VALUES ($1, $2, $3) + ON CONFLICT (state) DO NOTHING"#, + ) + .bind(&state) + .bind(user.id) + .bind(now.format("%Y-%m-%dT%H:%M:%SZ").to_string()) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + let callback = format!("{origin}/api/player/lastfm/callback?state={state}"); + let mut url = reqwest::Url::parse("https://www.last.fm/api/auth/") + .map_err(|e| cot::Error::internal(e.to_string()))?; + url.query_pairs_mut() + .append_pair("api_key", credentials.api_key()) + .append_pair("cb", &callback); + Ok(redirect_response(url.as_str())) +} + +async fn lastfm_callback_handler( + session: Session, + db: Database, + pool: &sqlx::PgPool, + query: cot::request::extractors::UrlQuery, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(redirect_response("/login")); + }; + let Some(token) = query + .0 + .token + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + else { + return Ok(redirect_response("/?lastfm=missing_token")); + }; + let Some(state) = query + .0 + .state + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + else { + return Ok(redirect_response("/?lastfm=missing_state")); + }; + + let state_user_id = sqlx::query_scalar::<_, i64>( + "SELECT user_id FROM furumusic__lastfm_auth_state WHERE state = $1", + ) + .bind(state) + .fetch_optional(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + if state_user_id != Some(user.id) { + return Ok(redirect_response("/?lastfm=bad_state")); + } + sqlx::query("DELETE FROM furumusic__lastfm_auth_state WHERE state = $1") + .bind(state) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + let (config, _) = AppConfig::load_with_db(&db).await; + let Some(credentials) = LastfmCredentials::from_config(&config) else { + return Ok(redirect_response("/?lastfm=not_configured")); + }; + let client = LastfmClient::new(credentials).map_err(|e| cot::Error::internal(e.to_string()))?; + match client.get_session(token).await { + Ok(lastfm_session) => { + let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + sqlx::query( + r#"INSERT INTO furumusic__lastfm_account + (user_id, lastfm_username, session_key, connected_at, updated_at, last_error, reauth_required) + VALUES ($1, $2, $3, $4, $4, NULL, false) + ON CONFLICT (user_id) DO UPDATE SET + lastfm_username = EXCLUDED.lastfm_username, + session_key = EXCLUDED.session_key, + updated_at = EXCLUDED.updated_at, + last_error = NULL, + reauth_required = false"#, + ) + .bind(user.id) + .bind(&lastfm_session.username) + .bind(&lastfm_session.session_key) + .bind(&now) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + Ok(redirect_response("/?lastfm=connected")) + } + Err(err) => { + tracing::warn!("Last.fm auth failed for user {}: {err}", user.id); + Ok(redirect_response("/?lastfm=auth_failed")) + } + } +} + +async fn lastfm_disconnect_handler( + session: Session, + db: Database, + pool: &sqlx::PgPool, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + sqlx::query("DELETE FROM furumusic__lastfm_account WHERE user_id = $1") + .bind(user.id) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + sqlx::query( + r#"UPDATE furumusic__lastfm_scrobble_outbox + SET status = 'blocked', + last_error = 'Last.fm account disconnected', + updated_at = $2 + WHERE user_id = $1 AND status IN ('pending', 'retry')"#, + ) + .bind(user.id) + .bind(now) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + Json(serde_json::json!({"ok": true})).into_response() +} + +async fn load_lastfm_account( + pool: &sqlx::PgPool, + user_id: i64, +) -> cot::Result> { + sqlx::query_as::<_, LastfmAccountApiRow>( + r#"SELECT session_key::text AS session_key, + reauth_required, + last_error::text AS last_error + FROM furumusic__lastfm_account + WHERE user_id = $1"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string())) +} + +async fn load_lastfm_track_payload( + pool: &sqlx::PgPool, + track_id: i64, +) -> cot::Result> { + let row = sqlx::query_as::<_, LastfmTrackMetaRow>( + r#"SELECT t.title::text AS title, + t.duration_seconds, + t.track_number, + r.title::text AS album_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, + ( + 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 album_artist_name + FROM furumusic__track t + LEFT JOIN furumusic__release r ON r.id = t.release_id + WHERE t.id = $1 AND t.is_hidden = false"#, + ) + .bind(track_id) + .fetch_optional(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + Ok(row.and_then(|row| { + let artist = row + .artist_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(); + Some(LastfmTrackPayload { + artist, + track: row.title, + album: row + .album_title + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + album_artist: row + .album_artist_name + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + track_number: row.track_number, + duration_seconds: Some(row.duration_seconds.round() as i32), + }) + })) +} + +async fn update_lastfm_account_error( + pool: &sqlx::PgPool, + user_id: i64, + error: &str, + reauth_required: bool, +) -> cot::Result<()> { + sqlx::query( + r#"UPDATE furumusic__lastfm_account + SET last_error = $2, + reauth_required = $3, + updated_at = $4 + WHERE user_id = $1"#, + ) + .bind(user_id) + .bind(error) + .bind(reauth_required) + .bind(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + Ok(()) +} + +async fn lastfm_now_playing_handler( + session: Session, + db: Database, + pool: &sqlx::PgPool, + Json(entry): Json, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + let (config, _) = AppConfig::load_with_db(&db).await; + let Some(credentials) = LastfmCredentials::from_config(&config) else { + return Json(LastfmActionResponse { + ok: false, + queued: false, + sent: false, + message: Some("Last.fm is not configured".to_string()), + }) + .into_response(); + }; + let Some(account) = load_lastfm_account(pool, user.id).await? else { + return Json(LastfmActionResponse { + ok: false, + queued: false, + sent: false, + message: Some("Last.fm account is not connected".to_string()), + }) + .into_response(); + }; + if account.reauth_required { + return Json(LastfmActionResponse { + ok: false, + queued: false, + sent: false, + message: account.last_error, + }) + .into_response(); + } + let Some(track) = load_lastfm_track_payload(pool, entry.track_id).await? else { + return Json(LastfmActionResponse { + ok: false, + queued: false, + sent: false, + message: Some("Track has no primary artist for Last.fm".to_string()), + }) + .into_response(); + }; + let client = LastfmClient::new(credentials).map_err(|e| cot::Error::internal(e.to_string()))?; + match client + .update_now_playing(&account.session_key, &track) + .await + { + Ok(()) => Json(LastfmActionResponse { + ok: true, + queued: false, + sent: true, + message: None, + }) + .into_response(), + Err(err) => { + let reauth_required = err.is_invalid_session(); + update_lastfm_account_error(pool, user.id, &err.to_string(), reauth_required).await?; + Json(LastfmActionResponse { + ok: false, + queued: false, + sent: false, + message: Some(err.to_string()), + }) + .into_response() + } + } +} + +async fn lastfm_scrobble_handler( + session: Session, + db: Database, + pool: &sqlx::PgPool, + Json(entry): Json, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + let (config, _) = AppConfig::load_with_db(&db).await; + if !crate::lastfm::is_configured(&config) { + return Json(LastfmActionResponse { + ok: false, + queued: false, + sent: false, + message: Some("Last.fm is not configured".to_string()), + }) + .into_response(); + } + if load_lastfm_account(pool, user.id).await?.is_none() { + return Json(LastfmActionResponse { + ok: false, + queued: false, + sent: false, + message: Some("Last.fm account is not connected".to_string()), + }) + .into_response(); + } + let Some(track) = load_lastfm_track_payload(pool, entry.track_id).await? else { + return Json(LastfmActionResponse { + ok: false, + queued: false, + sent: false, + message: Some("Track has no primary artist for Last.fm".to_string()), + }) + .into_response(); + }; + let duration_seconds = track.duration_seconds.unwrap_or(0).max(0); + if duration_seconds <= 30 { + return Json(LastfmActionResponse { + ok: false, + queued: false, + sent: false, + message: Some("Track is too short to scrobble".to_string()), + }) + .into_response(); + } + let threshold = ((duration_seconds as f64 / 2.0).min(240.0)).ceil() as i32; + let listened_seconds = entry.listened_seconds.max(0); + if listened_seconds < threshold { + return Json(LastfmActionResponse { + ok: false, + queued: false, + sent: false, + message: Some("Scrobble threshold has not been reached".to_string()), + }) + .into_response(); + } + + let now_ts = chrono::Utc::now().timestamp(); + let started_at = entry + .started_at + .unwrap_or(now_ts - listened_seconds as i64) + .min(now_ts); + let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + let dedupe_key = format!("{}:{}:{}", user.id, entry.track_id, started_at); + sqlx::query( + r#"INSERT INTO furumusic__lastfm_scrobble_outbox + (user_id, track_id, started_at, listened_seconds, duration_seconds, status, created_at, updated_at, dedupe_key) + VALUES ($1, $2, $3, $4, $5, 'pending', $6, $6, $7) + ON CONFLICT (dedupe_key) DO NOTHING"#, + ) + .bind(user.id) + .bind(entry.track_id) + .bind(started_at) + .bind(listened_seconds) + .bind(duration_seconds) + .bind(&now) + .bind(&dedupe_key) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + let sent = + match crate::lastfm::process_pending_scrobbles(pool, &config, Some(user.id), 10).await { + Ok(summary) => summary.sent > 0, + Err(err) => { + tracing::warn!("Last.fm immediate scrobble send failed: {err:#}"); + false + } + }; + Json(LastfmActionResponse { + ok: true, + queued: true, + sent, + message: None, + }) + .into_response() +} + // --------------------------------------------------------------------------- // GET /api/player/agent-queue // --------------------------------------------------------------------------- @@ -2437,6 +2955,152 @@ impl App for PlayerApp { }, "player_me", ), + Route::with_handler_and_name( + "/lastfm/status", + get({ + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + move |session: Session, db: Database| { + 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; + lastfm_status_handler(session, db, pg_pool).await + } + } + }), + "player_lastfm_status", + ), + Route::with_handler_and_name( + "/lastfm/connect", + get({ + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + move |session: Session, db: Database, request: cot::request::Request| { + 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; + lastfm_connect_handler(session, db, pg_pool, request).await + } + } + }), + "player_lastfm_connect", + ), + Route::with_handler_and_name( + "/lastfm/callback", + get({ + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + move |session: Session, + db: Database, + query: cot::request::extractors::UrlQuery| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + lastfm_callback_handler(session, db, pg_pool, query).await + } + } + }), + "player_lastfm_callback", + ), + Route::with_handler_and_name( + "/lastfm/disconnect", + post({ + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + move |session: Session, db: Database| { + 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; + lastfm_disconnect_handler(session, db, pg_pool).await + } + } + }), + "player_lastfm_disconnect", + ), + Route::with_handler_and_name( + "/lastfm/now-playing", + post({ + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + move |session: Session, db: Database, json: Json| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + lastfm_now_playing_handler(session, db, pg_pool, json).await + } + } + }), + "player_lastfm_now_playing", + ), + Route::with_handler_and_name( + "/lastfm/scrobble", + post({ + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + move |session: Session, db: Database, json: Json| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + lastfm_scrobble_handler(session, db, pg_pool, json).await + } + } + }), + "player_lastfm_scrobble", + ), Route::with_handler_and_name( "/agent-queue", { diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs index 29feea8..e94a8cc 100644 --- a/src/scheduler/mod.rs +++ b/src/scheduler/mod.rs @@ -1357,7 +1357,10 @@ 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 && job_name != "lastfm_popularity" { + if !live_config.agent_enabled + && job_name != "lastfm_popularity" + && job_name != "lastfm_scrobble" + { tracing::warn!(job = job_name, "Skipping: agent_enabled=false"); return; } diff --git a/templates/admin/settings.html b/templates/admin/settings.html index 811ce83..0a2c3aa 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -85,6 +85,16 @@ {{ swagger_enabled_source }} + +
{{ t.settings_lastfm_api_key_help }} + + {{ lastfm_api_key_source }} + + +
{{ t.settings_lastfm_shared_secret_help }} + + {{ lastfm_shared_secret_source }} +

{{ t.settings_agent }}

diff --git a/templates/admin/v2.html b/templates/admin/v2.html index f5ba9d0..d8594c6 100644 --- a/templates/admin/v2.html +++ b/templates/admin/v2.html @@ -1136,7 +1136,7 @@ tbody tr:hover { @@ -1734,7 +1734,7 @@ tbody tr:hover { API Developer and enrichment integrations - +
@@ -1750,11 +1750,19 @@ tbody tr:hover {
-
Used by the weekly Last.fm popularity task.
+
{{ t.settings_lastfm_api_key_help }}
+
+
+ + +
{{ t.settings_lastfm_shared_secret_help }}
@@ -2093,7 +2101,7 @@ function adminV2() { editorArtistToAdd: '', editorDetail: null, editorDraft: { title: '', hidden: 'false', release_type: 'album', year: '', artist_ids: [] }, - settings: { values: {}, sources: {}, lastfm_api_key_configured: false }, + settings: { values: {}, sources: {}, lastfm_api_key_configured: false, lastfm_shared_secret_configured: false, lastfm_scrobbling_configured: false }, settingsDraft: { auth_password_enabled: false, auth_sso_enabled: false, @@ -2105,6 +2113,7 @@ function adminV2() { oidc_user_groups: '', swagger_enabled: false, lastfm_api_key: '', + lastfm_shared_secret: '', agent_enabled: false, agent_inbox_dir: '', agent_storage_dir: '', diff --git a/templates/player/scripts.html b/templates/player/scripts.html index 39f004a..dee2ebd 100644 --- a/templates/player/scripts.html +++ b/templates/player/scripts.html @@ -30,6 +30,14 @@ const T = { lastfmPlaycount: "{{ t.player_lastfm_playcount }}", lastfmUpdated: "{{ t.player_lastfm_updated }}", lastfmNotLoaded: "{{ t.player_lastfm_not_loaded }}", + lastfmProfile: "{{ t.player_lastfm_profile }}", + lastfmConnect: "{{ t.player_lastfm_connect }}", + lastfmConnected: "{{ t.player_lastfm_connected }}", + lastfmReconnect: "{{ t.player_lastfm_reconnect }}", + lastfmNotConfigured: "{{ t.player_lastfm_not_configured }}", + lastfmDisconnectConfirm: "{{ t.player_lastfm_disconnect_confirm }}", + lastfmConnectFailed: "{{ t.player_lastfm_connect_failed }}", + lastfmDisconnectFailed: "{{ t.player_lastfm_disconnect_failed }}", trackWord: "{{ t.player_tracks_count }}", clientIdle: "{{ t.player_client_idle }}", active: "{{ t.player_active }}", @@ -162,9 +170,12 @@ document.addEventListener('alpine:init', () => { Alpine.store('user', { profile: null, menuOpen: false, + lastfm: { configured: false, connected: false, username: null, reauth_required: false, last_error: null }, + lastfmBusy: false, init() { this.load(); + this.loadLastfm(); }, async load() { @@ -177,6 +188,56 @@ document.addEventListener('alpine:init', () => { } }, + async loadLastfm() { + try { + const res = await fetch('/api/player/lastfm/status'); + if (!res.ok) throw new Error('failed'); + this.lastfm = await res.json(); + const player = Alpine.store('player'); + if (player?.isPlaying) player._sendNowPlaying(); + } catch { + this.lastfm = { configured: false, connected: false, username: null, reauth_required: false, last_error: null }; + } + }, + + lastfmLabel() { + if (!this.lastfm?.configured) return T.lastfmNotConfigured; + if (this.lastfm?.connected && this.lastfm?.reauth_required) return T.lastfmReconnect; + if (this.lastfm?.connected) { + const user = this.lastfm.username || T.unknown; + return T.lastfmConnected.replace('{user}', user); + } + return T.lastfmConnect; + }, + + lastfmClass() { + if (!this.lastfm?.configured) return 'not-configured'; + if (this.lastfm?.connected && this.lastfm?.reauth_required) return 'needs-auth'; + if (this.lastfm?.connected) return 'connected'; + return 'available'; + }, + + async handleLastfm() { + if (this.lastfmBusy) return; + if (!this.lastfm?.configured) return; + if (!this.lastfm?.connected || this.lastfm?.reauth_required) { + window.location.href = '/api/player/lastfm/connect'; + return; + } + const user = this.lastfm.username || T.unknown; + if (!window.confirm(T.lastfmDisconnectConfirm.replace('{user}', user))) return; + this.lastfmBusy = true; + try { + const res = await fetch('/api/player/lastfm/disconnect', { method: 'POST' }); + if (!res.ok) throw new Error(T.lastfmDisconnectFailed); + await this.loadLastfm(); + } catch { + alert(T.lastfmDisconnectFailed); + } finally { + this.lastfmBusy = false; + } + }, + initials() { const name = this.profile?.name || ''; return name.trim().charAt(0) || '?'; @@ -294,6 +355,11 @@ document.addEventListener('alpine:init', () => { progress: 0, _saveTimer: null, _historyRecorded: false, + _nowPlayingSent: false, + _scrobbleSent: false, + _playbackStartedAt: null, + _listenedSeconds: 0, + _lastAudioTime: 0, init() { audio.volume = this.volume; @@ -302,6 +368,8 @@ document.addEventListener('alpine:init', () => { this.currentTime = audio.currentTime; this.duration = audio.duration || 0; this.progress = this.duration > 0 ? (this.currentTime / this.duration) * 100 : 0; + this._trackListenedDelta(); + this._maybeScrobble(); }); audio.addEventListener('ended', () => { @@ -309,8 +377,16 @@ document.addEventListener('alpine:init', () => { this.next(); }); - audio.addEventListener('play', () => { this.isPlaying = true; }); - audio.addEventListener('pause', () => { this.isPlaying = false; }); + audio.addEventListener('play', () => { + this.isPlaying = true; + if (!this._playbackStartedAt) this._playbackStartedAt = Math.floor(Date.now() / 1000); + this._lastAudioTime = audio.currentTime || 0; + this._sendNowPlaying(); + }); + audio.addEventListener('pause', () => { + this.isPlaying = false; + this._lastAudioTime = audio.currentTime || 0; + }); audio.addEventListener('loadedmetadata', () => { this.duration = audio.duration || 0; @@ -333,6 +409,7 @@ document.addEventListener('alpine:init', () => { play(track) { this.currentTrack = track; this._historyRecorded = false; + this._resetPlaybackTracking(); audio.src = track.stream_url; audio.play().catch(() => {}); this._updateMediaSession(); @@ -349,11 +426,13 @@ document.addEventListener('alpine:init', () => { seek(time) { audio.currentTime = time; + this._lastAudioTime = audio.currentTime || 0; }, seekRelative(delta) { if (!this.currentTrack) return; audio.currentTime = Math.max(0, Math.min(audio.duration || 0, audio.currentTime + delta)); + this._lastAudioTime = audio.currentTime || 0; }, seekFromClick(event) { @@ -541,6 +620,7 @@ document.addEventListener('alpine:init', () => { if (currentTrack) { this.currentTrack = currentTrack; this._historyRecorded = false; + this._resetPlaybackTracking(); audio.src = currentTrack.stream_url; // Seek to saved position once metadata is loaded const seekMs = state.position_ms || 0; @@ -573,6 +653,68 @@ document.addEventListener('alpine:init', () => { }), }).catch(() => {}); }, + + _resetPlaybackTracking() { + this._nowPlayingSent = false; + this._scrobbleSent = false; + this._playbackStartedAt = null; + this._listenedSeconds = 0; + this._lastAudioTime = 0; + }, + + _trackListenedDelta() { + if (!this.currentTrack) return; + const current = audio.currentTime || 0; + if (!this.isPlaying) { + this._lastAudioTime = current; + return; + } + const previous = Number.isFinite(this._lastAudioTime) ? this._lastAudioTime : current; + const delta = current - previous; + if (delta > 0 && delta < 5) { + this._listenedSeconds += delta; + } + this._lastAudioTime = current; + }, + + _sendNowPlaying() { + if (this._nowPlayingSent || !this.currentTrack) return; + const lastfm = Alpine.store('user')?.lastfm; + if (!lastfm?.configured || !lastfm?.connected || lastfm?.reauth_required) return; + this._nowPlayingSent = true; + fetch('/api/player/lastfm/now-playing', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ track_id: this.currentTrack.id }), + }).catch(() => {}); + }, + + _maybeScrobble() { + if (this._scrobbleSent || !this.currentTrack) return; + const lastfm = Alpine.store('user')?.lastfm; + if (!lastfm?.configured || !lastfm?.connected || lastfm?.reauth_required) return; + const duration = Number(this.duration || audio.duration || this.currentTrack.duration_seconds || 0); + if (!duration || duration <= 30) return; + const threshold = Math.min(duration / 2, 240); + if (this._listenedSeconds < threshold) return; + this._sendScrobble(); + }, + + _sendScrobble() { + if (this._scrobbleSent || !this.currentTrack) return; + this._scrobbleSent = true; + fetch('/api/player/lastfm/scrobble', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + track_id: this.currentTrack.id, + started_at: this._playbackStartedAt || Math.floor(Date.now() / 1000), + listened_seconds: Math.floor(this._listenedSeconds), + }), + }).catch(() => { + this._scrobbleSent = false; + }); + }, }); // ----------------------------------------------------------------------- diff --git a/templates/player/shell.html b/templates/player/shell.html index 1029572..623b041 100644 --- a/templates/player/shell.html +++ b/templates/player/shell.html @@ -38,6 +38,13 @@ {{ t.player_listened }} + +