Rockbox open source high quality audio player as a Music Player Daemon
mpris rockbox mpd libadwaita audio rust zig deno
2
fork

Configure Feed

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

Add artist genres and Rocksky sync

+327 -27
+2
Cargo.lock
··· 9504 9504 "lofty", 9505 9505 "md5", 9506 9506 "owo-colors 4.1.0", 9507 + "reqwest", 9507 9508 "rockbox-sys", 9508 9509 "serde", 9510 + "serde_json", 9509 9511 "sqlx", 9510 9512 "tokio", 9511 9513 ]
+2
cli/src/api/rockbox.v1alpha1.rs
··· 376 376 pub albums: ::prost::alloc::vec::Vec<Album>, 377 377 #[prost(message, repeated, tag = "6")] 378 378 pub tracks: ::prost::alloc::vec::Vec<Track>, 379 + #[prost(string, optional, tag = "7")] 380 + pub genres: ::core::option::Option<::prost::alloc::string::String>, 379 381 } 380 382 #[derive(Clone, PartialEq, ::prost::Message)] 381 383 pub struct Album {
cli/src/api/rockbox_descriptor.bin

This is a binary file and will not be displayed.

+2
crates/controls/src/api/rockbox.v1alpha1.rs
··· 376 376 pub albums: ::prost::alloc::vec::Vec<Album>, 377 377 #[prost(message, repeated, tag = "6")] 378 378 pub tracks: ::prost::alloc::vec::Vec<Track>, 379 + #[prost(string, optional, tag = "7")] 380 + pub genres: ::core::option::Option<::prost::alloc::string::String>, 379 381 } 380 382 #[derive(Clone, PartialEq, ::prost::Message)] 381 383 pub struct Album {
crates/controls/src/api/rockbox_descriptor.bin

This is a binary file and will not be displayed.

