A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(navidrome): add Subsonic REST API v1.16.1 compatible crate

Implements a new `rocksky-navidrome` crate providing a Navidrome/Subsonic
compatible API so any Subsonic client can browse and stream user-uploaded
audio. Auth uses the Bluesky handle as username and an existing API key
as password (plain, hex-encoded, or token/salt MD5).

Endpoints: ping, getMusicFolders, getArtists/getIndexes, getArtist,
getAlbum, getSong, stream (presigned S3 redirect), getCoverArt,
search3/search2, scrobble, updateNowPlaying, getAlbumList2/getAlbumList,
getRandomSongs, star, unstar. Both JSON and XML response formats are
supported. Start with `rockskyd navidrome` (default port 4533).

+1994 -11
+23
Cargo.lock
··· 4990 4990 ] 4991 4991 4992 4992 [[package]] 4993 + name = "rocksky-navidrome" 4994 + version = "0.1.0" 4995 + dependencies = [ 4996 + "actix-cors", 4997 + "actix-web", 4998 + "anyhow", 4999 + "chrono", 5000 + "dotenv", 5001 + "hex", 5002 + "md5", 5003 + "owo-colors", 5004 + "quick-xml 0.37.5", 5005 + "reqwest", 5006 + "rust-s3", 5007 + "serde", 5008 + "serde_json", 5009 + "sqlx", 5010 + "tokio", 5011 + "tracing", 5012 + ] 5013 + 5014 + [[package]] 4993 5015 name = "rocksky-pgpull" 4994 5016 version = "0.1.0" 4995 5017 dependencies = [ ··· 5165 5187 "rocksky-dropbox", 5166 5188 "rocksky-googledrive", 5167 5189 "rocksky-jetstream", 5190 + "rocksky-navidrome", 5168 5191 "rocksky-pgpull", 5169 5192 "rocksky-playlists", 5170 5193 "rocksky-scrobbler",
+8
apps/web/src/hooks/useUploads.tsx
··· 2 2 import type { UploadedTrack } from "../api/uploads"; 3 3 import { 4 4 deleteUpload, 5 + getAlbumTracks, 5 6 getStreamUrl, 6 7 getUploads, 7 8 uploadTrack, ··· 43 44 return allPages.flat().length; 44 45 }, 45 46 placeholderData: keepPreviousData, 47 + }); 48 + 49 + export const useAlbumTracksQuery = (albumUri?: string, albumArtist?: string, albumName?: string) => 50 + useQuery({ 51 + queryKey: ["albumTracks", albumUri, albumArtist, albumName], 52 + queryFn: () => getAlbumTracks(albumUri, albumArtist, albumName), 53 + enabled: !!(albumUri || (albumArtist && albumName)), 46 54 }); 47 55 48 56 export const useDeleteUploadMutation = () => {
+3 -11
apps/web/src/pages/library/LibraryAlbum.tsx
··· 9 9 IconVinyl, 10 10 } from "@tabler/icons-react"; 11 11 import { useNavigate, useParams } from "@tanstack/react-router"; 12 - import { useMemo, useState, useRef, useEffect, useCallback } from "react"; 12 + import { useState, useRef, useEffect, useCallback } from "react"; 13 13 import type { UploadedTrack } from "../../api/uploads"; 14 - import { useUploadsQuery } from "../../hooks/useUploads"; 14 + import { useAlbumTracksQuery } from "../../hooks/useUploads"; 15 15 import { useUploadPlayer } from "../../hooks/useUploadPlayer"; 16 16 import type { QueueTrack } from "../../atoms/queue"; 17 17 import Main from "../../layouts/Main"; ··· 369 369 370 370 const albumUri = `at://${did}/app.rocksky.album/${rkey}`; 371 371 372 - const { data: allUploads = [], isLoading } = useUploadsQuery(0, 1000); 373 - 374 - const tracks = useMemo( 375 - () => 376 - allUploads 377 - .filter((item) => item.track.albumUri === albumUri) 378 - .sort((a, b) => (a.track.trackNumber ?? 0) - (b.track.trackNumber ?? 0)), 379 - [allUploads, albumUri], 380 - ); 372 + const { data: tracks = [], isLoading } = useAlbumTracksQuery(albumUri); 381 373 382 374 const albumArt = tracks[0]?.track.albumArt ?? null; 383 375 const album = tracks[0]?.track.album ?? "";
+37
crates/navidrome/Cargo.toml
··· 1 + [package] 2 + name = "rocksky-navidrome" 3 + version = "0.1.0" 4 + authors.workspace = true 5 + edition.workspace = true 6 + license.workspace = true 7 + repository.workspace = true 8 + 9 + [dependencies] 10 + serde = { version = "1.0.217", features = ["derive"] } 11 + serde_json = "1.0.139" 12 + sqlx = { version = "0.8.3", features = [ 13 + "runtime-tokio", 14 + "tls-rustls", 15 + "postgres", 16 + "chrono", 17 + "derive", 18 + "macros", 19 + ] } 20 + tokio = { version = "1.43.0", features = ["full"] } 21 + owo-colors = "4.1.0" 22 + dotenv = "0.15.0" 23 + anyhow = "1.0.96" 24 + actix-web = "4.9.0" 25 + actix-cors = "0.7.1" 26 + chrono = { version = "= 0.4.39", features = ["serde"] } 27 + tracing = "0.1.41" 28 + md5 = "0.7.0" 29 + hex = "0.4.3" 30 + quick-xml = { version = "0.37.4", features = ["serialize"] } 31 + reqwest = { version = "0.12.12", features = [ 32 + "rustls-tls", 33 + "stream", 34 + ], default-features = false } 35 + rust-s3 = { version = "0.35.1", features = [ 36 + "tokio-rustls-tls", 37 + ], default-features = false }
+44
crates/navidrome/src/auth.rs
··· 1 + use anyhow::Error; 2 + use sqlx::{Pool, Postgres}; 3 + 4 + use crate::repo::user::get_user_with_apikeys; 5 + use crate::xata::user::UserWithApiKey; 6 + 7 + pub async fn authenticate( 8 + pool: &Pool<Postgres>, 9 + username: &str, 10 + password: Option<&str>, 11 + token: Option<&str>, 12 + salt: Option<&str>, 13 + ) -> Result<UserWithApiKey, Error> { 14 + let users = get_user_with_apikeys(pool, username).await?; 15 + 16 + if users.is_empty() { 17 + return Err(Error::msg("User not found")); 18 + } 19 + 20 + for user in &users { 21 + let api_key = &user.api_key; 22 + 23 + if let Some(p) = password { 24 + let plain = if let Some(hex_pass) = p.strip_prefix("enc:") { 25 + let bytes = hex::decode(hex_pass) 26 + .map_err(|_| Error::msg("Invalid hex-encoded password"))?; 27 + String::from_utf8(bytes).map_err(|_| Error::msg("Invalid UTF-8 in password"))? 28 + } else { 29 + p.to_string() 30 + }; 31 + 32 + if plain == *api_key { 33 + return Ok(user.clone()); 34 + } 35 + } else if let (Some(t), Some(s)) = (token, salt) { 36 + let expected = format!("{:x}", md5::compute(format!("{}{}", api_key, s))); 37 + if expected == t { 38 + return Ok(user.clone()); 39 + } 40 + } 41 + } 42 + 43 + Err(Error::msg("Wrong username or password")) 44 + }
+156
crates/navidrome/src/handlers/albums.rs
··· 1 + use actix_web::HttpResponse; 2 + use serde_json::{json, Value}; 3 + use sqlx::{Pool, Postgres}; 4 + use std::sync::Arc; 5 + 6 + use crate::{repo, response, xata::album::AlbumWithStats}; 7 + 8 + fn album_to_json(a: &AlbumWithStats, artist_id_override: Option<&str>) -> Value { 9 + let mut obj = json!({ 10 + "id": a.xata_id, 11 + "name": a.title, 12 + "artist": a.artist, 13 + "songCount": a.song_count, 14 + "duration": a.total_duration.unwrap_or(0), 15 + "created": a.created_at.map(|d| d.to_rfc3339()).unwrap_or_default(), 16 + }); 17 + 18 + let aid = artist_id_override.or(a.artist_id.as_deref()); 19 + if let Some(aid) = aid { 20 + obj["artistId"] = json!(aid); 21 + } 22 + 23 + if let Some(year) = a.year { 24 + obj["year"] = json!(year); 25 + } 26 + 27 + if let Some(art) = &a.album_art { 28 + obj["coverArt"] = json!(format!("al-{}", a.xata_id)); 29 + let _ = art; 30 + } 31 + 32 + obj 33 + } 34 + 35 + pub async fn handle_get_album( 36 + format: &str, 37 + user_id: &str, 38 + album_id: &str, 39 + pool: &Arc<Pool<Postgres>>, 40 + ) -> HttpResponse { 41 + let album = match repo::album::get_album_by_id(pool, album_id, user_id).await { 42 + Ok(Some(a)) => a, 43 + Ok(None) => return response::err(format, 70, "Album not found"), 44 + Err(e) => { 45 + tracing::error!("getAlbum error: {}", e); 46 + return response::err(format, 0, "Internal server error"); 47 + } 48 + }; 49 + 50 + let tracks = match repo::track::get_tracks_by_album(pool, album_id, user_id).await { 51 + Ok(t) => t, 52 + Err(e) => { 53 + tracing::error!("getAlbum tracks error: {}", e); 54 + return response::err(format, 0, "Internal server error"); 55 + } 56 + }; 57 + 58 + let songs: Vec<Value> = tracks 59 + .iter() 60 + .map(|t| { 61 + let suffix = mime_to_suffix(&t.mime_type); 62 + let mut s = json!({ 63 + "id": t.xata_id, 64 + "parent": album_id, 65 + "isDir": false, 66 + "title": t.title, 67 + "album": t.album, 68 + "artist": t.artist, 69 + "duration": t.duration, 70 + "size": t.file_size, 71 + "contentType": t.mime_type, 72 + "suffix": suffix, 73 + "albumId": album_id, 74 + "type": "music", 75 + }); 76 + if let Some(tn) = t.track_number { 77 + s["track"] = json!(tn); 78 + } 79 + if let Some(dn) = t.disc_number { 80 + s["discNumber"] = json!(dn); 81 + } 82 + if let Some(g) = &t.genre { 83 + s["genre"] = json!(g); 84 + } 85 + if let Some(mb) = &t.mb_id { 86 + s["musicBrainzId"] = json!(mb); 87 + } 88 + if t.album_art.is_some() { 89 + s["coverArt"] = json!(format!("tr-{}", t.xata_id)); 90 + } 91 + if let Some(aid) = &album.artist_id { 92 + s["artistId"] = json!(aid); 93 + } 94 + s 95 + }) 96 + .collect(); 97 + 98 + let mut album_obj = album_to_json(&album, None); 99 + album_obj["song"] = json!(songs); 100 + 101 + response::ok(format, json!({ "album": album_obj })) 102 + } 103 + 104 + pub async fn handle_get_album_list2( 105 + format: &str, 106 + user_id: &str, 107 + pool: &Arc<Pool<Postgres>>, 108 + params: &std::collections::HashMap<String, String>, 109 + ) -> HttpResponse { 110 + let list_type = params.get("type").map(|s| s.as_str()).unwrap_or("newest"); 111 + let count: i64 = params 112 + .get("size") 113 + .and_then(|s| s.parse().ok()) 114 + .unwrap_or(10) 115 + .min(500); 116 + let offset: i64 = params 117 + .get("offset") 118 + .and_then(|s| s.parse().ok()) 119 + .unwrap_or(0); 120 + let from_year: Option<i32> = params.get("fromYear").and_then(|s| s.parse().ok()); 121 + let to_year: Option<i32> = params.get("toYear").and_then(|s| s.parse().ok()); 122 + let genre = params.get("genre").map(|s| s.as_str()); 123 + 124 + // "starred" albums not supported — return empty 125 + if list_type == "starred" { 126 + return response::ok(format, json!({ "albumList2": { "album": [] } })); 127 + } 128 + 129 + match repo::album::get_album_list( 130 + pool, user_id, list_type, count, offset, from_year, to_year, genre, 131 + ) 132 + .await 133 + { 134 + Ok(albums) => { 135 + let list: Vec<Value> = albums.iter().map(|a| album_to_json(a, None)).collect(); 136 + response::ok(format, json!({ "albumList2": { "album": list } })) 137 + } 138 + Err(e) => { 139 + tracing::error!("getAlbumList2 error: {}", e); 140 + response::err(format, 0, "Internal server error") 141 + } 142 + } 143 + } 144 + 145 + pub fn mime_to_suffix(mime: &str) -> &str { 146 + match mime { 147 + "audio/mpeg" | "audio/mp3" => "mp3", 148 + "audio/flac" => "flac", 149 + "audio/mp4" | "audio/x-m4a" | "audio/aac" => "m4a", 150 + "audio/ogg" | "audio/vorbis" => "ogg", 151 + "audio/wav" | "audio/x-wav" => "wav", 152 + "audio/aiff" | "audio/x-aiff" => "aiff", 153 + "audio/opus" => "opus", 154 + _ => "mp3", 155 + } 156 + }
+154
crates/navidrome/src/handlers/artists.rs
··· 1 + use actix_web::HttpResponse; 2 + use serde_json::{json, Value}; 3 + use sqlx::{Pool, Postgres}; 4 + use std::collections::BTreeMap; 5 + use std::sync::Arc; 6 + 7 + use crate::{repo, response, xata::artist::ArtistWithStats}; 8 + 9 + const IGNORED_ARTICLES: &str = "The An A Die Das Ein Eine Les Le La"; 10 + 11 + fn sort_key(name: &str) -> String { 12 + for article in IGNORED_ARTICLES.split_whitespace() { 13 + let prefix = format!("{} ", article); 14 + if let Some(stripped) = name.strip_prefix(&prefix) { 15 + return stripped.to_string(); 16 + } 17 + } 18 + name.to_string() 19 + } 20 + 21 + fn first_letter(name: &str) -> String { 22 + let key = sort_key(name); 23 + key.chars() 24 + .next() 25 + .map(|c| { 26 + let u = c.to_uppercase().next().unwrap_or(c); 27 + if u.is_ascii_alphabetic() { 28 + u.to_string() 29 + } else { 30 + "#".to_string() 31 + } 32 + }) 33 + .unwrap_or_else(|| "#".to_string()) 34 + } 35 + 36 + fn artist_to_json(a: &ArtistWithStats) -> Value { 37 + let mut obj = json!({ 38 + "id": a.xata_id, 39 + "name": a.name, 40 + "albumCount": a.album_count, 41 + }); 42 + 43 + if let Some(pic) = &a.picture { 44 + obj["artistImageUrl"] = json!(pic); 45 + obj["coverArt"] = json!(format!("ar-{}", a.xata_id)); 46 + } 47 + 48 + obj 49 + } 50 + 51 + pub async fn handle_get_artists( 52 + format: &str, 53 + user_id: &str, 54 + pool: &Arc<Pool<Postgres>>, 55 + ) -> HttpResponse { 56 + match repo::artist::get_all_artists(pool, user_id).await { 57 + Ok(artists) => { 58 + let mut index_map: BTreeMap<String, Vec<Value>> = BTreeMap::new(); 59 + 60 + for artist in &artists { 61 + let letter = first_letter(&artist.name); 62 + index_map 63 + .entry(letter) 64 + .or_default() 65 + .push(artist_to_json(artist)); 66 + } 67 + 68 + let index: Vec<Value> = index_map 69 + .into_iter() 70 + .map(|(letter, list)| { 71 + json!({ 72 + "name": letter, 73 + "artist": list 74 + }) 75 + }) 76 + .collect(); 77 + 78 + response::ok( 79 + format, 80 + json!({ 81 + "artists": { 82 + "ignoredArticles": IGNORED_ARTICLES, 83 + "index": index 84 + } 85 + }), 86 + ) 87 + } 88 + Err(e) => { 89 + tracing::error!("getArtists error: {}", e); 90 + response::err(format, 0, "Internal server error") 91 + } 92 + } 93 + } 94 + 95 + pub async fn handle_get_artist( 96 + format: &str, 97 + user_id: &str, 98 + artist_id: &str, 99 + pool: &Arc<Pool<Postgres>>, 100 + ) -> HttpResponse { 101 + let artist = match repo::artist::get_artist_by_id(pool, artist_id).await { 102 + Ok(Some(a)) => a, 103 + Ok(None) => return response::err(format, 70, "Artist not found"), 104 + Err(e) => { 105 + tracing::error!("getArtist error: {}", e); 106 + return response::err(format, 0, "Internal server error"); 107 + } 108 + }; 109 + 110 + let albums = match repo::album::get_albums_by_artist(pool, artist_id, user_id).await { 111 + Ok(a) => a, 112 + Err(e) => { 113 + tracing::error!("getArtist albums error: {}", e); 114 + return response::err(format, 0, "Internal server error"); 115 + } 116 + }; 117 + 118 + let album_list: Vec<Value> = albums 119 + .iter() 120 + .map(|a| { 121 + let mut obj = json!({ 122 + "id": a.xata_id, 123 + "name": a.title, 124 + "artist": a.artist, 125 + "artistId": artist_id, 126 + "songCount": a.song_count, 127 + "duration": a.total_duration.unwrap_or(0), 128 + "created": a.created_at.map(|d| d.to_rfc3339()).unwrap_or_default(), 129 + }); 130 + if let Some(year) = a.year { 131 + obj["year"] = json!(year); 132 + } 133 + if let Some(art) = &a.album_art { 134 + obj["coverArt"] = json!(format!("al-{}", a.xata_id)); 135 + obj["_coverArtUrl"] = json!(art); 136 + } 137 + obj 138 + }) 139 + .collect(); 140 + 141 + let mut artist_obj = json!({ 142 + "id": artist.xata_id, 143 + "name": artist.name, 144 + "albumCount": albums.len(), 145 + "album": album_list, 146 + }); 147 + 148 + if let Some(pic) = &artist.picture { 149 + artist_obj["artistImageUrl"] = json!(pic); 150 + artist_obj["coverArt"] = json!(format!("ar-{}", artist.xata_id)); 151 + } 152 + 153 + response::ok(format, json!({ "artist": artist_obj })) 154 + }
+59
crates/navidrome/src/handlers/cover_art.rs
··· 1 + use actix_web::HttpResponse; 2 + use sqlx::{Pool, Postgres}; 3 + use std::sync::Arc; 4 + 5 + use crate::{repo, response, s3}; 6 + 7 + pub async fn handle(format: &str, cover_id: &str, pool: &Arc<Pool<Postgres>>) -> HttpResponse { 8 + let image_url = if let Some(album_id) = cover_id.strip_prefix("al-") { 9 + repo::album::get_album_art(pool, album_id) 10 + .await 11 + .ok() 12 + .flatten() 13 + } else if let Some(artist_id) = cover_id.strip_prefix("ar-") { 14 + repo::artist::get_picture_by_artist_id(pool, artist_id) 15 + .await 16 + .ok() 17 + .flatten() 18 + } else if let Some(track_id) = cover_id.strip_prefix("tr-") { 19 + repo::track::get_album_art_by_track_id(pool, track_id) 20 + .await 21 + .ok() 22 + .flatten() 23 + } else { 24 + // Try track first, then album 25 + let by_track = repo::track::get_album_art_by_track_id(pool, cover_id) 26 + .await 27 + .ok() 28 + .flatten(); 29 + if by_track.is_some() { 30 + by_track 31 + } else { 32 + repo::album::get_album_art(pool, cover_id) 33 + .await 34 + .ok() 35 + .flatten() 36 + } 37 + }; 38 + 39 + match image_url { 40 + Some(url) if url.starts_with("http://") || url.starts_with("https://") => { 41 + HttpResponse::TemporaryRedirect() 42 + .append_header(("Location", url)) 43 + .finish() 44 + } 45 + Some(key) => { 46 + // Treat as an S3/R2 key 47 + match s3::presign_get(&key, 3600).await { 48 + Ok(url) => HttpResponse::TemporaryRedirect() 49 + .append_header(("Location", url)) 50 + .finish(), 51 + Err(e) => { 52 + tracing::error!("coverArt presign error: {}", e); 53 + response::err(format, 70, "Cover art not found") 54 + } 55 + } 56 + } 57 + None => response::err(format, 70, "Cover art not found"), 58 + } 59 + }
+138
crates/navidrome/src/handlers/mod.rs
··· 1 + pub mod albums; 2 + pub mod artists; 3 + pub mod cover_art; 4 + pub mod music_folders; 5 + pub mod ping; 6 + pub mod scrobble; 7 + pub mod search; 8 + pub mod songs; 9 + pub mod star; 10 + pub mod stream; 11 + 12 + use actix_web::{get, post, web, HttpResponse}; 13 + use sqlx::{Pool, Postgres}; 14 + use std::{collections::HashMap, sync::Arc}; 15 + 16 + use crate::{auth, response}; 17 + 18 + fn get_format(params: &HashMap<String, String>) -> String { 19 + params 20 + .get("f") 21 + .map(|s| s.to_lowercase()) 22 + .filter(|s| s == "xml" || s == "json") 23 + .unwrap_or_else(|| "json".to_string()) 24 + } 25 + 26 + async fn dispatch( 27 + method: &str, 28 + params: HashMap<String, String>, 29 + pool: &Arc<Pool<Postgres>>, 30 + ) -> HttpResponse { 31 + let format = get_format(&params); 32 + 33 + // Auth is required for all endpoints except ping 34 + let user = if method != "ping" { 35 + let username = match params.get("u") { 36 + Some(u) => u.as_str(), 37 + None => return response::err(&format, 10, "Missing u parameter"), 38 + }; 39 + let password = params.get("p").map(|s| s.as_str()); 40 + let token = params.get("t").map(|s| s.as_str()); 41 + let salt = params.get("s").map(|s| s.as_str()); 42 + 43 + if password.is_none() && (token.is_none() || salt.is_none()) { 44 + return response::err(&format, 10, "Missing credentials: provide p or t+s"); 45 + } 46 + 47 + match auth::authenticate(pool, username, password, token, salt).await { 48 + Ok(u) => Some(u), 49 + Err(e) => { 50 + tracing::warn!("Auth failed for '{}': {}", username, e); 51 + return response::err(&format, 40, "Wrong username or password"); 52 + } 53 + } 54 + } else { 55 + None 56 + }; 57 + 58 + let user_id = user.as_ref().map(|u| u.xata_id.as_str()).unwrap_or(""); 59 + 60 + match method { 61 + "ping" => ping::handle(&format), 62 + "getMusicFolders" => music_folders::handle(&format), 63 + "getArtists" | "getIndexes" => artists::handle_get_artists(&format, user_id, pool).await, 64 + "getArtist" => { 65 + let id = match params.get("id") { 66 + Some(id) => id.as_str(), 67 + None => return response::err(&format, 10, "Missing id parameter"), 68 + }; 69 + artists::handle_get_artist(&format, user_id, id, pool).await 70 + } 71 + "getAlbum" => { 72 + let id = match params.get("id") { 73 + Some(id) => id.as_str(), 74 + None => return response::err(&format, 10, "Missing id parameter"), 75 + }; 76 + albums::handle_get_album(&format, user_id, id, pool).await 77 + } 78 + "getSong" => { 79 + let id = match params.get("id") { 80 + Some(id) => id.as_str(), 81 + None => return response::err(&format, 10, "Missing id parameter"), 82 + }; 83 + songs::handle_get_song(&format, user_id, id, pool).await 84 + } 85 + "stream" | "download" => { 86 + let id = match params.get("id") { 87 + Some(id) => id.as_str(), 88 + None => return response::err(&format, 10, "Missing id parameter"), 89 + }; 90 + stream::handle(&format, user_id, id, pool).await 91 + } 92 + "getCoverArt" => { 93 + let id = match params.get("id") { 94 + Some(id) => id.as_str(), 95 + None => return response::err(&format, 10, "Missing id parameter"), 96 + }; 97 + cover_art::handle(&format, id, pool).await 98 + } 99 + "search3" | "search2" => search::handle_search3(&format, user_id, pool, &params).await, 100 + "scrobble" => scrobble::handle_scrobble(&format, user_id, pool, &params).await, 101 + "updateNowPlaying" => { 102 + scrobble::handle_update_now_playing(&format, user_id, pool, &params).await 103 + } 104 + "getAlbumList2" | "getAlbumList" => { 105 + albums::handle_get_album_list2(&format, user_id, pool, &params).await 106 + } 107 + "getRandomSongs" => songs::handle_get_random_songs(&format, user_id, pool, &params).await, 108 + "star" => star::handle_star(&format, user_id, pool, &params).await, 109 + "unstar" => star::handle_unstar(&format, user_id, pool, &params).await, 110 + _ => response::err( 111 + &format, 112 + 70, 113 + &format!("Requested method '{}' not found", method), 114 + ), 115 + } 116 + } 117 + 118 + #[get("/rest/{method}")] 119 + pub async fn handle_get( 120 + path: web::Path<String>, 121 + pool: web::Data<Arc<Pool<Postgres>>>, 122 + query: web::Query<HashMap<String, String>>, 123 + ) -> HttpResponse { 124 + let method = path.into_inner(); 125 + let method = method.trim_end_matches(".view").to_string(); 126 + dispatch(&method, query.into_inner(), pool.get_ref()).await 127 + } 128 + 129 + #[post("/rest/{method}")] 130 + pub async fn handle_post( 131 + path: web::Path<String>, 132 + pool: web::Data<Arc<Pool<Postgres>>>, 133 + form: web::Form<HashMap<String, String>>, 134 + ) -> HttpResponse { 135 + let method = path.into_inner(); 136 + let method = method.trim_end_matches(".view").to_string(); 137 + dispatch(&method, form.into_inner(), pool.get_ref()).await 138 + }
+17
crates/navidrome/src/handlers/music_folders.rs
··· 1 + use actix_web::HttpResponse; 2 + use serde_json::json; 3 + 4 + use crate::response; 5 + 6 + pub fn handle(format: &str) -> HttpResponse { 7 + response::ok( 8 + format, 9 + json!({ 10 + "musicFolders": { 11 + "musicFolder": [ 12 + { "id": 1, "name": "Music" } 13 + ] 14 + } 15 + }), 16 + ) 17 + }
+8
crates/navidrome/src/handlers/ping.rs
··· 1 + use actix_web::HttpResponse; 2 + use serde_json::json; 3 + 4 + use crate::response; 5 + 6 + pub fn handle(format: &str) -> HttpResponse { 7 + response::ok(format, json!({})) 8 + }
+94
crates/navidrome/src/handlers/scrobble.rs
··· 1 + use actix_web::HttpResponse; 2 + use chrono::{DateTime, TimeZone, Utc}; 3 + use serde_json::json; 4 + use sqlx::{Pool, Postgres}; 5 + use std::{collections::HashMap, sync::Arc}; 6 + 7 + use crate::{repo, response}; 8 + 9 + pub async fn handle_scrobble( 10 + format: &str, 11 + user_id: &str, 12 + pool: &Arc<Pool<Postgres>>, 13 + params: &HashMap<String, String>, 14 + ) -> HttpResponse { 15 + let song_id = match params.get("id") { 16 + Some(id) => id.as_str(), 17 + None => return response::err(format, 10, "Missing id parameter"), 18 + }; 19 + 20 + let submission = params 21 + .get("submission") 22 + .map(|s| s != "false") 23 + .unwrap_or(true); 24 + 25 + if !submission { 26 + // Now-playing update — acknowledge without persisting 27 + tracing::info!(user_id, song_id, "now playing update"); 28 + return response::ok(format, json!({})); 29 + } 30 + 31 + let timestamp: DateTime<Utc> = params 32 + .get("time") 33 + .and_then(|s| s.parse::<i64>().ok()) 34 + .map(|ms| { 35 + Utc.timestamp_millis_opt(ms) 36 + .single() 37 + .unwrap_or_else(Utc::now) 38 + }) 39 + .unwrap_or_else(Utc::now); 40 + 41 + let album_id = repo::track::get_album_id_for_track(pool, song_id) 42 + .await 43 + .ok() 44 + .flatten(); 45 + let artist_id = repo::track::get_artist_id_for_track(pool, song_id) 46 + .await 47 + .ok() 48 + .flatten(); 49 + 50 + match repo::scrobble::create_scrobble( 51 + pool, 52 + user_id, 53 + song_id, 54 + album_id.as_deref(), 55 + artist_id.as_deref(), 56 + timestamp, 57 + ) 58 + .await 59 + { 60 + Ok(_) => { 61 + tracing::info!(user_id, song_id, "scrobble recorded"); 62 + response::ok(format, json!({})) 63 + } 64 + Err(e) => { 65 + tracing::error!("scrobble error: {}", e); 66 + response::err(format, 0, "Internal server error") 67 + } 68 + } 69 + } 70 + 71 + pub async fn handle_update_now_playing( 72 + format: &str, 73 + user_id: &str, 74 + pool: &Arc<Pool<Postgres>>, 75 + params: &HashMap<String, String>, 76 + ) -> HttpResponse { 77 + let song_id = match params.get("id") { 78 + Some(id) => id.as_str(), 79 + None => return response::err(format, 10, "Missing id parameter"), 80 + }; 81 + 82 + // Verify the track exists and belongs to the user 83 + match repo::track::get_track_by_id(pool, song_id, user_id).await { 84 + Ok(Some(_)) => { 85 + tracing::info!(user_id, song_id, "now playing updated"); 86 + response::ok(format, json!({})) 87 + } 88 + Ok(None) => response::err(format, 70, "Song not found"), 89 + Err(e) => { 90 + tracing::error!("updateNowPlaying error: {}", e); 91 + response::err(format, 0, "Internal server error") 92 + } 93 + } 94 + }
+133
crates/navidrome/src/handlers/search.rs
··· 1 + use actix_web::HttpResponse; 2 + use serde_json::{json, Value}; 3 + use sqlx::{Pool, Postgres}; 4 + use std::{collections::HashMap, sync::Arc}; 5 + 6 + use crate::{handlers::albums::mime_to_suffix, repo, response}; 7 + 8 + pub async fn handle_search3( 9 + format: &str, 10 + user_id: &str, 11 + pool: &Arc<Pool<Postgres>>, 12 + params: &HashMap<String, String>, 13 + ) -> HttpResponse { 14 + let query = match params.get("query") { 15 + Some(q) if !q.is_empty() => q.as_str(), 16 + _ => return response::err(format, 10, "Missing query parameter"), 17 + }; 18 + 19 + let artist_count: i64 = params 20 + .get("artistCount") 21 + .and_then(|s| s.parse().ok()) 22 + .unwrap_or(20); 23 + let artist_offset: i64 = params 24 + .get("artistOffset") 25 + .and_then(|s| s.parse().ok()) 26 + .unwrap_or(0); 27 + let album_count: i64 = params 28 + .get("albumCount") 29 + .and_then(|s| s.parse().ok()) 30 + .unwrap_or(20); 31 + let album_offset: i64 = params 32 + .get("albumOffset") 33 + .and_then(|s| s.parse().ok()) 34 + .unwrap_or(0); 35 + let song_count: i64 = params 36 + .get("songCount") 37 + .and_then(|s| s.parse().ok()) 38 + .unwrap_or(20); 39 + let song_offset: i64 = params 40 + .get("songOffset") 41 + .and_then(|s| s.parse().ok()) 42 + .unwrap_or(0); 43 + 44 + let artists_fut = 45 + repo::artist::search_artists(pool, user_id, query, artist_count, artist_offset); 46 + let albums_fut = repo::album::search_albums(pool, user_id, query, album_count, album_offset); 47 + let songs_fut = repo::track::search_tracks(pool, user_id, query, song_count, song_offset); 48 + 49 + let (artists, albums, songs) = tokio::join!(artists_fut, albums_fut, songs_fut); 50 + 51 + let artist_list: Vec<Value> = artists 52 + .unwrap_or_default() 53 + .iter() 54 + .map(|a| { 55 + let mut obj = json!({ 56 + "id": a.xata_id, 57 + "name": a.name, 58 + "albumCount": a.album_count, 59 + }); 60 + if let Some(pic) = &a.picture { 61 + obj["artistImageUrl"] = json!(pic); 62 + obj["coverArt"] = json!(format!("ar-{}", a.xata_id)); 63 + } 64 + obj 65 + }) 66 + .collect(); 67 + 68 + let album_list: Vec<Value> = albums 69 + .unwrap_or_default() 70 + .iter() 71 + .map(|a| { 72 + let mut obj = json!({ 73 + "id": a.xata_id, 74 + "name": a.title, 75 + "artist": a.artist, 76 + "songCount": a.song_count, 77 + "duration": a.total_duration.unwrap_or(0), 78 + "created": a.created_at.map(|d| d.to_rfc3339()).unwrap_or_default(), 79 + }); 80 + if let Some(aid) = &a.artist_id { 81 + obj["artistId"] = json!(aid); 82 + } 83 + if let Some(year) = a.year { 84 + obj["year"] = json!(year); 85 + } 86 + if a.album_art.is_some() { 87 + obj["coverArt"] = json!(format!("al-{}", a.xata_id)); 88 + } 89 + obj 90 + }) 91 + .collect(); 92 + 93 + let song_list: Vec<Value> = songs 94 + .unwrap_or_default() 95 + .iter() 96 + .map(|t| { 97 + let suffix = mime_to_suffix(&t.mime_type); 98 + let mut s = json!({ 99 + "id": t.xata_id, 100 + "isDir": false, 101 + "title": t.title, 102 + "album": t.album, 103 + "artist": t.artist, 104 + "duration": t.duration, 105 + "size": t.file_size, 106 + "contentType": t.mime_type, 107 + "suffix": suffix, 108 + "type": "music", 109 + }); 110 + if let Some(tn) = t.track_number { 111 + s["track"] = json!(tn); 112 + } 113 + if let Some(g) = &t.genre { 114 + s["genre"] = json!(g); 115 + } 116 + if t.album_art.is_some() { 117 + s["coverArt"] = json!(format!("tr-{}", t.xata_id)); 118 + } 119 + s 120 + }) 121 + .collect(); 122 + 123 + response::ok( 124 + format, 125 + json!({ 126 + "searchResult3": { 127 + "artist": artist_list, 128 + "album": album_list, 129 + "song": song_list, 130 + } 131 + }), 132 + ) 133 + }
+87
crates/navidrome/src/handlers/songs.rs
··· 1 + use actix_web::HttpResponse; 2 + use serde_json::{json, Value}; 3 + use sqlx::{Pool, Postgres}; 4 + use std::sync::Arc; 5 + 6 + use crate::{handlers::albums::mime_to_suffix, repo, response}; 7 + 8 + pub async fn handle_get_song( 9 + format: &str, 10 + user_id: &str, 11 + song_id: &str, 12 + pool: &Arc<Pool<Postgres>>, 13 + ) -> HttpResponse { 14 + match repo::track::get_track_by_id(pool, song_id, user_id).await { 15 + Ok(Some(t)) => { 16 + let song = track_to_json(&t, user_id); 17 + response::ok(format, json!({ "song": song })) 18 + } 19 + Ok(None) => response::err(format, 70, "Song not found"), 20 + Err(e) => { 21 + tracing::error!("getSong error: {}", e); 22 + response::err(format, 0, "Internal server error") 23 + } 24 + } 25 + } 26 + 27 + pub async fn handle_get_random_songs( 28 + format: &str, 29 + user_id: &str, 30 + pool: &Arc<Pool<Postgres>>, 31 + params: &std::collections::HashMap<String, String>, 32 + ) -> HttpResponse { 33 + let count: i64 = params 34 + .get("size") 35 + .and_then(|s| s.parse().ok()) 36 + .unwrap_or(10) 37 + .min(500); 38 + let genre = params.get("genre").map(|s| s.as_str()); 39 + let from_year: Option<i32> = params.get("fromYear").and_then(|s| s.parse().ok()); 40 + let to_year: Option<i32> = params.get("toYear").and_then(|s| s.parse().ok()); 41 + 42 + match repo::track::get_random_songs(pool, user_id, count, genre, from_year, to_year).await { 43 + Ok(tracks) => { 44 + let songs: Vec<Value> = tracks.iter().map(|t| track_to_json(t, user_id)).collect(); 45 + response::ok(format, json!({ "randomSongs": { "song": songs } })) 46 + } 47 + Err(e) => { 48 + tracing::error!("getRandomSongs error: {}", e); 49 + response::err(format, 0, "Internal server error") 50 + } 51 + } 52 + } 53 + 54 + pub fn track_to_json(t: &crate::xata::track::TrackWithUpload, _user_id: &str) -> Value { 55 + let suffix = mime_to_suffix(&t.mime_type); 56 + let mut s = json!({ 57 + "id": t.xata_id, 58 + "isDir": false, 59 + "title": t.title, 60 + "album": t.album, 61 + "artist": t.artist, 62 + "duration": t.duration, 63 + "size": t.file_size, 64 + "contentType": t.mime_type, 65 + "suffix": suffix, 66 + "type": "music", 67 + "created": t.xata_createdat.to_rfc3339(), 68 + }); 69 + 70 + if let Some(tn) = t.track_number { 71 + s["track"] = json!(tn); 72 + } 73 + if let Some(dn) = t.disc_number { 74 + s["discNumber"] = json!(dn); 75 + } 76 + if let Some(g) = &t.genre { 77 + s["genre"] = json!(g); 78 + } 79 + if let Some(mb) = &t.mb_id { 80 + s["musicBrainzId"] = json!(mb); 81 + } 82 + if t.album_art.is_some() { 83 + s["coverArt"] = json!(format!("tr-{}", t.xata_id)); 84 + } 85 + 86 + s 87 + }
+43
crates/navidrome/src/handlers/star.rs
··· 1 + use actix_web::HttpResponse; 2 + use serde_json::json; 3 + use sqlx::{Pool, Postgres}; 4 + use std::{collections::HashMap, sync::Arc}; 5 + 6 + use crate::{repo, response}; 7 + 8 + pub async fn handle_star( 9 + format: &str, 10 + user_id: &str, 11 + pool: &Arc<Pool<Postgres>>, 12 + params: &HashMap<String, String>, 13 + ) -> HttpResponse { 14 + if let Some(id) = params.get("id") { 15 + match repo::scrobble::star_track(pool, user_id, id).await { 16 + Ok(_) => {} 17 + Err(e) => { 18 + tracing::error!("star error: {}", e); 19 + return response::err(format, 0, "Internal server error"); 20 + } 21 + } 22 + } 23 + // albumId and artistId starring is silently accepted (no DB support yet) 24 + response::ok(format, json!({})) 25 + } 26 + 27 + pub async fn handle_unstar( 28 + format: &str, 29 + user_id: &str, 30 + pool: &Arc<Pool<Postgres>>, 31 + params: &HashMap<String, String>, 32 + ) -> HttpResponse { 33 + if let Some(id) = params.get("id") { 34 + match repo::scrobble::unstar_track(pool, user_id, id).await { 35 + Ok(_) => {} 36 + Err(e) => { 37 + tracing::error!("unstar error: {}", e); 38 + return response::err(format, 0, "Internal server error"); 39 + } 40 + } 41 + } 42 + response::ok(format, json!({})) 43 + }
+32
crates/navidrome/src/handlers/stream.rs
··· 1 + use actix_web::HttpResponse; 2 + use sqlx::{Pool, Postgres}; 3 + use std::sync::Arc; 4 + 5 + use crate::{repo, response, s3}; 6 + 7 + pub async fn handle( 8 + format: &str, 9 + user_id: &str, 10 + song_id: &str, 11 + pool: &Arc<Pool<Postgres>>, 12 + ) -> HttpResponse { 13 + let track = match repo::track::get_track_by_id(pool, song_id, user_id).await { 14 + Ok(Some(t)) => t, 15 + Ok(None) => return response::err(format, 70, "Song not found"), 16 + Err(e) => { 17 + tracing::error!("stream lookup error: {}", e); 18 + return response::err(format, 0, "Internal server error"); 19 + } 20 + }; 21 + 22 + match s3::presign_get(&track.r2_key, 3600).await { 23 + Ok(url) => HttpResponse::TemporaryRedirect() 24 + .append_header(("Location", url)) 25 + .append_header(("Cache-Control", "no-cache")) 26 + .finish(), 27 + Err(e) => { 28 + tracing::error!("presign error: {}", e); 29 + response::err(format, 0, "Failed to generate stream URL") 30 + } 31 + } 32 + }
+65
crates/navidrome/src/lib.rs
··· 1 + pub mod auth; 2 + pub mod handlers; 3 + pub mod repo; 4 + pub mod response; 5 + pub mod s3; 6 + pub mod xata; 7 + 8 + use std::{env, sync::Arc, time::Duration}; 9 + 10 + use actix_cors::Cors; 11 + use actix_web::{web::Data, App, HttpServer}; 12 + use anyhow::Error; 13 + use owo_colors::OwoColorize; 14 + use sqlx::postgres::PgPoolOptions; 15 + 16 + pub const BANNER: &str = r#" 17 + _ __ _ __ 18 + / | / /___ __ __ (_)___/ /________ ____ ___ ___ 19 + / |/ / __ `/ | / // // __ // ___/ / / / __ `/ / _ \ 20 + / /| / /_/ /| |/ // // /_/ // / / /_/ / /_/ / / __/ 21 + /_/ |_/\__,_/ |___// / \__,_//_/ \____/\____/ \___/ 22 + /___/ 23 + 24 + Rocksky Navidrome-compatible API (Subsonic REST API v1.16.1) 25 + "#; 26 + 27 + pub async fn run() -> Result<(), Error> { 28 + println!("{}", BANNER.cyan()); 29 + 30 + let pool = PgPoolOptions::new() 31 + .max_connections(10) 32 + .min_connections(2) 33 + .acquire_timeout(Duration::from_secs(10)) 34 + .idle_timeout(Duration::from_secs(300)) 35 + .max_lifetime(Duration::from_secs(1800)) 36 + .connect(&env::var("XATA_POSTGRES_URL")?) 37 + .await?; 38 + 39 + let conn = Arc::new(pool); 40 + 41 + let host = env::var("NAVIDROME_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); 42 + let port = env::var("NAVIDROME_PORT") 43 + .unwrap_or_else(|_| "4533".to_string()) 44 + .parse::<u16>() 45 + .unwrap_or(4533); 46 + 47 + tracing::info!( 48 + url = %format!("http://{}:{}", host, port).bright_green(), 49 + "Starting Navidrome-compatible API @" 50 + ); 51 + 52 + HttpServer::new(move || { 53 + let cors = Cors::permissive(); 54 + App::new() 55 + .wrap(cors) 56 + .app_data(Data::new(conn.clone())) 57 + .service(handlers::handle_get) 58 + .service(handlers::handle_post) 59 + }) 60 + .bind((host, port))? 61 + .run() 62 + .await?; 63 + 64 + Ok(()) 65 + }
+211
crates/navidrome/src/repo/album.rs
··· 1 + use anyhow::Error; 2 + use sqlx::{Pool, Postgres}; 3 + 4 + use crate::xata::album::AlbumWithStats; 5 + 6 + const BASE_ALBUM_QUERY: &str = r#" 7 + SELECT 8 + albums.xata_id, 9 + albums.title, 10 + albums.artist, 11 + albums.year, 12 + albums.album_art, 13 + COUNT(DISTINCT album_tracks.track_id) AS song_count, 14 + SUM(tracks.duration)::bigint AS total_duration, 15 + MIN(user_uploads.uploaded_at) AS created_at, 16 + (SELECT aa.artist_id FROM artist_albums aa WHERE aa.album_id = albums.xata_id LIMIT 1) AS artist_id 17 + FROM albums 18 + JOIN album_tracks ON albums.xata_id = album_tracks.album_id 19 + JOIN tracks ON album_tracks.track_id = tracks.xata_id 20 + JOIN user_uploads ON tracks.xata_id = user_uploads.track_id 21 + WHERE user_uploads.user_id = $1 22 + GROUP BY albums.xata_id, albums.title, albums.artist, albums.year, albums.album_art 23 + "#; 24 + 25 + pub async fn get_albums_by_artist( 26 + pool: &Pool<Postgres>, 27 + artist_id: &str, 28 + user_id: &str, 29 + ) -> Result<Vec<AlbumWithStats>, Error> { 30 + let rows: Vec<AlbumWithStats> = sqlx::query_as(&format!( 31 + r#" 32 + SELECT 33 + albums.xata_id, 34 + albums.title, 35 + albums.artist, 36 + albums.year, 37 + albums.album_art, 38 + COUNT(DISTINCT album_tracks.track_id) AS song_count, 39 + SUM(tracks.duration)::bigint AS total_duration, 40 + MIN(user_uploads.uploaded_at) AS created_at, 41 + $2::text AS artist_id 42 + FROM albums 43 + JOIN artist_albums ON albums.xata_id = artist_albums.album_id 44 + JOIN album_tracks ON albums.xata_id = album_tracks.album_id 45 + JOIN tracks ON album_tracks.track_id = tracks.xata_id 46 + JOIN user_uploads ON tracks.xata_id = user_uploads.track_id 47 + WHERE artist_albums.artist_id = $2 48 + AND user_uploads.user_id = $1 49 + GROUP BY albums.xata_id, albums.title, albums.artist, albums.year, albums.album_art 50 + ORDER BY albums.year DESC NULLS LAST 51 + "# 52 + )) 53 + .bind(user_id) 54 + .bind(artist_id) 55 + .fetch_all(pool) 56 + .await?; 57 + 58 + Ok(rows) 59 + } 60 + 61 + pub async fn get_album_by_id( 62 + pool: &Pool<Postgres>, 63 + album_id: &str, 64 + user_id: &str, 65 + ) -> Result<Option<AlbumWithStats>, Error> { 66 + let row: Option<AlbumWithStats> = 67 + sqlx::query_as(&format!("{} AND albums.xata_id = $2", BASE_ALBUM_QUERY)) 68 + .bind(user_id) 69 + .bind(album_id) 70 + .fetch_optional(pool) 71 + .await?; 72 + 73 + Ok(row) 74 + } 75 + 76 + pub async fn get_album_list( 77 + pool: &Pool<Postgres>, 78 + user_id: &str, 79 + list_type: &str, 80 + count: i64, 81 + offset: i64, 82 + from_year: Option<i32>, 83 + to_year: Option<i32>, 84 + genre: Option<&str>, 85 + ) -> Result<Vec<AlbumWithStats>, Error> { 86 + let order_clause = match list_type { 87 + "newest" => "ORDER BY created_at DESC NULLS LAST", 88 + "alphabeticalByName" => "ORDER BY albums.title ASC", 89 + "alphabeticalByArtist" => "ORDER BY albums.artist ASC", 90 + "random" => "ORDER BY RANDOM()", 91 + "recent" => "ORDER BY created_at DESC NULLS LAST", 92 + "byYear" => { 93 + if from_year.unwrap_or(0) > to_year.unwrap_or(9999) { 94 + "ORDER BY albums.year DESC NULLS LAST" 95 + } else { 96 + "ORDER BY albums.year ASC NULLS LAST" 97 + } 98 + } 99 + _ => "ORDER BY created_at DESC NULLS LAST", 100 + }; 101 + 102 + let year_filter = if list_type == "byYear" { 103 + let from = from_year.unwrap_or(0); 104 + let to = to_year.unwrap_or(9999); 105 + format!( 106 + " AND albums.year BETWEEN {} AND {}", 107 + from.min(to), 108 + from.max(to) 109 + ) 110 + } else { 111 + String::new() 112 + }; 113 + 114 + let genre_filter = if list_type == "byGenre" { 115 + if let Some(g) = genre { 116 + format!( 117 + " AND LOWER(tracks.genre) = LOWER('{}')", 118 + g.replace('\'', "''") 119 + ) 120 + } else { 121 + String::new() 122 + } 123 + } else { 124 + String::new() 125 + }; 126 + 127 + let sql = format!( 128 + r#" 129 + SELECT 130 + albums.xata_id, 131 + albums.title, 132 + albums.artist, 133 + albums.year, 134 + albums.album_art, 135 + COUNT(DISTINCT album_tracks.track_id) AS song_count, 136 + SUM(tracks.duration)::bigint AS total_duration, 137 + MIN(user_uploads.uploaded_at) AS created_at, 138 + (SELECT aa.artist_id FROM artist_albums aa WHERE aa.album_id = albums.xata_id LIMIT 1) AS artist_id 139 + FROM albums 140 + JOIN album_tracks ON albums.xata_id = album_tracks.album_id 141 + JOIN tracks ON album_tracks.track_id = tracks.xata_id 142 + JOIN user_uploads ON tracks.xata_id = user_uploads.track_id 143 + WHERE user_uploads.user_id = $1 144 + {}{} 145 + GROUP BY albums.xata_id, albums.title, albums.artist, albums.year, albums.album_art 146 + {} 147 + LIMIT $2 OFFSET $3 148 + "#, 149 + year_filter, genre_filter, order_clause 150 + ); 151 + 152 + let rows: Vec<AlbumWithStats> = sqlx::query_as(&sql) 153 + .bind(user_id) 154 + .bind(count) 155 + .bind(offset) 156 + .fetch_all(pool) 157 + .await?; 158 + 159 + Ok(rows) 160 + } 161 + 162 + pub async fn search_albums( 163 + pool: &Pool<Postgres>, 164 + user_id: &str, 165 + query: &str, 166 + count: i64, 167 + offset: i64, 168 + ) -> Result<Vec<AlbumWithStats>, Error> { 169 + let pattern = format!("%{}%", query); 170 + let rows: Vec<AlbumWithStats> = sqlx::query_as( 171 + r#" 172 + SELECT 173 + albums.xata_id, 174 + albums.title, 175 + albums.artist, 176 + albums.year, 177 + albums.album_art, 178 + COUNT(DISTINCT album_tracks.track_id) AS song_count, 179 + SUM(tracks.duration)::bigint AS total_duration, 180 + MIN(user_uploads.uploaded_at) AS created_at, 181 + (SELECT aa.artist_id FROM artist_albums aa WHERE aa.album_id = albums.xata_id LIMIT 1) AS artist_id 182 + FROM albums 183 + JOIN album_tracks ON albums.xata_id = album_tracks.album_id 184 + JOIN tracks ON album_tracks.track_id = tracks.xata_id 185 + JOIN user_uploads ON tracks.xata_id = user_uploads.track_id 186 + WHERE user_uploads.user_id = $1 187 + AND LOWER(albums.title) LIKE LOWER($2) 188 + GROUP BY albums.xata_id, albums.title, albums.artist, albums.year, albums.album_art 189 + ORDER BY albums.title ASC 190 + LIMIT $3 OFFSET $4 191 + "#, 192 + ) 193 + .bind(user_id) 194 + .bind(&pattern) 195 + .bind(count) 196 + .bind(offset) 197 + .fetch_all(pool) 198 + .await?; 199 + 200 + Ok(rows) 201 + } 202 + 203 + pub async fn get_album_art(pool: &Pool<Postgres>, album_id: &str) -> Result<Option<String>, Error> { 204 + let row: Option<(Option<String>,)> = 205 + sqlx::query_as(r#"SELECT album_art FROM albums WHERE xata_id = $1"#) 206 + .bind(album_id) 207 + .fetch_optional(pool) 208 + .await?; 209 + 210 + Ok(row.and_then(|(art,)| art)) 211 + }
+94
crates/navidrome/src/repo/artist.rs
··· 1 + use anyhow::Error; 2 + use sqlx::{Pool, Postgres}; 3 + 4 + use crate::xata::artist::{ArtistRow, ArtistWithStats}; 5 + 6 + pub async fn get_all_artists( 7 + pool: &Pool<Postgres>, 8 + user_id: &str, 9 + ) -> Result<Vec<ArtistWithStats>, Error> { 10 + let rows: Vec<ArtistWithStats> = sqlx::query_as( 11 + r#" 12 + SELECT 13 + artists.xata_id, 14 + artists.name, 15 + artists.picture, 16 + COUNT(DISTINCT artist_albums.album_id) AS album_count 17 + FROM artists 18 + JOIN artist_tracks ON artists.xata_id = artist_tracks.artist_id 19 + JOIN user_uploads ON artist_tracks.track_id = user_uploads.track_id 20 + LEFT JOIN artist_albums ON artists.xata_id = artist_albums.artist_id 21 + WHERE user_uploads.user_id = $1 22 + GROUP BY artists.xata_id, artists.name, artists.picture 23 + ORDER BY artists.name ASC 24 + "#, 25 + ) 26 + .bind(user_id) 27 + .fetch_all(pool) 28 + .await?; 29 + 30 + Ok(rows) 31 + } 32 + 33 + pub async fn get_artist_by_id( 34 + pool: &Pool<Postgres>, 35 + artist_id: &str, 36 + ) -> Result<Option<ArtistRow>, Error> { 37 + let row: Option<ArtistRow> = sqlx::query_as( 38 + r#"SELECT xata_id, name, picture, xata_createdat FROM artists WHERE xata_id = $1"#, 39 + ) 40 + .bind(artist_id) 41 + .fetch_optional(pool) 42 + .await?; 43 + 44 + Ok(row) 45 + } 46 + 47 + pub async fn search_artists( 48 + pool: &Pool<Postgres>, 49 + user_id: &str, 50 + query: &str, 51 + count: i64, 52 + offset: i64, 53 + ) -> Result<Vec<ArtistWithStats>, Error> { 54 + let pattern = format!("%{}%", query); 55 + let rows: Vec<ArtistWithStats> = sqlx::query_as( 56 + r#" 57 + SELECT 58 + artists.xata_id, 59 + artists.name, 60 + artists.picture, 61 + COUNT(DISTINCT artist_albums.album_id) AS album_count 62 + FROM artists 63 + JOIN artist_tracks ON artists.xata_id = artist_tracks.artist_id 64 + JOIN user_uploads ON artist_tracks.track_id = user_uploads.track_id 65 + LEFT JOIN artist_albums ON artists.xata_id = artist_albums.artist_id 66 + WHERE user_uploads.user_id = $1 67 + AND LOWER(artists.name) LIKE LOWER($2) 68 + GROUP BY artists.xata_id, artists.name, artists.picture 69 + ORDER BY artists.name ASC 70 + LIMIT $3 OFFSET $4 71 + "#, 72 + ) 73 + .bind(user_id) 74 + .bind(&pattern) 75 + .bind(count) 76 + .bind(offset) 77 + .fetch_all(pool) 78 + .await?; 79 + 80 + Ok(rows) 81 + } 82 + 83 + pub async fn get_picture_by_artist_id( 84 + pool: &Pool<Postgres>, 85 + artist_id: &str, 86 + ) -> Result<Option<String>, Error> { 87 + let row: Option<(Option<String>,)> = 88 + sqlx::query_as(r#"SELECT picture FROM artists WHERE xata_id = $1"#) 89 + .bind(artist_id) 90 + .fetch_optional(pool) 91 + .await?; 92 + 93 + Ok(row.and_then(|(p,)| p)) 94 + }
+5
crates/navidrome/src/repo/mod.rs
··· 1 + pub mod album; 2 + pub mod artist; 3 + pub mod scrobble; 4 + pub mod track; 5 + pub mod user;
+75
crates/navidrome/src/repo/scrobble.rs
··· 1 + use anyhow::Error; 2 + use chrono::{DateTime, Utc}; 3 + use sqlx::{Pool, Postgres}; 4 + 5 + pub async fn create_scrobble( 6 + pool: &Pool<Postgres>, 7 + user_id: &str, 8 + track_id: &str, 9 + album_id: Option<&str>, 10 + artist_id: Option<&str>, 11 + timestamp: DateTime<Utc>, 12 + ) -> Result<(), Error> { 13 + sqlx::query( 14 + r#" 15 + INSERT INTO scrobbles (user_id, track_id, album_id, artist_id, timestamp) 16 + VALUES ($1, $2, $3, $4, $5) 17 + "#, 18 + ) 19 + .bind(user_id) 20 + .bind(track_id) 21 + .bind(album_id) 22 + .bind(artist_id) 23 + .bind(timestamp) 24 + .execute(pool) 25 + .await?; 26 + 27 + Ok(()) 28 + } 29 + 30 + pub async fn star_track(pool: &Pool<Postgres>, user_id: &str, track_id: &str) -> Result<(), Error> { 31 + sqlx::query( 32 + r#" 33 + INSERT INTO loved_tracks (user_id, track_id) 34 + SELECT $1, $2 35 + WHERE NOT EXISTS ( 36 + SELECT 1 FROM loved_tracks WHERE user_id = $1 AND track_id = $2 37 + ) 38 + "#, 39 + ) 40 + .bind(user_id) 41 + .bind(track_id) 42 + .execute(pool) 43 + .await?; 44 + 45 + Ok(()) 46 + } 47 + 48 + pub async fn unstar_track( 49 + pool: &Pool<Postgres>, 50 + user_id: &str, 51 + track_id: &str, 52 + ) -> Result<(), Error> { 53 + sqlx::query(r#"DELETE FROM loved_tracks WHERE user_id = $1 AND track_id = $2"#) 54 + .bind(user_id) 55 + .bind(track_id) 56 + .execute(pool) 57 + .await?; 58 + 59 + Ok(()) 60 + } 61 + 62 + pub async fn is_track_starred( 63 + pool: &Pool<Postgres>, 64 + user_id: &str, 65 + track_id: &str, 66 + ) -> Result<bool, Error> { 67 + let row: Option<(i64,)> = 68 + sqlx::query_as(r#"SELECT COUNT(*) FROM loved_tracks WHERE user_id = $1 AND track_id = $2"#) 69 + .bind(user_id) 70 + .bind(track_id) 71 + .fetch_optional(pool) 72 + .await?; 73 + 74 + Ok(row.map_or(false, |(count,)| count > 0)) 75 + }
+183
crates/navidrome/src/repo/track.rs
··· 1 + use anyhow::Error; 2 + use sqlx::{Pool, Postgres}; 3 + 4 + use crate::xata::track::TrackWithUpload; 5 + 6 + const TRACK_SELECT: &str = r#" 7 + SELECT 8 + tracks.xata_id, 9 + tracks.title, 10 + tracks.artist, 11 + tracks.album_artist, 12 + tracks.album_art, 13 + tracks.album, 14 + tracks.track_number, 15 + tracks.disc_number, 16 + tracks.duration, 17 + tracks.mb_id, 18 + tracks.genre, 19 + tracks.xata_createdat, 20 + user_uploads.r2_key, 21 + user_uploads.mime_type, 22 + user_uploads.file_size 23 + FROM tracks 24 + JOIN user_uploads ON tracks.xata_id = user_uploads.track_id 25 + "#; 26 + 27 + pub async fn get_tracks_by_album( 28 + pool: &Pool<Postgres>, 29 + album_id: &str, 30 + user_id: &str, 31 + ) -> Result<Vec<TrackWithUpload>, Error> { 32 + let rows: Vec<TrackWithUpload> = sqlx::query_as(&format!( 33 + r#" 34 + {} 35 + JOIN album_tracks ON tracks.xata_id = album_tracks.track_id 36 + WHERE album_tracks.album_id = $1 37 + AND user_uploads.user_id = $2 38 + ORDER BY tracks.disc_number ASC NULLS FIRST, tracks.track_number ASC NULLS FIRST 39 + "#, 40 + TRACK_SELECT 41 + )) 42 + .bind(album_id) 43 + .bind(user_id) 44 + .fetch_all(pool) 45 + .await?; 46 + 47 + Ok(rows) 48 + } 49 + 50 + pub async fn get_track_by_id( 51 + pool: &Pool<Postgres>, 52 + track_id: &str, 53 + user_id: &str, 54 + ) -> Result<Option<TrackWithUpload>, Error> { 55 + let row: Option<TrackWithUpload> = sqlx::query_as(&format!( 56 + r#" 57 + {} 58 + WHERE tracks.xata_id = $1 59 + AND user_uploads.user_id = $2 60 + "#, 61 + TRACK_SELECT 62 + )) 63 + .bind(track_id) 64 + .bind(user_id) 65 + .fetch_optional(pool) 66 + .await?; 67 + 68 + Ok(row) 69 + } 70 + 71 + pub async fn get_random_songs( 72 + pool: &Pool<Postgres>, 73 + user_id: &str, 74 + count: i64, 75 + genre: Option<&str>, 76 + from_year: Option<i32>, 77 + to_year: Option<i32>, 78 + ) -> Result<Vec<TrackWithUpload>, Error> { 79 + let mut filters = vec!["user_uploads.user_id = $1".to_string()]; 80 + 81 + if let Some(g) = genre { 82 + filters.push(format!( 83 + "LOWER(tracks.genre) = LOWER('{}')", 84 + g.replace('\'', "''") 85 + )); 86 + } 87 + if let Some(from) = from_year { 88 + if let Some(to) = to_year { 89 + filters.push(format!( 90 + "EXTRACT(YEAR FROM tracks.xata_createdat) BETWEEN {} AND {}", 91 + from.min(to), 92 + from.max(to) 93 + )); 94 + } 95 + } 96 + 97 + let where_clause = filters.join(" AND "); 98 + 99 + let sql = format!( 100 + r#" 101 + {} 102 + WHERE {} 103 + ORDER BY RANDOM() 104 + LIMIT $2 105 + "#, 106 + TRACK_SELECT, where_clause 107 + ); 108 + 109 + let rows: Vec<TrackWithUpload> = sqlx::query_as(&sql) 110 + .bind(user_id) 111 + .bind(count) 112 + .fetch_all(pool) 113 + .await?; 114 + 115 + Ok(rows) 116 + } 117 + 118 + pub async fn search_tracks( 119 + pool: &Pool<Postgres>, 120 + user_id: &str, 121 + query: &str, 122 + count: i64, 123 + offset: i64, 124 + ) -> Result<Vec<TrackWithUpload>, Error> { 125 + let pattern = format!("%{}%", query); 126 + let rows: Vec<TrackWithUpload> = sqlx::query_as(&format!( 127 + r#" 128 + {} 129 + WHERE user_uploads.user_id = $1 130 + AND LOWER(tracks.title) LIKE LOWER($2) 131 + ORDER BY tracks.title ASC 132 + LIMIT $3 OFFSET $4 133 + "#, 134 + TRACK_SELECT 135 + )) 136 + .bind(user_id) 137 + .bind(&pattern) 138 + .bind(count) 139 + .bind(offset) 140 + .fetch_all(pool) 141 + .await?; 142 + 143 + Ok(rows) 144 + } 145 + 146 + pub async fn get_album_art_by_track_id( 147 + pool: &Pool<Postgres>, 148 + track_id: &str, 149 + ) -> Result<Option<String>, Error> { 150 + let row: Option<(Option<String>,)> = 151 + sqlx::query_as(r#"SELECT album_art FROM tracks WHERE xata_id = $1"#) 152 + .bind(track_id) 153 + .fetch_optional(pool) 154 + .await?; 155 + 156 + Ok(row.and_then(|(art,)| art)) 157 + } 158 + 159 + pub async fn get_album_id_for_track( 160 + pool: &Pool<Postgres>, 161 + track_id: &str, 162 + ) -> Result<Option<String>, Error> { 163 + let row: Option<(String,)> = 164 + sqlx::query_as(r#"SELECT album_id FROM album_tracks WHERE track_id = $1 LIMIT 1"#) 165 + .bind(track_id) 166 + .fetch_optional(pool) 167 + .await?; 168 + 169 + Ok(row.map(|(id,)| id)) 170 + } 171 + 172 + pub async fn get_artist_id_for_track( 173 + pool: &Pool<Postgres>, 174 + track_id: &str, 175 + ) -> Result<Option<String>, Error> { 176 + let row: Option<(String,)> = 177 + sqlx::query_as(r#"SELECT artist_id FROM artist_tracks WHERE track_id = $1 LIMIT 1"#) 178 + .bind(track_id) 179 + .fetch_optional(pool) 180 + .await?; 181 + 182 + Ok(row.map(|(id,)| id)) 183 + }
+29
crates/navidrome/src/repo/user.rs
··· 1 + use anyhow::Error; 2 + use sqlx::{Pool, Postgres}; 3 + 4 + use crate::xata::user::UserWithApiKey; 5 + 6 + pub async fn get_user_with_apikeys( 7 + pool: &Pool<Postgres>, 8 + handle: &str, 9 + ) -> Result<Vec<UserWithApiKey>, Error> { 10 + let rows: Vec<UserWithApiKey> = sqlx::query_as( 11 + r#" 12 + SELECT 13 + users.xata_id, 14 + users.handle, 15 + users.display_name, 16 + users.avatar, 17 + api_keys.api_key 18 + FROM users 19 + JOIN api_keys ON users.xata_id = api_keys.user_id 20 + WHERE users.handle = $1 21 + AND api_keys.enabled = true 22 + "#, 23 + ) 24 + .bind(handle) 25 + .fetch_all(pool) 26 + .await?; 27 + 28 + Ok(rows) 29 + }
+141
crates/navidrome/src/response.rs
··· 1 + use actix_web::HttpResponse; 2 + use serde_json::{json, Value}; 3 + 4 + const VERSION: &str = "1.16.1"; 5 + const SERVER_TYPE: &str = "navidrome"; 6 + const SERVER_VERSION: &str = "0.49.3"; 7 + 8 + pub fn ok(format: &str, data: Value) -> HttpResponse { 9 + let mut base = json!({ 10 + "status": "ok", 11 + "version": VERSION, 12 + "type": SERVER_TYPE, 13 + "serverVersion": SERVER_VERSION, 14 + }); 15 + 16 + if let (Some(obj), Some(inner)) = (base.as_object_mut(), data.as_object()) { 17 + for (k, v) in inner { 18 + obj.insert(k.clone(), v.clone()); 19 + } 20 + } 21 + 22 + if format == "xml" { 23 + let xml = to_xml(&base); 24 + HttpResponse::Ok() 25 + .content_type("text/xml; charset=utf-8") 26 + .body(xml) 27 + } else { 28 + HttpResponse::Ok().json(json!({ "subsonic-response": base })) 29 + } 30 + } 31 + 32 + pub fn err(format: &str, code: u32, message: &str) -> HttpResponse { 33 + let base = json!({ 34 + "status": "failed", 35 + "version": VERSION, 36 + "type": SERVER_TYPE, 37 + "serverVersion": SERVER_VERSION, 38 + "error": { 39 + "code": code, 40 + "message": message 41 + } 42 + }); 43 + 44 + if format == "xml" { 45 + let xml = to_xml(&base); 46 + HttpResponse::Ok() 47 + .content_type("text/xml; charset=utf-8") 48 + .body(xml) 49 + } else { 50 + HttpResponse::Ok().json(json!({ "subsonic-response": base })) 51 + } 52 + } 53 + 54 + fn xml_escape(s: &str) -> String { 55 + s.replace('&', "&amp;") 56 + .replace('<', "&lt;") 57 + .replace('>', "&gt;") 58 + .replace('"', "&quot;") 59 + .replace('\'', "&apos;") 60 + } 61 + 62 + fn value_to_xml(tag: &str, value: &Value, buf: &mut String) { 63 + match value { 64 + Value::Object(map) => { 65 + let mut attrs = String::new(); 66 + let mut children: Vec<(&str, &Value)> = Vec::new(); 67 + 68 + for (key, val) in map { 69 + match val { 70 + Value::String(s) => { 71 + attrs.push_str(&format!(r#" {}="{}""#, key, xml_escape(s))); 72 + } 73 + Value::Number(n) => { 74 + attrs.push_str(&format!(r#" {}="{}""#, key, n)); 75 + } 76 + Value::Bool(b) => { 77 + attrs.push_str(&format!(r#" {}="{}""#, key, b)); 78 + } 79 + Value::Null => {} 80 + _ => { 81 + children.push((key.as_str(), val)); 82 + } 83 + } 84 + } 85 + 86 + if children.is_empty() { 87 + buf.push_str(&format!("<{}{}/>", tag, attrs)); 88 + } else { 89 + buf.push_str(&format!("<{}{}>", tag, attrs)); 90 + for (child_tag, child_val) in children { 91 + value_to_xml(child_tag, child_val, buf); 92 + } 93 + buf.push_str(&format!("</{}>", tag)); 94 + } 95 + } 96 + Value::Array(arr) => { 97 + for item in arr { 98 + value_to_xml(tag, item, buf); 99 + } 100 + } 101 + _ => {} 102 + } 103 + } 104 + 105 + fn to_xml(response: &Value) -> String { 106 + let mut buf = String::from(r#"<?xml version="1.0" encoding="UTF-8"?>"#); 107 + 108 + let status = response 109 + .get("status") 110 + .and_then(|v| v.as_str()) 111 + .unwrap_or("ok"); 112 + let version = response 113 + .get("version") 114 + .and_then(|v| v.as_str()) 115 + .unwrap_or(VERSION); 116 + let server_type = response 117 + .get("type") 118 + .and_then(|v| v.as_str()) 119 + .unwrap_or(SERVER_TYPE); 120 + let server_version = response 121 + .get("serverVersion") 122 + .and_then(|v| v.as_str()) 123 + .unwrap_or(SERVER_VERSION); 124 + 125 + buf.push_str(&format!( 126 + r#"<subsonic-response xmlns="http://subsonic.org/restapi" status="{}" version="{}" type="{}" serverVersion="{}">"#, 127 + status, version, server_type, server_version 128 + )); 129 + 130 + if let Value::Object(map) = response { 131 + let skip = ["status", "version", "type", "serverVersion"]; 132 + for (key, val) in map { 133 + if !skip.contains(&key.as_str()) { 134 + value_to_xml(key, val, &mut buf); 135 + } 136 + } 137 + } 138 + 139 + buf.push_str("</subsonic-response>"); 140 + buf 141 + }
+34
crates/navidrome/src/s3.rs
··· 1 + use anyhow::Error; 2 + use s3::{creds::Credentials, region::Region, Bucket}; 3 + use std::env; 4 + 5 + pub async fn presign_get(r2_key: &str, expires_secs: u32) -> Result<String, Error> { 6 + let region = Region::Custom { 7 + region: env::var("S3_REGION").unwrap_or_else(|_| "auto".to_string()), 8 + endpoint: env::var("S3_ENDPOINT").map_err(|_| Error::msg("S3_ENDPOINT is not set"))?, 9 + }; 10 + 11 + let credentials = Credentials::new( 12 + Some(&env::var("S3_ACCESS_KEY_ID").map_err(|_| Error::msg("S3_ACCESS_KEY_ID is not set"))?), 13 + Some( 14 + &env::var("S3_SECRET_ACCESS_KEY") 15 + .map_err(|_| Error::msg("S3_SECRET_ACCESS_KEY is not set"))?, 16 + ), 17 + None, 18 + None, 19 + None, 20 + )?; 21 + 22 + let bucket_name = env::var("S3_BUCKET").map_err(|_| Error::msg("S3_BUCKET is not set"))?; 23 + 24 + let bucket = Bucket::new(&bucket_name, region, credentials)?.with_path_style(); 25 + 26 + let key = if r2_key.starts_with('/') { 27 + r2_key.to_string() 28 + } else { 29 + format!("/{}", r2_key) 30 + }; 31 + 32 + let url = bucket.presign_get(&key, expires_secs, None).await?; 33 + Ok(url) 34 + }
+26
crates/navidrome/src/xata/album.rs
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + #[derive(Debug, sqlx::FromRow, Serialize, Deserialize, Clone)] 5 + pub struct AlbumRow { 6 + pub xata_id: String, 7 + pub title: String, 8 + pub artist: String, 9 + pub year: Option<i32>, 10 + pub album_art: Option<String>, 11 + #[serde(with = "chrono::serde::ts_seconds")] 12 + pub xata_createdat: DateTime<Utc>, 13 + } 14 + 15 + #[derive(Debug, sqlx::FromRow, Clone)] 16 + pub struct AlbumWithStats { 17 + pub xata_id: String, 18 + pub title: String, 19 + pub artist: String, 20 + pub year: Option<i32>, 21 + pub album_art: Option<String>, 22 + pub song_count: i64, 23 + pub total_duration: Option<i64>, 24 + pub created_at: Option<DateTime<Utc>>, 25 + pub artist_id: Option<String>, 26 + }
+19
crates/navidrome/src/xata/artist.rs
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + #[derive(Debug, sqlx::FromRow, Serialize, Deserialize, Clone)] 5 + pub struct ArtistRow { 6 + pub xata_id: String, 7 + pub name: String, 8 + pub picture: Option<String>, 9 + #[serde(with = "chrono::serde::ts_seconds")] 10 + pub xata_createdat: DateTime<Utc>, 11 + } 12 + 13 + #[derive(Debug, sqlx::FromRow, Clone)] 14 + pub struct ArtistWithStats { 15 + pub xata_id: String, 16 + pub name: String, 17 + pub picture: Option<String>, 18 + pub album_count: i64, 19 + }
+4
crates/navidrome/src/xata/mod.rs
··· 1 + pub mod album; 2 + pub mod artist; 3 + pub mod track; 4 + pub mod user;
+38
crates/navidrome/src/xata/track.rs
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + #[derive(Debug, sqlx::FromRow, Serialize, Deserialize, Clone)] 5 + pub struct TrackRow { 6 + pub xata_id: String, 7 + pub title: String, 8 + pub artist: String, 9 + pub album_artist: String, 10 + pub album_art: Option<String>, 11 + pub album: String, 12 + pub track_number: Option<i32>, 13 + pub disc_number: Option<i32>, 14 + pub duration: i32, 15 + pub mb_id: Option<String>, 16 + pub genre: Option<String>, 17 + #[serde(with = "chrono::serde::ts_seconds")] 18 + pub xata_createdat: DateTime<Utc>, 19 + } 20 + 21 + #[derive(Debug, sqlx::FromRow, Clone)] 22 + pub struct TrackWithUpload { 23 + pub xata_id: String, 24 + pub title: String, 25 + pub artist: String, 26 + pub album_artist: String, 27 + pub album_art: Option<String>, 28 + pub album: String, 29 + pub track_number: Option<i32>, 30 + pub disc_number: Option<i32>, 31 + pub duration: i32, 32 + pub mb_id: Option<String>, 33 + pub genre: Option<String>, 34 + pub xata_createdat: DateTime<Utc>, 35 + pub r2_key: String, 36 + pub mime_type: String, 37 + pub file_size: i32, 38 + }
+22
crates/navidrome/src/xata/user.rs
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + #[derive(Debug, sqlx::FromRow, Serialize, Deserialize, Clone)] 5 + pub struct User { 6 + pub xata_id: String, 7 + pub display_name: String, 8 + pub did: String, 9 + pub handle: String, 10 + pub avatar: Option<String>, 11 + #[serde(with = "chrono::serde::ts_seconds")] 12 + pub xata_createdat: DateTime<Utc>, 13 + } 14 + 15 + #[derive(Debug, sqlx::FromRow, Clone)] 16 + pub struct UserWithApiKey { 17 + pub xata_id: String, 18 + pub handle: String, 19 + pub display_name: String, 20 + pub avatar: Option<String>, 21 + pub api_key: String, 22 + }
+1
crates/rockskyd/Cargo.toml
··· 19 19 rocksky-spotify = { path = "../spotify" } 20 20 rocksky-tracklist = { path = "../tracklist" } 21 21 rocksky-webscrobbler = { path = "../webscrobbler" } 22 + rocksky-navidrome = { path = "../navidrome" } 22 23 rocksky-pgpull = { path = "../pgpull" } 23 24 tracing = "0.1.41" 24 25 tracing-subscriber = "0.3.20"
+1
crates/rockskyd/src/cmd/mod.rs
··· 1 1 pub mod dropbox; 2 2 pub mod googledrive; 3 3 pub mod jetstream; 4 + pub mod navidrome; 4 5 pub mod playlist; 5 6 pub mod pull; 6 7 pub mod scrobbler;
+6
crates/rockskyd/src/cmd/navidrome.rs
··· 1 + use anyhow::Error; 2 + 3 + pub async fn start_navidrome_service() -> Result<(), Error> { 4 + rocksky_navidrome::run().await?; 5 + Ok(()) 6 + }
+4
crates/rockskyd/src/main.rs
··· 21 21 .subcommand(Command::new("serve").about("Serve Rocksky Google Drive API")), 22 22 ) 23 23 .subcommand(Command::new("jetstream").about("Start JetStream Subscriber Service")) 24 + .subcommand(Command::new("navidrome").about("Start Navidrome-compatible API (Subsonic REST API)")) 24 25 .subcommand(Command::new("playlist").about("Playlist related commands")) 25 26 .subcommand(Command::new("scrobbler").about("Start Scrobbler API")) 26 27 .subcommand(Command::new("spotify").about("Start Spotify Listener Service")) ··· 62 63 }, 63 64 Some(("jetstream", _)) => { 64 65 cmd::jetstream::start_jetstream_service().await?; 66 + } 67 + Some(("navidrome", _)) => { 68 + cmd::navidrome::start_navidrome_service().await?; 65 69 } 66 70 Some(("playlist", _)) => { 67 71 cmd::playlist::start_playlist_service().await?;