feat!: playlists

This commit is contained in:
2024-12-01 03:48:51 +01:00
parent 634c147ee9
commit a652ae330f
11 changed files with 456 additions and 57 deletions

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<String> {
hashes
}
pub fn write_cover(hash: &str, cover: &CoverData, base_path: &str) -> Result<(), Box<dyn std::error::Error>> {
pub fn write_cover(
hash: &str,
cover: &CoverData,
base_path: &str,
) -> Result<(), Box<dyn std::error::Error>> {
if cover.mime_type != "image/jpeg"
&& cover.mime_type != "image/png"
&& cover.mime_type != "image/webp"

View File

@@ -14,9 +14,7 @@ fn map_artist(row: &Row) -> Result<Artist, rusqlite::Error> {
})
}
pub async fn get_artists(
pool: &Pool,
) -> Result<Vec<Artist>, rusqlite::Error> {
pub async fn get_artists(pool: &Pool) -> Result<Vec<Artist>, rusqlite::Error> {
let manager = pool.get().await.unwrap();
let connection = manager.lock().unwrap();

View File

@@ -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(())
}

View File

@@ -16,9 +16,7 @@ fn map_library_path(row: &Row) -> Result<LibraryPath, rusqlite::Error> {
})
}
pub async fn get_library_paths(
pool: &Pool,
) -> Result<Vec<LibraryPath>, rusqlite::Error> {
pub async fn get_library_paths(pool: &Pool) -> Result<Vec<LibraryPath>, 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<LibraryPath, rusqlite::Error> {
pub async fn get_library_path(pool: &Pool, id: u64) -> Result<LibraryPath, rusqlite::Error> {
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<bool, rusqlite::Error> {
pub async fn delete_library_path(pool: &Pool, path_id: u64) -> Result<bool, rusqlite::Error> {
let manager = pool.get().await.unwrap();
let connection = manager.lock().unwrap();

View File

@@ -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<Track, rusqlite::Error> {
})
}
pub async fn get_tracks(
pool: &Pool,
) -> Result<Vec<Track>, 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<Playlist, rusqlite::Error> {
Ok(Playlist {
id: row.get(0)?,
name: row.get(1)?,
tracks: Vec::new(),
})
}
pub async fn get_tracks(pool: &Pool) -> Result<Vec<Track>, 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<Track, rusqlite::Error> {
pub async fn get_track(pool: &Pool, hash: &str) -> Result<Track, rusqlite::Error> {
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<PathBuf, rusqlite::Error> {
hashes: &Vec<String>,
) -> Result<Vec<Track>, 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<Track> = 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<PathBuf, rusqlite::Error> {
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<String>,
) -> Result<Vec<PathBuf>, rusqlite::Error> {
let manager = pool.get().await.unwrap();
let connection = &manager.lock().unwrap();
let mut paths: Vec<PathBuf> = 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<String, TrackMetadata>,
@@ -190,3 +238,105 @@ pub async fn insert_tracks(
Ok(())
}
pub async fn get_playlists(pool: &Pool) -> Result<Vec<Playlist>, 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<Playlist> = 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<proto::library::Track> = 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<Playlist, rusqlite::Error> {
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<proto::library::Track> = 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<Playlist, rusqlite::Error> {
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<bool, rusqlite::Error> {
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<bool, rusqlite::Error> {
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<bool, rusqlite::Error> {
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)
}

View File

@@ -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::Response<TrackList>, tonic::Status> {
async fn list_tracks(&self, _request: Request<()>) -> Result<Response<TrackList>, 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::<Vec<Track>>(),
};
Ok(tonic::Response::new(response))
Ok(Response::new(response))
}
async fn list_playlists(
&self,
_request: Request<()>,
) -> Result<Response<ListPlaylistsResponse>, 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<CreatePlaylistRequest>,
) -> Result<Response<CreatePlaylistResponse>, 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<DeletePlaylistRequest>,
) -> Result<Response<()>, 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<AddTrackToPlaylistRequest>,
) -> Result<Response<()>, 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<RemoveTrackFromPlaylistRequest>,
) -> Result<Response<()>, 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());

View File

@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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();

View File

@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
let Some(queued_track) = self.queue.pop_front() else {
self.clear();

View File

@@ -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<TracksRequest>,
) -> Result<tonic::Response<Queue>, 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<PlayPlaylistRequest>,
) -> Result<tonic::Response<PlayerStatus>, 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<TrackRequest>,