+6 -1
crates/library/Cargo.toml
··· 11 11 lofty = "0.21.1" 12 12 md5 = "0.7.0" 13 13 owo-colors = "4.1.0" 14 + reqwest = { version = "0.12.5", features = [ 15 + "rustls-tls", 16 + "json" 17 + ], default-features = false } 14 18 rockbox-sys = {path = "../sys"} 15 - serde = "1.0.210" 19 + serde = { version = "1.0.210", features = ["derive"] } 20 + serde_json = "1.0.128" 16 21 sqlx = {version = "0.8.2", features = ["runtime-tokio", "tls-rustls", "sqlite", "chrono", "derive", "macros"]} 17 22 tokio = {version = "1.36.0", features = ["full"]}
+11
crates/library/migrations/20251218173111_add_artist_genres.sql
··· 1 + -- Add migration script here 2 + CREATE TABLE IF NOT EXISTS artist_genres ( 3 + id VARCHAR(255) PRIMARY KEY, 4 + artist_id VARCHAR(255) NOT NULL, 5 + genre_id VARCHAR(255) NOT NULL, 6 + UNIQUE (artist_id, genre_id) 7 + ); 8 + 9 + ALTER TABLE artist ADD COLUMN genres VARCHAR(255) DEFAULT NULL; 10 + 11 + ALTER TABLE genre ADD CONSTRAINT unique_genre_name UNIQUE (name);
+98
crates/library/src/artists.rs
··· 1 + use std::{collections::HashMap, thread}; 2 + 3 + use anyhow::Error; 4 + use cuid::cuid1; 5 + use owo_colors::OwoColorize; 6 + use serde::Deserialize; 7 + use sqlx::{Pool, Sqlite}; 8 + 9 + use crate::repo; 10 + 11 + #[derive(Debug, Deserialize, Clone)] 12 + pub struct Artist { 13 + pub id: String, 14 + pub name: String, 15 + pub picture: Option<String>, 16 + pub sha256: String, 17 + pub uri: Option<String>, 18 + #[serde(rename = "playCount")] 19 + pub play_count: u64, 20 + #[serde(rename = "uniqueListeners")] 21 + pub unique_listeners: u32, 22 + #[serde(default)] 23 + pub genres: Vec<String>, 24 + } 25 + 26 + #[derive(Debug, Deserialize, Clone)] 27 + pub struct Artists { 28 + pub artists: Vec<Artist>, 29 + } 30 + 31 + const ROCKSKY_API: &str = "https://api.rocksky.app"; 32 + 33 + pub fn update_metadata(pool: Pool<Sqlite>) -> Result<(), Error> { 34 + thread::spawn(move || { 35 + let runtime = tokio::runtime::Runtime::new().unwrap(); 36 + let result = runtime.block_on(async { 37 + let mut offset = 0; 38 + let limit = 500; 39 + let mut artist_map: HashMap<String, Artist> = HashMap::new(); 40 + 41 + loop { 42 + let client = reqwest::Client::new(); 43 + let response = client 44 + .get(format!( 45 + "{}/xrpc/app.rocksky.artist.getArtists", 46 + ROCKSKY_API 47 + )) 48 + .query(&[("limit", limit), ("offset", offset)]) 49 + .send() 50 + .await?; 51 + let text = response.text().await?; 52 + let response: Artists = serde_json::from_str(&text)?; 53 + let artists = response.artists; 54 + 55 + for artist in artists.clone() { 56 + println!("Loading artist: {}", artist.name.bright_green()); 57 + artist_map.insert(artist.name.clone(), artist); 58 + } 59 + 60 + if artists.is_empty() { 61 + break; 62 + } 63 + 64 + offset += limit; 65 + println!("Loaded {} artists", offset); 66 + } 67 + 68 + let artists = repo::artist::all(pool.clone()).await?; 69 + let artists = artists.into_iter().filter(|v| v.image.is_none()); 70 + 71 + for artist in artists { 72 + println!("Updating artist: {}", artist.name.bright_green()); 73 + let artist_id = artist.id; 74 + if let Some(artist) = artist_map.get(&artist.name) { 75 + repo::artist::update_genres(&pool, &artist_id, &artist.genres.join(", ")) 76 + .await?; 77 + if let Some(picture) = artist.picture.clone() { 78 + repo::artist::update_picture(&pool, &artist_id, &picture).await?; 79 + } 80 + for genre in &artist.genres { 81 + println!("Saving genre: {}", genre.bright_green()); 82 + let id = cuid1()?; 83 + repo::genre::save(&pool, &id, genre).await?; 84 + repo::artist::save_artist_genre(&pool, &cuid1()?, &artist_id, &id).await?; 85 + } 86 + } 87 + } 88 + 89 + Ok::<(), Error>(()) 90 + }); 91 + 92 + match result { 93 + Ok(_) => {} 94 + Err(e) => eprintln!("Error updating artists: {}", e), 95 + } 96 + }); 97 + Ok(()) 98 + }
+2
crates/library/src/audio_scan.rs
··· 1 1 use crate::album_art::extract_and_save_album_cover; 2 + use crate::artists::update_metadata; 2 3 use crate::copyright_message::extract_copyright_message; 3 4 use crate::entity::album::Album; 4 5 use crate::entity::album_tracks::AlbumTracks; ··· 99 100 }, 100 101 bio: None, 101 102 image: None, 103 + genres: None, 102 104 }, 103 105 ) 104 106 .await?;
+1 -1
crates/library/src/copyright_message.rs
··· 24 24 25 25 let copyright = tag 26 26 .get_string(&lofty::tag::ItemKey::CopyrightMessage) 27 - .map(|label| label.to_string()); 27 + .map(|v| v.to_string()); 28 28 29 29 Ok(copyright) 30 30 }
+2
crates/library/src/entity/artist.rs
··· 8 8 pub bio: Option<String>, 9 9 #[serde(skip_serializing_if = "Option::is_none")] 10 10 pub image: Option<String>, 11 + #[serde(skip_serializing_if = "Option::is_none")] 12 + pub genres: Option<String>, 11 13 }
+6
crates/library/src/entity/artist_genres.rs
··· 1 + #[derive(sqlx::FromRow, Default)] 2 + pub struct ArtistGenres { 3 + pub id: String, 4 + pub artist_id: String, 5 + pub genre_id: String, 6 + }
+1
crates/library/src/entity/mod.rs
··· 1 1 pub mod album; 2 2 pub mod album_tracks; 3 3 pub mod artist; 4 + pub mod artist_genres; 4 5 pub mod artist_tracks; 5 6 pub mod favourites; 6 7 pub mod folder;
+11
crates/library/src/lib.rs
··· 3 3 use sqlx::{sqlite::SqliteConnectOptions, Error, Executor, Pool, Sqlite, SqlitePool}; 4 4 5 5 pub mod album_art; 6 + pub mod artists; 6 7 pub mod audio_scan; 7 8 pub mod copyright_message; 8 9 pub mod entity; ··· 62 63 { 63 64 Ok(_) => {} 64 65 Err(_) => println!("copyright_message column already exists"), 66 + } 67 + 68 + match pool 69 + .execute(include_str!( 70 + "../migrations/20251218173111_add_artist_genres.sql" 71 + )) 72 + .await 73 + { 74 + Ok(_) => {} 75 + Err(_) => println!("genres column already exists"), 65 76 } 66 77 67 78 sqlx::query("PRAGMA journal_mode=WAL")
+68 -1
crates/library/src/repo/artist.rs
··· 2 2 use sqlx::{Error, Pool, Sqlite}; 3 3 4 4 pub async fn save(pool: Pool<Sqlite>, artist: Artist) -> Result<String, Error> { 5 + if artist.name.is_empty() { 6 + return Err(Error::ColumnNotFound("name".to_string())); 7 + } 8 + 5 9 match sqlx::query( 6 10 r#" 7 11 INSERT INTO artist ( 8 - id, 12 + id, 9 13 name, 10 14 bio, 11 15 image ··· 97 101 } 98 102 } 99 103 } 104 + 105 + pub async fn update_picture(pool: &Pool<Sqlite>, id: &str, picture: &str) -> Result<(), Error> { 106 + match sqlx::query( 107 + r#" 108 + UPDATE artist SET image = $2 WHERE id = $1 109 + "#, 110 + ) 111 + .bind(id) 112 + .bind(picture) 113 + .execute(pool) 114 + .await 115 + { 116 + Ok(_) => Ok(()), 117 + Err(e) => { 118 + eprintln!("Error updating artist picture: {:?}", e); 119 + Err(e) 120 + } 121 + } 122 + } 123 + 124 + pub async fn update_genres(pool: &Pool<Sqlite>, id: &str, genres: &str) -> Result<(), Error> { 125 + match sqlx::query( 126 + r#" 127 + UPDATE artist SET genres = $2 WHERE id = $1 128 + "#, 129 + ) 130 + .bind(id) 131 + .bind(genres) 132 + .execute(pool) 133 + .await 134 + { 135 + Ok(_) => Ok(()), 136 + Err(e) => { 137 + eprintln!("Error updating artist genres: {:?}", e); 138 + Err(e) 139 + } 140 + } 141 + } 142 + 143 + pub async fn save_artist_genre( 144 + pool: &Pool<Sqlite>, 145 + id: &str, 146 + artist_id: &str, 147 + genre_id: &str, 148 + ) -> Result<(), Error> { 149 + match sqlx::query( 150 + r#" 151 + INSERT OR IGNORE INTO artist_genres (id, artist_id, genre_id) VALUES ($1, $2, $3) 152 + "#, 153 + ) 154 + .bind(id) 155 + .bind(artist_id) 156 + .bind(genre_id) 157 + .execute(pool) 158 + .await 159 + { 160 + Ok(_) => Ok(()), 161 + Err(e) => { 162 + eprintln!("Error saving artist genre: {:?}", e); 163 + Err(e) 164 + } 165 + } 166 + }
+32
crates/library/src/repo/genre.rs
··· 1 + use crate::entity::genre::Genre; 2 + use sqlx::{Error, Pool, Sqlite}; 1 3 4 + pub async fn save(pool: &Pool<Sqlite>, id: &str, name: &str) -> Result<(), Error> { 5 + match sqlx::query( 6 + r#" 7 + INSERT OR IGNORE INTO genre (id, name) 8 + VALUES ($1, $2) 9 + "#, 10 + ) 11 + .bind(id) 12 + .bind(name) 13 + .execute(pool) 14 + .await 15 + { 16 + Ok(_) => Ok(()), 17 + Err(e) => Err(e.into()), 18 + } 19 + } 20 + 21 + pub async fn all(pool: Pool<Sqlite>) -> Result<Vec<Genre>, Error> { 22 + match sqlx::query_as( 23 + r#" 24 + SELECT * FROM genre 25 + "#, 26 + ) 27 + .fetch_all(&pool) 28 + .await 29 + { 30 + Ok(genres) => Ok(genres), 31 + Err(e) => Err(e.into()), 32 + } 33 + }
+2
crates/rocksky/src/api/rockbox.v1alpha1.rs
··· 376 376 pub albums: ::prost::alloc::vec::Vec<Album>, 377 377 #[prost(message, repeated, tag = "6")] 378 378 pub tracks: ::prost::alloc::vec::Vec<Track>, 379 + #[prost(string, optional, tag = "7")] 380 + pub genres: ::core::option::Option<::prost::alloc::string::String>, 379 381 } 380 382 #[derive(Clone, PartialEq, ::prost::Message)] 381 383 pub struct Album {
crates/rocksky/src/api/rockbox_descriptor.bin

This is a binary file and will not be displayed.

+1
crates/rpc/proto/rockbox/v1alpha1/library.proto
··· 35 35 optional string image = 4; 36 36 repeated Album albums = 5; 37 37 repeated Track tracks = 6; 38 + optional string genres = 7; 38 39 } 39 40 40 41 message Album {
+2
crates/rpc/src/api/rockbox.v1alpha1.rs
··· 903 903 pub albums: ::prost::alloc::vec::Vec<Album>, 904 904 #[prost(message, repeated, tag = "6")] 905 905 pub tracks: ::prost::alloc::vec::Vec<Track>, 906 + #[prost(string, optional, tag = "7")] 907 + pub genres: ::core::option::Option<::prost::alloc::string::String>, 906 908 } 907 909 #[derive(Clone, PartialEq, ::prost::Message)] 908 910 pub struct Album {
crates/rpc/src/api/rockbox_descriptor.bin

This is a binary file and will not be displayed.

+2
crates/rpc/src/lib.rs
··· 763 763 image: artist.image, 764 764 albums: vec![], 765 765 tracks: vec![], 766 + genres: artist.genres, 766 767 } 767 768 } 768 769 } ··· 842 843 image: artist.image, 843 844 albums: vec![], 844 845 tracks: vec![], 846 + genres: None, 845 847 } 846 848 } 847 849 }
+3 -1
crates/server/src/handlers/system.rs
··· 2 2 3 3 use crate::http::{Context, Request, Response}; 4 4 use anyhow::Error; 5 - use rockbox_library::{audio_scan::scan_audio_files, repo}; 5 + use rockbox_library::{artists::update_metadata, audio_scan::scan_audio_files, repo}; 6 6 use rockbox_search::{ 7 7 album::Album, artist::Artist, delete_all_documents, index_entity, liked_album::LikedAlbum, 8 8 liked_track::LikedTrack, track::Track, ··· 40 40 res.text("0"); 41 41 return Ok(()); 42 42 } 43 + 44 + update_metadata(ctx.pool.clone())?; 43 45 44 46 let tracks = repo::track::all(ctx.pool.clone()).await?; 45 47 let albums = repo::album::all(ctx.pool.clone()).await?;
macos/Rockbox.xcodeproj/project.xcworkspace/xcuserdata/tsirysandratraina.xcuserdatad/UserInterfaceState.xcuserstate

