From a652ae330f35535aa5f6ba3a041d7128f9f325de Mon Sep 17 00:00:00 2001 From: 409 Date: Sun, 1 Dec 2024 03:48:51 +0100 Subject: [PATCH] feat!: playlists --- proto/library.proto | 37 +++++++++ proto/player.proto | 10 +++ src/covers.rs | 12 ++- src/database/artists.rs | 4 +- src/database/mod.rs | 32 +++++++- src/database/paths.rs | 14 +--- src/database/tracks.rs | 172 +++++++++++++++++++++++++++++++++++++--- src/library.rs | 114 ++++++++++++++++++++++---- src/main.rs | 9 ++- src/music/player.rs | 26 +++++- src/player.rs | 83 +++++++++++++++++-- 11 files changed, 456 insertions(+), 57 deletions(-) diff --git a/proto/library.proto b/proto/library.proto index ee0ed1d..4be63f6 100644 --- a/proto/library.proto +++ b/proto/library.proto @@ -6,6 +6,11 @@ package library; service Library { rpc ListTracks(google.protobuf.Empty) returns (TrackList); + rpc ListPlaylists(google.protobuf.Empty) returns (ListPlaylistsResponse); + rpc CreatePlaylist(CreatePlaylistRequest) returns (CreatePlaylistResponse); + rpc DeletePlaylist(DeletePlaylistRequest) returns (google.protobuf.Empty); + rpc AddTrackToPlaylist(AddTrackToPlaylistRequest) returns (google.protobuf.Empty); + rpc RemoveTrackFromPlaylist(RemoveTrackFromPlaylistRequest) returns (google.protobuf.Empty); } message TrackList { @@ -19,3 +24,35 @@ message Track { uint64 artist_id = 4; uint64 duration = 5; } + +message Playlist { + uint32 id = 1; + string name = 2; + repeated Track tracks = 3; +} + +message ListPlaylistsResponse { + repeated Playlist playlists = 1; +} + +message CreatePlaylistRequest { + string name = 1; +} + +message CreatePlaylistResponse { + Playlist playlist = 1; +} + +message DeletePlaylistRequest { + uint32 id = 1; +} + +message AddTrackToPlaylistRequest { + uint32 playlist_id = 1; + string track_hash = 2; +} + +message RemoveTrackFromPlaylistRequest { + uint32 playlist_id = 1; + uint32 track_rank = 2; +} diff --git a/proto/player.proto b/proto/player.proto index c4c5813..15a5fae 100644 --- a/proto/player.proto +++ b/proto/player.proto @@ -15,6 +15,8 @@ service Player { rpc SetVolume(SetVolumeRequest) returns (SetVolumeResponse); rpc PlayTrackNext(TrackRequest) returns (Queue); rpc AddTrackToQueue(TrackRequest) returns (Queue); + rpc AddTracksToQueue(TracksRequest) returns (Queue); + rpc PlayPlaylist(PlayPlaylistRequest) returns (PlayerStatus); rpc SwapQueueIndices(SwapQueueIndicesRequest) returns (Queue); rpc SkipTrack(google.protobuf.Empty) returns (PlayerStatus); rpc SkipToQueueIndex(SkipToQueueIndexRequest) returns (PlayerStatus); @@ -69,3 +71,11 @@ message SwapQueueIndicesRequest { uint32 a = 1; uint32 b = 2; } + +message TracksRequest { + repeated string tracks = 1; +} + +message PlayPlaylistRequest { + uint32 id = 1; +} diff --git a/src/covers.rs b/src/covers.rs index 7cd9ca4..1965180 100644 --- a/src/covers.rs +++ b/src/covers.rs @@ -1,8 +1,8 @@ -use std::{fs, path::Path}; -use image::EncodableLayout; -use webp::Encoder; use axum::Router; +use image::EncodableLayout; +use std::{fs, path::Path}; use tower_http::services::ServeDir; +use webp::Encoder; use crate::music::metadata::CoverData; @@ -40,7 +40,11 @@ pub fn get_all_cover_hashes() -> Vec { hashes } -pub fn write_cover(hash: &str, cover: &CoverData, base_path: &str) -> Result<(), Box> { +pub fn write_cover( + hash: &str, + cover: &CoverData, + base_path: &str, +) -> Result<(), Box> { if cover.mime_type != "image/jpeg" && cover.mime_type != "image/png" && cover.mime_type != "image/webp" diff --git a/src/database/artists.rs b/src/database/artists.rs index c81c82b..2b3dcd2 100644 --- a/src/database/artists.rs +++ b/src/database/artists.rs @@ -14,9 +14,7 @@ fn map_artist(row: &Row) -> Result { }) } -pub async fn get_artists( - pool: &Pool, -) -> Result, rusqlite::Error> { +pub async fn get_artists(pool: &Pool) -> Result, rusqlite::Error> { let manager = pool.get().await.unwrap(); let connection = manager.lock().unwrap(); diff --git a/src/database/mod.rs b/src/database/mod.rs index a8cbbb9..4636a24 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -10,14 +10,14 @@ pub fn establish_connection() -> Pool { let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let cfg = Config::new(database_url); - let pool = cfg.create_pool(deadpool::Runtime::Tokio1).expect("Error creating SQLite pool"); + let pool = cfg + .create_pool(deadpool::Runtime::Tokio1) + .expect("Error creating SQLite pool"); pool } -pub async fn initialize_database( - pool: &Pool, -) -> Result<(), rusqlite::Error> { +pub async fn initialize_database(pool: &Pool) -> Result<(), rusqlite::Error> { let manager = pool.get().await.unwrap(); let conn = manager.lock().unwrap(); @@ -65,5 +65,29 @@ pub async fn initialize_database( [], )?; + conn.execute( + " + CREATE TABLE IF NOT EXISTS playlists ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + name TEXT NOT NULL + ); + ", + [], + )?; + + conn.execute( + " + CREATE TABLE IF NOT EXISTS playlist_tracks ( + playlist_id INTEGER NOT NULL, + track_hash TEXT NOT NULL, + rank INTEGER NOT NULL, + FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE, + FOREIGN KEY (track_hash) REFERENCES tracks (hash) ON DELETE CASCADE, + UNIQUE (playlist_id, rank) ON CONFLICT FAIL + ); + ", + [], + )?; + Ok(()) } diff --git a/src/database/paths.rs b/src/database/paths.rs index c081f18..f3892e2 100644 --- a/src/database/paths.rs +++ b/src/database/paths.rs @@ -16,9 +16,7 @@ fn map_library_path(row: &Row) -> Result { }) } -pub async fn get_library_paths( - pool: &Pool, -) -> Result, rusqlite::Error> { +pub async fn get_library_paths(pool: &Pool) -> Result, rusqlite::Error> { let manager = pool.get().await.unwrap(); let connection = manager.lock().unwrap(); @@ -36,10 +34,7 @@ pub async fn get_library_paths( Ok(paths) } -pub async fn get_library_path( - pool: &Pool, - id: u64, -) -> Result { +pub async fn get_library_path(pool: &Pool, id: u64) -> Result { let manager = pool.get().await.unwrap(); let connection = manager.lock().unwrap(); @@ -62,10 +57,7 @@ pub async fn insert_library_path( Ok(result > 0) } -pub async fn delete_library_path( - pool: &Pool, - path_id: u64, -) -> Result { +pub async fn delete_library_path(pool: &Pool, path_id: u64) -> Result { let manager = pool.get().await.unwrap(); let connection = manager.lock().unwrap(); diff --git a/src/database/tracks.rs b/src/database/tracks.rs index d8c363e..19dc52a 100644 --- a/src/database/tracks.rs +++ b/src/database/tracks.rs @@ -9,7 +9,7 @@ use rusqlite::{params, Row}; use crate::{ covers::{get_all_cover_hashes, get_cover_base_path, write_cover}, music::metadata::TrackMetadata, - proto, + proto::{self, library::Playlist}, }; use super::artists::get_artists; @@ -57,9 +57,17 @@ fn map_track(row: &Row) -> Result { }) } -pub async fn get_tracks( - pool: &Pool, -) -> Result, rusqlite::Error> { +/// Creates a playlist from a database row (tracks are empty and need to be filled afterwards) +/// * `row`: The database row +fn map_playlist(row: &Row) -> Result { + Ok(Playlist { + id: row.get(0)?, + name: row.get(1)?, + tracks: Vec::new(), + }) +} + +pub async fn get_tracks(pool: &Pool) -> Result, rusqlite::Error> { let manager = pool.get().await.unwrap(); let connection = manager.lock().unwrap(); @@ -77,20 +85,31 @@ pub async fn get_tracks( Ok(tracks) } -pub async fn get_track( - pool: &Pool, - hash: &str, -) -> Result { +pub async fn get_track(pool: &Pool, hash: &str) -> Result { let manager = pool.get().await.unwrap(); let connection = &manager.lock().unwrap(); connection.query_row("SELECT t.hash, t.name, t.duration, t.artist_id, a.name AS artist_name FROM tracks t INNER JOIN artists a ON a.id = t.artist_id WHERE t.hash = ?1", [hash], map_track) } -pub async fn get_track_full_path( +pub async fn get_specific_tracks( pool: &Pool, - hash: &str, -) -> Result { + hashes: &Vec, +) -> Result, rusqlite::Error> { + let manager = pool.get().await.unwrap(); + let connection = &manager.lock().unwrap(); + + let mut statement = connection.prepare("SELECT t.hash, t.name, t.duration, t.artist_id, a.name AS artist_name FROM tracks t INNER JOIN artists a ON a.id = t.artist_id WHERE t.hash = ?1")?; + let mut tracks: Vec = Vec::new(); + + for hash in hashes { + tracks.push(statement.query_row([hash], map_track)?); + } + + Ok(tracks) +} + +pub async fn get_track_full_path(pool: &Pool, hash: &str) -> Result { let manager = pool.get().await.unwrap(); let connection = &manager.lock().unwrap(); @@ -106,6 +125,35 @@ pub async fn get_track_full_path( Ok(path) } +pub async fn get_specific_tracks_full_path( + pool: &Pool, + hashes: &Vec, +) -> Result, rusqlite::Error> { + let manager = pool.get().await.unwrap(); + let connection = &manager.lock().unwrap(); + + let mut paths: Vec = Vec::new(); + + let home = home::home_dir().unwrap(); + let home_dir = home.to_str().unwrap(); + + let mut statement = connection.prepare( + "SELECT t.path, l.path AS library_root FROM tracks t INNER JOIN library_paths l ON t.library_path_id = l.id WHERE t.hash = ?1", + )?; + + for hash in hashes { + let (relative_path, library_path): (String, String) = + statement.query_row([hash], |r| Ok((r.get(0)?, r.get(1)?)))?; + + let library_root = library_path.replace("~", home_dir); + let path = Path::new(&library_root).join(&relative_path); + + paths.push(path); + } + + Ok(paths) +} + pub async fn insert_tracks( pool: &Pool, tracks: HashMap, @@ -190,3 +238,105 @@ pub async fn insert_tracks( Ok(()) } + +pub async fn get_playlists(pool: &Pool) -> Result, rusqlite::Error> { + let manager = pool.get().await.unwrap(); + let connection = manager.lock().unwrap(); + + let mut statement = connection.prepare("SELECT id, name FROM playlists")?; + let rows = statement.query_map([], map_playlist)?; + + let mut playlists: Vec = Vec::new(); + + let mut tracks_statement = connection.prepare(" + SELECT t.hash, t.name, t.duration, t.artist_id, a.name FROM playlist_tracks pt INNER JOIN tracks t ON pt.track_hash = t.hash INNER JOIN artists a ON t.artist_id = a.id WHERE pt.playlist_id = ?1 + ")?; + + for row in rows { + if let Ok(mut row) = row { + let track_rows: Vec = tracks_statement + .query_map([row.id], map_track)? + .filter_map(|t| t.ok().map(|t| t.into())) + .collect(); + row.tracks = track_rows; + + playlists.push(row); + } + } + + Ok(playlists) +} + +pub async fn get_playlist(pool: &Pool, id: u32) -> Result { + let manager = pool.get().await.unwrap(); + let connection = manager.lock().unwrap(); + + let mut playlist = connection.query_row( + "SELECT id, name FROM playlists WHERE id = ?1", + [id], + map_playlist, + )?; + + let mut tracks_statement = connection.prepare(" + SELECT t.hash, t.name, t.duration, t.artist_id, a.name FROM playlist_tracks pt INNER JOIN tracks t ON pt.track_hash = t.hash INNER JOIN artists a ON t.artist_id = a.id WHERE pt.playlist_id = ?1 + ")?; + + let track_rows: Vec = tracks_statement + .query_map([id], map_track)? + .filter_map(|t| t.ok().map(|t| t.into())) + .collect(); + playlist.tracks = track_rows; + + Ok(playlist) +} + +pub async fn create_playlist(pool: &Pool, name: &str) -> Result { + let manager = pool.get().await.unwrap(); + let connection = manager.lock().unwrap(); + + let mut statement = + connection.prepare("INSERT INTO playlists (name) VALUES (?1) RETURNING id, name")?; + let playlist = statement.query_row([name], map_playlist)?; + + Ok(playlist) +} + +pub async fn delete_playlist(pool: &Pool, id: u32) -> Result { + let manager = pool.get().await.unwrap(); + let connection = manager.lock().unwrap(); + + let mut statement = connection.prepare("DELETE FROM playlists WHERE id = ?1")?; + let len = statement.execute([id])?; + + Ok(len == 1) +} + +pub async fn add_track_to_playlist( + pool: &Pool, + playlist_id: u32, + track_hash: &str, +) -> Result { + let manager = pool.get().await.unwrap(); + let connection = manager.lock().unwrap(); + + let mut statement = connection + .prepare("INSERT INTO playlist_tracks (playlist_id, track_hash, rank) VALUES (?1, ?2, (SELECT COALESCE(MAX(rank) + 1, 0) FROM playlist_tracks WHERE playlist_id = ?1))")?; + let len = statement.execute(params![playlist_id, track_hash])?; + + Ok(len == 1) +} + +pub async fn remove_track_from_playlist( + pool: &Pool, + playlist_id: u32, + track_rank: u32, +) -> Result { + let manager = pool.get().await.unwrap(); + let connection = manager.lock().unwrap(); + + let mut statement = connection + .prepare("DELETE FROM playlist_tracks WHERE playlist_id = ?1 AND track_rank = ?2")?; + let len = statement.execute(params![playlist_id, track_rank])?; + + Ok(len == 1) +} diff --git a/src/library.rs b/src/library.rs index a1fe446..5c9ab34 100644 --- a/src/library.rs +++ b/src/library.rs @@ -1,14 +1,22 @@ -use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Instant}; use deadpool_sqlite::Pool; +use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Instant}; use tokio::{sync::Mutex, task::JoinSet}; +use tonic::{Request, Response, Status}; use walkdir::{DirEntry, WalkDir}; use crate::{ checksum::generate_hash, - database::tracks::{get_tracks, insert_tracks}, + database::tracks::{ + add_track_to_playlist, create_playlist, delete_playlist, get_playlists, get_tracks, + insert_tracks, remove_track_from_playlist, + }, music::metadata::{extract_track_data, TrackMetadata}, - proto::library::{library_server::Library, Track, TrackList}, + proto::library::{ + library_server::Library, AddTrackToPlaylistRequest, CreatePlaylistRequest, + CreatePlaylistResponse, DeletePlaylistRequest, ListPlaylistsResponse, + RemoveTrackFromPlaylistRequest, Track, TrackList, + }, state::GrooveState, }; @@ -26,12 +34,9 @@ impl LibraryService { #[tonic::async_trait] impl Library for LibraryService { - async fn list_tracks( - &self, - _request: tonic::Request<()>, - ) -> Result, tonic::Status> { + async fn list_tracks(&self, _request: Request<()>) -> Result, Status> { let Ok(tracks) = get_tracks(&self.pool).await else { - return Err(tonic::Status::internal("")); + return Err(Status::internal("")); }; let response = TrackList { @@ -47,15 +52,96 @@ impl Library for LibraryService { .collect::>(), }; - Ok(tonic::Response::new(response)) + Ok(Response::new(response)) + } + + async fn list_playlists( + &self, + _request: Request<()>, + ) -> Result, Status> { + let Ok(playlists) = get_playlists(&self.pool).await else { + return Err(Status::internal("")); + }; + + let response = ListPlaylistsResponse { playlists }; + + Ok(Response::new(response)) + } + + async fn create_playlist( + &self, + request: Request, + ) -> Result, Status> { + let input = request.get_ref(); + + let Ok(playlist) = create_playlist(&self.pool, &input.name).await else { + return Err(Status::internal("")); + }; + + let response = CreatePlaylistResponse { + playlist: Some(playlist), + }; + + Ok(Response::new(response)) + } + + async fn delete_playlist( + &self, + request: Request, + ) -> Result, Status> { + let input = request.get_ref(); + + let Ok(success) = delete_playlist(&self.pool, input.id).await else { + return Err(Status::internal("")); + }; + + if !success { + return Err(Status::internal("")); + } + + Ok(Response::new(())) + } + + async fn add_track_to_playlist( + &self, + request: Request, + ) -> Result, Status> { + let input = request.get_ref(); + + let Ok(success) = + add_track_to_playlist(&self.pool, input.playlist_id, &input.track_hash).await + else { + return Err(Status::internal("")); + }; + + if !success { + return Err(Status::internal("")); + } + + Ok(Response::new(())) + } + + async fn remove_track_from_playlist( + &self, + request: Request, + ) -> Result, Status> { + let input = request.get_ref(); + + let Ok(success) = + remove_track_from_playlist(&self.pool, input.playlist_id, input.track_rank).await + else { + return Err(Status::internal("")); + }; + + if !success { + return Err(Status::internal("")); + } + + Ok(Response::new(())) } } -pub async fn index_path( - path: PathBuf, - db: &Pool, - path_id: u64, -) -> Result<(), rusqlite::Error> { +pub async fn index_path(path: PathBuf, db: &Pool, path_id: u64) -> Result<(), rusqlite::Error> { let home = home::home_dir().unwrap(); let correct_path = path.to_str().unwrap().replace("~", home.to_str().unwrap()); diff --git a/src/main.rs b/src/main.rs index d796793..92def88 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,8 +21,8 @@ pub mod player; pub mod settings; pub mod state; -use settings::SettingsService; use music::player::start_watching; +use settings::SettingsService; pub mod proto { pub mod settings { @@ -40,7 +40,9 @@ pub mod proto { async fn main() -> Result<(), Box> { let pool = &mut establish_connection(); - initialize_database(&pool).await.expect("Error initializing database"); + initialize_database(&pool) + .await + .expect("Error initializing database"); let address = "[::1]:39993".parse()?; @@ -58,8 +60,7 @@ async fn main() -> Result<(), Box> { let mut player = player.lock().await; match next { Some(queued_track) => { - let _ = player - .play_track(queued_track.track, queued_track.path, false); + let _ = player.play_track(queued_track.track, queued_track.path, false); } None => { player.clear(); diff --git a/src/music/player.rs b/src/music/player.rs index 7705c5e..642ec25 100644 --- a/src/music/player.rs +++ b/src/music/player.rs @@ -2,7 +2,7 @@ use std::{ collections::VecDeque, fs, io::{BufReader, Cursor}, - path::Path, + path::{Path, PathBuf}, sync::Arc, time::Duration, }; @@ -97,6 +97,30 @@ impl AudioPlayer { Ok(()) } + pub async fn add_tracks_to_queue( + &mut self, + tracks: Vec<(Track, PathBuf)>, + clear_queue: bool, + ) -> Result<(), Box> { + if clear_queue { + self.clear(); + } + + for (track, path) in tracks { + self.queue.push_back(QueuedTrack { track, path }); + } + + if !self.sink.empty() || self.queue.is_empty() { + return Ok(()); + } + + if let Some(queued_track) = self.queue.pop_front() { + self.play_track(queued_track.track, queued_track.path, false)?; + }; + + Ok(()) + } + pub fn skip_track(&mut self) -> Result<(), Box> { let Some(queued_track) = self.queue.pop_front() else { self.clear(); diff --git a/src/player.rs b/src/player.rs index d89fa81..ef7cb63 100644 --- a/src/player.rs +++ b/src/player.rs @@ -1,17 +1,21 @@ use deadpool_sqlite::Pool; -use std::{pin::Pin, time::Duration}; +use std::{path::PathBuf, pin::Pin, time::Duration}; use tokio::sync::mpsc; use tokio_stream::{wrappers::ReceiverStream, Stream}; use crate::{ - database::tracks::{get_track, get_track_full_path}, + database::tracks::{ + get_playlist, get_specific_tracks, get_specific_tracks_full_path, get_track, + get_track_full_path, Track, + }, music::queue::queue_to_track_vec, proto::{ self, player::{ - player_server::Player, PauseState, PlayTrackResponse, PlayerStatus, Queue, - SeekPositionRequest, SeekPositionResponse, SetVolumeRequest, SetVolumeResponse, - SkipToQueueIndexRequest, SwapQueueIndicesRequest, TrackRequest, + player_server::Player, PauseState, PlayPlaylistRequest, PlayTrackResponse, + PlayerStatus, Queue, SeekPositionRequest, SeekPositionResponse, SetVolumeRequest, + SetVolumeResponse, SkipToQueueIndexRequest, SwapQueueIndicesRequest, TrackRequest, + TracksRequest, }, }, state::GrooveState, @@ -198,6 +202,75 @@ impl Player for PlayerService { Ok(tonic::Response::new(response)) } + async fn add_tracks_to_queue( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let input = request.get_ref(); + + let Ok(tracks) = get_specific_tracks(&self.pool, &input.tracks).await else { + return Err(tonic::Status::not_found("")); + }; + + let Ok(track_paths) = get_specific_tracks_full_path(&self.pool, &input.tracks).await else { + return Err(tonic::Status::not_found("")); + }; + + let state = self.state.lock().await; + let mut player = state.player.lock().await; + + let tracks_and_paths: Vec<(Track, PathBuf)> = + tracks.into_iter().zip(track_paths.into_iter()).collect(); + + if let Err(_) = player.add_tracks_to_queue(tracks_and_paths, false).await { + return Err(tonic::Status::internal("")); + } + + let queue = player.queue(); + + let response = Queue { + tracks: queue_to_track_vec(queue), + }; + + Ok(tonic::Response::new(response)) + } + + async fn play_playlist( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let input = request.get_ref(); + + let Ok(playlist) = get_playlist(&self.pool, input.id).await else { + return Err(tonic::Status::not_found("")); + }; + + let Ok(track_paths) = get_specific_tracks_full_path( + &self.pool, + &playlist.tracks.iter().map(|t| t.hash.clone()).collect(), + ) + .await + else { + return Err(tonic::Status::not_found("")); + }; + + let state = self.state.lock().await; + let mut player = state.player.lock().await; + + let tracks = playlist.tracks.iter().map(|t| t.clone().into()); + + let tracks_and_paths: Vec<(Track, PathBuf)> = + tracks.into_iter().zip(track_paths.into_iter()).collect(); + + if let Err(_) = player.add_tracks_to_queue(tracks_and_paths, true).await { + return Err(tonic::Status::internal("")); + } + + let response = player.get_snapshot().into(); + + Ok(tonic::Response::new(response)) + } + async fn play_track_next( &self, request: tonic::Request,