Added offline mode support
Build and Publish / Build and Publish Docker Image (push) Successful in 3m46s

This commit is contained in:
Ultradesu
2026-07-15 18:13:40 +03:00
parent 716da908c9
commit 184371afca
6 changed files with 316 additions and 8 deletions
Generated
+1 -1
View File
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]] [[package]]
name = "furumusic" name = "furumusic"
version = "0.4.8" version = "0.5.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "furumusic" name = "furumusic"
version = "0.4.8" version = "0.5.0"
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"
+1
View File
@@ -883,6 +883,7 @@ const KNOWN_HTTP_ROUTES: &[&str] = &[
"/api/player/lastfm/now-playing", "/api/player/lastfm/now-playing",
"/api/player/lastfm/scrobble", "/api/player/lastfm/scrobble",
"/api/player/agent-queue", "/api/player/agent-queue",
"/api/player/offline/manifest",
"/api/player/torrents", "/api/player/torrents",
"/api/player/torrents/session/{id}", "/api/player/torrents/session/{id}",
"/api/player/torrents/preview", "/api/player/torrents/preview",
+39
View File
@@ -325,6 +325,45 @@ pub(super) struct UserProfile {
pub(super) stats: UserStats, pub(super) stats: UserStats,
} }
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct OfflineManifestResponse {
pub(super) generated_at: String,
pub(super) tracks: Vec<OfflineTrackManifestItem>,
pub(super) playlists: Vec<OfflinePlaylistManifestItem>,
pub(super) liked_track_ids: Vec<i64>,
pub(super) followed_artist_ids: Vec<i64>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct OfflineTrackManifestItem {
pub(super) id: i64,
pub(super) updated_at: String,
pub(super) stream_url: String,
pub(super) audio_file_id: i64,
pub(super) audio_hash: String,
pub(super) audio_size_bytes: i64,
pub(super) audio_mime_type: String,
pub(super) audio_updated_at: String,
pub(super) cover_file_id: Option<i64>,
pub(super) cover_url: Option<String>,
pub(super) cover_hash: Option<String>,
pub(super) cover_updated_at: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct OfflinePlaylistManifestItem {
pub(super) id: i64,
pub(super) title: String,
pub(super) description: Option<String>,
pub(super) updated_at: String,
pub(super) is_own: bool,
pub(super) owner_name: Option<String>,
pub(super) is_public: bool,
pub(super) is_saved: bool,
pub(super) kind: String,
pub(super) track_ids: Vec<i64>,
}
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub(super) struct LastfmStatus { pub(super) struct LastfmStatus {
pub(super) configured: bool, pub(super) configured: bool,
+245 -6
View File
@@ -1048,6 +1048,192 @@ async fn me_handler(
.into_response() .into_response()
} }
// ---------------------------------------------------------------------------
// GET /api/player/offline/manifest
// ---------------------------------------------------------------------------
async fn offline_manifest_handler(
auth_ctx: auth::AuthContext,
session: Session,
db: Database,
pool: &sqlx::PgPool,
) -> cot::Result<cot::response::Response> {
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
};
let generated_at = now_iso_string();
let track_rows = sqlx::query_as::<_, OfflineTrackManifestRow>(
r#"SELECT t.id,
GREATEST(
t.updated_at::text,
r.updated_at::text,
mf.created_at::text,
COALESCE(cover_mf.created_at::text, ''),
COALESCE((
SELECT MAX(a.updated_at::text)
FROM furumusic__track_artist ta
JOIN furumusic__artist a ON a.id = ta.artist_id
WHERE ta.track_id = t.id
), ''),
COALESCE((
SELECT MAX(a.updated_at::text)
FROM furumusic__release_artist ra
JOIN furumusic__artist a ON a.id = ra.artist_id
WHERE ra.release_id = r.id
), '')
) AS updated_at,
mf.id AS audio_file_id,
mf.sha256_hash::text AS audio_hash,
mf.file_size_bytes AS audio_size_bytes,
mf.mime_type::text AS audio_mime_type,
mf.created_at::text AS audio_updated_at,
cover_mf.id AS cover_file_id,
cover_mf.sha256_hash::text AS cover_hash,
cover_mf.created_at::text AS cover_updated_at
FROM furumusic__track t
JOIN furumusic__release r ON r.id = t.release_id
JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
LEFT JOIN furumusic__media_file cover_mf
ON cover_mf.id = COALESCE(t.cover_file_id, r.cover_file_id)
WHERE t.is_hidden = false AND r.is_hidden = false
ORDER BY t.id"#,
)
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let liked_track_ids = sqlx::query_scalar::<_, i64>(
r#"SELECT ult.track_id
FROM furumusic__user_liked_track ult
JOIN furumusic__track t ON t.id = ult.track_id AND t.is_hidden = false
JOIN furumusic__release r ON r.id = t.release_id AND r.is_hidden = false
WHERE ult.user_id = $1
ORDER BY ult.created_at DESC"#,
)
.bind(user.id)
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let likes_updated_at = sqlx::query_scalar::<_, Option<String>>(
"SELECT MAX(created_at::text) FROM furumusic__user_liked_track WHERE user_id = $1",
)
.bind(user.id)
.fetch_one(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?
.unwrap_or_else(|| generated_at.clone());
let playlist_rows = sqlx::query_as::<_, OfflinePlaylistManifestRow>(
r#"SELECT p.id,
p.title::text AS title,
p.description,
p.updated_at::text AS updated_at,
(p.owner_id = $1) AS is_own,
COALESCE(NULLIF(u.display_name, ''), u.username)::text AS owner_name,
p.is_public,
EXISTS (
SELECT 1 FROM furumusic__saved_playlist sp
WHERE sp.user_id = $1 AND sp.playlist_id = p.id
) AS is_saved,
COALESCE(
array_agg(pt.track_id ORDER BY pt.position)
FILTER (WHERE t.id IS NOT NULL AND r.id IS NOT NULL),
ARRAY[]::bigint[]
) AS track_ids
FROM furumusic__playlist p
JOIN furumusic__user u ON u.id = p.owner_id
LEFT JOIN furumusic__playlist_track pt ON pt.playlist_id = p.id
LEFT JOIN furumusic__track t ON t.id = pt.track_id AND t.is_hidden = false
LEFT JOIN furumusic__release r ON r.id = t.release_id AND r.is_hidden = false
WHERE p.owner_id = $1
OR EXISTS (
SELECT 1 FROM furumusic__saved_playlist sp
WHERE sp.user_id = $1 AND sp.playlist_id = p.id
)
OR p.is_public = true
GROUP BY p.id, u.display_name, u.username
ORDER BY
CASE WHEN p.owner_id = $1 THEN 0 WHEN p.is_public THEN 2 ELSE 1 END,
p.title"#,
)
.bind(user.id)
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let followed_artist_ids = sqlx::query_scalar::<_, i64>(
r#"SELECT ufa.artist_id
FROM furumusic__user_followed_artist ufa
JOIN furumusic__artist a ON a.id = ufa.artist_id AND a.is_hidden = false
WHERE ufa.user_id = $1
ORDER BY ufa.created_at DESC"#,
)
.bind(user.id)
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let tracks = track_rows
.into_iter()
.map(|row| OfflineTrackManifestItem {
id: row.id,
updated_at: row.updated_at,
stream_url: format!("/api/player/stream/{}", row.id),
audio_file_id: row.audio_file_id,
audio_hash: row.audio_hash,
audio_size_bytes: row.audio_size_bytes,
audio_mime_type: row.audio_mime_type,
audio_updated_at: row.audio_updated_at,
cover_file_id: row.cover_file_id,
cover_url: cover_variant_url(row.cover_file_id, "medium"),
cover_hash: row.cover_hash,
cover_updated_at: row.cover_updated_at,
})
.collect();
let mut playlists = vec![OfflinePlaylistManifestItem {
id: -1,
title: "Likes".to_string(),
description: None,
updated_at: likes_updated_at,
is_own: true,
owner_name: None,
is_public: false,
is_saved: false,
kind: "likes".to_string(),
track_ids: liked_track_ids.clone(),
}];
playlists.extend(
playlist_rows
.into_iter()
.map(|row| OfflinePlaylistManifestItem {
id: row.id,
title: row.title,
description: row.description,
updated_at: row.updated_at,
is_own: row.is_own,
owner_name: Some(row.owner_name),
is_public: row.is_public,
is_saved: row.is_saved,
kind: "user".to_string(),
track_ids: row.track_ids,
}),
);
Json(OfflineManifestResponse {
generated_at,
tracks,
playlists,
liked_track_ids,
followed_artist_ids,
})
.into_response()
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Last.fm account + scrobbling // Last.fm account + scrobbling
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -3855,7 +4041,11 @@ async fn stream_handler(
// Look up track → audio_file_id → MediaFile // Look up track → audio_file_id → MediaFile
let media = sqlx::query_as::<_, MediaFileRow>( let media = sqlx::query_as::<_, MediaFileRow>(
r#"SELECT mf.file_path, mf.mime_type::text as mime_type, mf.file_size_bytes r#"SELECT mf.file_path,
mf.mime_type::text as mime_type,
mf.file_size_bytes,
mf.sha256_hash::text AS sha256_hash,
mf.created_at::text AS created_at
FROM furumusic__track t FROM furumusic__track t
JOIN furumusic__media_file mf ON mf.id = t.audio_file_id JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
WHERE t.id = $1"#, WHERE t.id = $1"#,
@@ -3880,6 +4070,7 @@ async fn stream_handler(
} }
let file_size = media.file_size_bytes as u64; let file_size = media.file_size_bytes as u64;
let etag = media_etag("audio", track_id, &media.sha256_hash, None);
// Parse Range header // Parse Range header
let range_header = request.headers().get(RANGE).and_then(|v| v.to_str().ok()); let range_header = request.headers().get(RANGE).and_then(|v| v.to_str().ok());
@@ -3899,6 +4090,9 @@ async fn stream_handler(
.header(ACCEPT_RANGES, "bytes") .header(ACCEPT_RANGES, "bytes")
.header(CONTENT_RANGE, format!("bytes {start}-{end}/{file_size}")) .header(CONTENT_RANGE, format!("bytes {start}-{end}/{file_size}"))
.header(CONTENT_LENGTH, chunk_size.to_string()) .header(CONTENT_LENGTH, chunk_size.to_string())
.header("ETag", etag.as_str())
.header("X-Furumi-Content-Sha256", media.sha256_hash.as_str())
.header("X-Furumi-Content-Updated-At", media.created_at.as_str())
.body(Body::fixed(data)) .body(Body::fixed(data))
.expect("valid response"); .expect("valid response");
@@ -3917,6 +4111,9 @@ async fn stream_handler(
.header(CONTENT_TYPE, media.mime_type.as_str()) .header(CONTENT_TYPE, media.mime_type.as_str())
.header(ACCEPT_RANGES, "bytes") .header(ACCEPT_RANGES, "bytes")
.header(CONTENT_LENGTH, file_size.to_string()) .header(CONTENT_LENGTH, file_size.to_string())
.header("ETag", etag.as_str())
.header("X-Furumi-Content-Sha256", media.sha256_hash.as_str())
.header("X-Furumi-Content-Updated-At", media.created_at.as_str())
.body(Body::fixed(data)) .body(Body::fixed(data))
.expect("valid response"); .expect("valid response");
@@ -4167,7 +4364,12 @@ async fn cover_response(
}; };
let media = sqlx::query_as::<_, MediaFileRow>( let media = sqlx::query_as::<_, MediaFileRow>(
"SELECT file_path, mime_type::text as mime_type, file_size_bytes FROM furumusic__media_file WHERE id = $1", r#"SELECT file_path,
mime_type::text as mime_type,
file_size_bytes,
sha256_hash::text AS sha256_hash,
created_at::text AS created_at
FROM furumusic__media_file WHERE id = $1"#,
) )
.bind(media_file_id) .bind(media_file_id)
.fetch_optional(pool) .fetch_optional(pool)
@@ -4185,26 +4387,30 @@ async fn cover_response(
return Ok(json_error(StatusCode::NOT_FOUND, "file not found on disk")); return Ok(json_error(StatusCode::NOT_FOUND, "file not found on disk"));
} }
let (response_path, content_type) = variant_name let (response_path, content_type, etag_variant) = variant_name
.and_then(crate::agent::cover_variants::variant_by_name) .and_then(crate::agent::cover_variants::variant_by_name)
.map(|variant| { .map(|variant| {
let variant_path = crate::agent::cover_variants::variant_path(&full_path, variant); let variant_path = crate::agent::cover_variants::variant_path(&full_path, variant);
if variant_path.exists() { if variant_path.exists() {
(variant_path, "image/jpeg") (variant_path, "image/jpeg", Some(variant.name))
} else { } else {
(full_path.clone(), media.mime_type.as_str()) (full_path.clone(), media.mime_type.as_str(), None)
} }
}) })
.unwrap_or_else(|| (full_path.clone(), media.mime_type.as_str())); .unwrap_or_else(|| (full_path.clone(), media.mime_type.as_str(), None));
let data = tokio::fs::read(&response_path) let data = tokio::fs::read(&response_path)
.await .await
.map_err(|e| cot::Error::internal(e.to_string()))?; .map_err(|e| cot::Error::internal(e.to_string()))?;
let etag = media_etag("cover", media_file_id, &media.sha256_hash, etag_variant);
let response = cot::http::Response::builder() let response = cot::http::Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.header(CONTENT_TYPE, content_type) .header(CONTENT_TYPE, content_type)
.header(CONTENT_LENGTH, data.len().to_string()) .header(CONTENT_LENGTH, data.len().to_string())
.header("ETag", etag.as_str())
.header("X-Furumi-Content-Sha256", media.sha256_hash.as_str())
.header("X-Furumi-Content-Updated-At", media.created_at.as_str())
.header("Cache-Control", "public, max-age=86400") .header("Cache-Control", "public, max-age=86400")
.body(Body::fixed(data)) .body(Body::fixed(data))
.expect("valid response"); .expect("valid response");
@@ -4212,6 +4418,13 @@ async fn cover_response(
Ok(response) Ok(response)
} }
fn media_etag(kind: &str, id: i64, sha256_hash: &str, variant: Option<&str>) -> String {
match variant {
Some(variant) => format!("\"{kind}-{id}-{variant}-{sha256_hash}\""),
None => format!("\"{kind}-{id}-{sha256_hash}\""),
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Player devices // Player devices
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -6271,6 +6484,32 @@ impl App for PlayerApp {
}, },
"player_me", "player_me",
), ),
Route::with_handler_and_name(
"/offline/manifest",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
get(
move |auth_ctx: auth::AuthContext, 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;
offline_manifest_handler(auth_ctx, session, db, pg_pool).await
}
},
)
},
"player_offline_manifest",
),
Route::with_handler_and_name( Route::with_handler_and_name(
"/lastfm/status", "/lastfm/status",
get({ get({
+29
View File
@@ -56,6 +56,8 @@ pub(super) struct MediaFileRow {
pub(super) file_path: String, pub(super) file_path: String,
pub(super) mime_type: String, pub(super) mime_type: String,
pub(super) file_size_bytes: i64, pub(super) file_size_bytes: i64,
pub(super) sha256_hash: String,
pub(super) created_at: String,
} }
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@@ -287,3 +289,30 @@ pub(super) struct ReleaseInfoRow {
pub(super) year: Option<i32>, pub(super) year: Option<i32>,
pub(super) cover_file_id: Option<i64>, pub(super) cover_file_id: Option<i64>,
} }
#[derive(sqlx::FromRow)]
pub(super) struct OfflineTrackManifestRow {
pub(super) id: i64,
pub(super) updated_at: String,
pub(super) audio_file_id: i64,
pub(super) audio_hash: String,
pub(super) audio_size_bytes: i64,
pub(super) audio_mime_type: String,
pub(super) audio_updated_at: String,
pub(super) cover_file_id: Option<i64>,
pub(super) cover_hash: Option<String>,
pub(super) cover_updated_at: Option<String>,
}
#[derive(sqlx::FromRow)]
pub(super) struct OfflinePlaylistManifestRow {
pub(super) id: i64,
pub(super) title: String,
pub(super) description: Option<String>,
pub(super) updated_at: String,
pub(super) is_own: bool,
pub(super) owner_name: String,
pub(super) is_public: bool,
pub(super) is_saved: bool,
pub(super) track_ids: Vec<i64>,
}