This is a binary file and will not be displayed.

+1
macos/Rockbox/Models/Core/Artist.swift
··· 10 10 let id = UUID() 11 11 let cuid: String 12 12 let name: String 13 + let image: String? 13 14 let genre: String 14 15 let color: Color 15 16
+12 -12
macos/Rockbox/Samples.swift
··· 110 110 */ 111 111 112 112 let sampleArtists: [Artist] = [ 113 - Artist(cuid: "", name: "Queen", genre: "Rock", color: .purple), 114 - Artist(cuid: "", name: "Fleetwood Mac", genre: "Rock", color: .blue), 115 - Artist(cuid: "", name: "AC/DC", genre: "Hard Rock", color: .black), 116 - Artist(cuid: "", name: "Michael Jackson", genre: "Pop", color: .orange), 117 - Artist(cuid: "", name: "Pink Floyd", genre: "Progressive Rock", color: .indigo), 118 - Artist(cuid: "", name: "The Beatles", genre: "Rock", color: .cyan), 119 - Artist(cuid: "", name: "Led Zeppelin", genre: "Hard Rock", color: .brown), 120 - Artist(cuid: "", name: "Eagles", genre: "Rock", color: .yellow), 121 - Artist(cuid: "", name: "Bruce Springsteen", genre: "Rock", color: .red), 122 - Artist(cuid: "", name: "Prince", genre: "Pop", color: .purple), 123 - Artist(cuid: "", name: "U2", genre: "Rock", color: .gray), 124 - Artist(cuid: "", name: "Nirvana", genre: "Grunge", color: .teal), 113 + Artist(cuid: "", name: "Queen", image: nil, genre: "Rock", color: .purple), 114 + Artist(cuid: "", name: "Fleetwood Mac", image: nil, genre: "Rock", color: .blue), 115 + Artist(cuid: "", name: "AC/DC", image: nil, genre: "Hard Rock", color: .black), 116 + Artist(cuid: "", name: "Michael Jackson", image: nil, genre: "Pop", color: .orange), 117 + Artist(cuid: "", name: "Pink Floyd", image: nil, genre: "Progressive Rock", color: .indigo), 118 + Artist(cuid: "", name: "The Beatles", image: nil, genre: "Rock", color: .cyan), 119 + Artist(cuid: "", name: "Led Zeppelin", image: nil, genre: "Hard Rock", color: .brown), 120 + Artist(cuid: "", name: "Eagles", image: nil, genre: "Rock", color: .yellow), 121 + Artist(cuid: "", name: "Bruce Springsteen", image: nil, genre: "Rock", color: .red), 122 + Artist(cuid: "", name: "Prince", image: nil, genre: "Pop", color: .purple), 123 + Artist(cuid: "", name: "U2", image: nil, genre: "Rock", color: .gray), 124 + Artist(cuid: "", name: "Nirvana", image: nil, genre: "Grunge", color: .teal), 125 125 ] 126 126 127 127
+16 -1
macos/Rockbox/Services/rockbox/v1alpha1/library.pb.swift
··· 194 194 195 195 var tracks: [Rockbox_V1alpha1_Track] = [] 196 196 197 + var genres: String { 198 + get {return _genres ?? String()} 199 + set {_genres = newValue} 200 + } 201 + /// Returns true if `genres` has been explicitly set. 202 + var hasGenres: Bool {return self._genres != nil} 203 + /// Clears the value of `genres`. Subsequent reads from it will return its default value. 204 + mutating func clearGenres() {self._genres = nil} 205 + 197 206 var unknownFields = SwiftProtobuf.UnknownStorage() 198 207 199 208 init() {} 200 209 201 210 fileprivate var _bio: String? = nil 202 211 fileprivate var _image: String? = nil 212 + fileprivate var _genres: String? = nil 203 213 } 204 214 205 215 struct Rockbox_V1alpha1_Album: Sendable { ··· 845 855 846 856 extension Rockbox_V1alpha1_Artist: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { 847 857 static let protoMessageName: String = _protobuf_package + ".Artist" 848 - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{1}name\0\u{1}bio\0\u{1}image\0\u{1}albums\0\u{1}tracks\0") 858 + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{1}name\0\u{1}bio\0\u{1}image\0\u{1}albums\0\u{1}tracks\0\u{1}genres\0") 849 859 850 860 mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws { 851 861 while let fieldNumber = try decoder.nextFieldNumber() { ··· 859 869 case 4: try { try decoder.decodeSingularStringField(value: &self._image) }() 860 870 case 5: try { try decoder.decodeRepeatedMessageField(value: &self.albums) }() 861 871 case 6: try { try decoder.decodeRepeatedMessageField(value: &self.tracks) }() 872 + case 7: try { try decoder.decodeSingularStringField(value: &self._genres) }() 862 873 default: break 863 874 } 864 875 } ··· 887 898 if !self.tracks.isEmpty { 888 899 try visitor.visitRepeatedMessageField(value: self.tracks, fieldNumber: 6) 889 900 } 901 + try { if let v = self._genres { 902 + try visitor.visitSingularStringField(value: v, fieldNumber: 7) 903 + } }() 890 904 try unknownFields.traverse(visitor: &visitor) 891 905 } 892 906 ··· 897 911 if lhs._image != rhs._image {return false} 898 912 if lhs.albums != rhs.albums {return false} 899 913 if lhs.tracks != rhs.tracks {return false} 914 + if lhs._genres != rhs._genres {return false} 900 915 if lhs.unknownFields != rhs.unknownFields {return false} 901 916 return true 902 917 }
+20 -4
macos/Rockbox/Views/ArtistDetail/ArtistHeaderView.swift
··· 36 36 .fill(artist.color.gradient) 37 37 .frame(width: 200, height: 200) 38 38 .overlay { 39 - Image(systemName: "music.mic") 40 - .font(.system(size: 60)) 41 - .foregroundStyle(.white.opacity(0.6)) 39 + AsyncImage(url: URL(string: artist.image ?? "")) { phase in 40 + switch phase { 41 + case .empty: 42 + Image(systemName: "music.mic") 43 + .font(.system(size: 60)) 44 + .foregroundStyle(.white.opacity(0.6)) 45 + case .success(let image): 46 + image 47 + .resizable() 48 + .aspectRatio(contentMode: .fill) 49 + case .failure: 50 + Image(systemName: "music.mic") 51 + .font(.system(size: 40)) 52 + .foregroundStyle(.white.opacity(0.6)) 53 + @unknown default: 54 + EmptyView() 55 + } 56 + } 42 57 } 43 - .shadow(color: .black.opacity(0.2), radius: 10, y: 5) 58 + .clipShape(Circle()) 59 + .shadow(color: .black.opacity(artist.image != nil ? 0.0 : 0.2), radius: 10, y: 5) 44 60 } 45 61 46 62 // Artist info
+23 -5
macos/Rockbox/Views/Artists/ArtistCardView.swift
··· 18 18 Circle() 19 19 .fill(artist.color.gradient) 20 20 .aspectRatio(1, contentMode: .fit) 21 - .shadow(color: .black.opacity(0.2), radius: isHovering ? 10 : 4, y: isHovering ? 6 : 2) 22 - 23 - Image(systemName: "music.mic") 24 - .font(.system(size: 40)) 25 - .foregroundStyle(.white.opacity(0.6)) 21 + .overlay { 22 + AsyncImage(url: URL(string: artist.image ?? "")) { phase in 23 + switch phase { 24 + case .empty: 25 + Image(systemName: "music.mic") 26 + .font(.system(size: 40)) 27 + .foregroundStyle(.white.opacity(0.6)) 28 + case .success(let image): 29 + image 30 + .resizable() 31 + .aspectRatio(contentMode: .fill) 32 + case .failure: 33 + Image(systemName: "music.mic") 34 + .font(.system(size: 40)) 35 + .foregroundStyle(.white.opacity(0.6)) 36 + @unknown default: 37 + EmptyView() 38 + } 39 + } 40 + } 41 + .clipShape(Circle()) 42 + .shadow(color: .black.opacity(artist.image != nil ? 0.0 : 0.2), radius: 10, y: 5) 43 + 26 44 27 45 // Play button on hover 28 46 if isHovering {
+1 -1
macos/Rockbox/Views/Artists/ArtistsGridView.swift
··· 34 34 let data = try await fetchArtists() 35 35 artists = [] 36 36 for artist in data { 37 - artists.append(Artist(cuid: artist.id, name: artist.name, genre: "", color: .gray.opacity(0.3))) 37 + artists.append(Artist(cuid: artist.id, name: artist.name, image: artist.image, genre: artist.genres, color: .gray.opacity(0.3))) 38 38 } 39 39 40 40 } catch {