feat: basic music backend

This commit is contained in:
2024-11-24 00:10:56 +01:00
commit 51a57ebd40
23 changed files with 4091 additions and 0 deletions

2
.env Normal file
View File

@@ -0,0 +1,2 @@
DATABASE_URL=/home/j409/.config/groove/groove.db
COVER_URL=/home/j409/.config/groove/covers

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

2984
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

28
Cargo.toml Normal file
View File

@@ -0,0 +1,28 @@
[package]
name = "groove"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7.9"
dotenvy = "0.15.7"
hex = "0.4.3"
home = "0.5.9"
image = "0.25.5"
prost = "0.13.3"
r2d2 = "0.8.10"
r2d2_sqlite = { version = "0.25.0", features = ["bundled"] }
rayon = "1.10.0"
rodio = "0.20.1"
rusqlite = { version = "0.32.1", features = ["bundled"] }
sha2 = "0.10.8"
symphonia = { version = "0.5.4", features = ["mp3"] }
tokio = { version = "1.41.1", features = ["full"] }
tonic = "0.12.3"
tonic-web = "0.12.3"
tower-http = { version = "0.6.2", features = ["cors", "fs"] }
walkdir = "2.5.0"
webp = "0.3.0"
[build-dependencies]
tonic-build = "0.12.3"

9
build.rs Normal file
View File

@@ -0,0 +1,9 @@
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
tonic_build::compile_protos("proto/settings.proto")?;
tonic_build::compile_protos("proto/library.proto")?;
tonic_build::compile_protos("proto/player.proto")?;
Ok(())
}

20
proto/library.proto Normal file
View File

@@ -0,0 +1,20 @@
syntax = "proto3";
import 'google/protobuf/empty.proto';
package library;
service Library {
rpc ListTracks(google.protobuf.Empty) returns (TrackList);
}
message TrackList {
repeated Track tracks = 1;
}
message Track {
string hash = 1;
string name = 2;
string artist_name = 3;
uint64 artist_id = 4;
}

18
proto/player.proto Normal file
View File

@@ -0,0 +1,18 @@
syntax = "proto3";
import 'google/protobuf/empty.proto';
package player;
service Player {
rpc PlayTrack(PlayTrackRequest) returns (PlayTrackResponse);
rpc ResumeTrack(google.protobuf.Empty) returns (google.protobuf.Empty);
rpc PauseTrack(google.protobuf.Empty) returns (google.protobuf.Empty);
}
message PlayTrackRequest {
string hash = 1;
}
message PlayTrackResponse {
}

43
proto/settings.proto Normal file
View File

@@ -0,0 +1,43 @@
syntax = "proto3";
import 'google/protobuf/empty.proto';
package settings;
service Settings {
rpc ListPaths(google.protobuf.Empty) returns (SettingsData);
rpc AddPath(AddPathRequest) returns (AddPathResponse);
rpc DeletePath(DeletePathRequest) returns (DeletePathResponse);
rpc RefreshPath(RefreshPathRequest) returns (RefreshPathResponse);
}
message SettingsData {
repeated LibraryPath library_paths = 1;
}
message LibraryPath {
uint64 id = 1;
string path = 2;
}
message AddPathRequest {
string path = 1;
}
message AddPathResponse {
uint64 id = 1;
}
message DeletePathRequest {
uint64 id = 1;
}
message DeletePathResponse {
}
message RefreshPathRequest {
uint64 id = 1;
}
message RefreshPathResponse {
}

11
src/checksum.rs Normal file
View File

@@ -0,0 +1,11 @@
use sha2::{Digest, Sha256, self};
pub fn generate_hash(content: impl AsRef<[u8]>) -> String {
let mut hasher = Sha256::new();
hasher.update(content);
let result = hasher.finalize();
let hex = hex::encode(result);
hex
}

96
src/covers.rs Normal file
View File

@@ -0,0 +1,96 @@
use std::{fs, path::Path, time::Instant};
use axum::Router;
use tower_http::services::ServeDir;
use webp::Encoder;
use crate::music::metadata::CoverData;
pub fn get_cover_base_path() -> String {
dotenvy::dotenv().ok();
std::env::var("COVER_URL").expect("Error getting cover directory URL")
}
pub fn get_all_cover_hashes() -> Vec<String> {
let path = get_cover_base_path();
let base_path = Path::new(&path);
if !base_path.exists() {
let _ = fs::create_dir_all(base_path);
}
let walkdir = walkdir::WalkDir::new(path).min_depth(1).max_depth(1);
let hashes: Vec<String> = walkdir
.into_iter()
.map(|e| {
let entry = e.unwrap();
let file_name = entry.file_name().to_str().unwrap();
// len - 5 because the file names end with .webp
let hash = file_name[0..(file_name.len() - 5)].to_string();
return hash;
})
.collect();
hashes
}
pub fn write_cover(hash: String, cover: CoverData) -> Result<(), Box<dyn std::error::Error>> {
let now = Instant::now();
if cover.mime_type != "image/jpeg"
&& cover.mime_type != "image/png"
&& cover.mime_type != "image/webp"
{
return Err(format!("Invalid cover MIME type: {}", cover.mime_type).into());
}
let base_path = get_cover_base_path();
let path = Path::new(&base_path).join(format!("{hash}.webp"));
let dynamic_image = image::load_from_memory_with_format(
&cover.bytes,
match cover.mime_type.as_str() {
"image/png" => image::ImageFormat::Png,
"image/jpeg" => image::ImageFormat::Jpeg,
"image/webp" => image::ImageFormat::WebP,
_ => panic!("Invalid cover MIME type (this should never happen)"),
},
)?;
let resized_image = if dynamic_image.width() > 640 || dynamic_image.height() > 640 {
dynamic_image.resize_to_fill(640, 640, image::imageops::FilterType::Lanczos3)
} else {
dynamic_image
};
let webp_encoder = Encoder::from_image(&resized_image)?;
let encoded_image = webp_encoder.encode_lossless().to_vec();
fs::write(path, encoded_image)?;
let elapsed = now.elapsed();
println!("Writing '{}' cover took {:.?}", hash, elapsed);
Ok(())
}
pub async fn create_cover_server() -> Result<(), Box<dyn std::error::Error>> {
let path = get_cover_base_path();
let service = ServeDir::new(path);
let app = Router::new().nest_service("/", service);
let listener = tokio::net::TcpListener::bind("[::1]:39994").await?;
Ok(axum::serve(listener, app).await?)
}

37
src/database/albums.rs Normal file
View File

@@ -0,0 +1,37 @@
use r2d2::PooledConnection;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::Row;
#[derive(Debug)]
pub struct Album {
pub id: String,
pub name: String,
pub artist_name: String,
pub artist_id: u64,
}
fn map_track(row: &Row) -> Result<Track, rusqlite::Error> {
Ok(Track {
hash: row.get(0)?,
name: row.get(1)?,
artist_id: row.get(2)?,
artist_name: row.get(3)?,
})
}
pub fn get_tracks(
connection: &PooledConnection<SqliteConnectionManager>,
) -> Result<Vec<Track>, rusqlite::Error> {
let mut statement = connection.prepare("SELECT t.hash, t.name, t.artist_id, a.name AS artist_name FROM tracks t INNER JOIN artists a ON a.id = t.artist_id")?;
let rows = statement.query_map([], map_track)?;
let mut tracks: Vec<Track> = Vec::new();
for row in rows {
if let Ok(row) = row {
tracks.push(row);
}
}
Ok(tracks)
}

33
src/database/artists.rs Normal file
View File

@@ -0,0 +1,33 @@
use r2d2::PooledConnection;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::Row;
#[derive(Debug)]
pub struct Artist {
pub id: u64,
pub name: String,
}
fn map_artist(row: &Row) -> Result<Artist, rusqlite::Error> {
Ok(Artist {
id: row.get(0)?,
name: row.get(1)?,
})
}
pub fn get_artists(
connection: &PooledConnection<SqliteConnectionManager>,
) -> Result<Vec<Artist>, rusqlite::Error> {
let mut statement = connection.prepare("SELECT id, name FROM artists")?;
let rows = statement.query_map([], map_artist)?;
let mut artists: Vec<Artist> = Vec::new();
for row in rows {
if let Ok(row) = row {
artists.push(row);
}
}
Ok(artists)
}

57
src/database/mod.rs Normal file
View File

@@ -0,0 +1,57 @@
pub mod paths;
pub mod tracks;
pub mod artists;
use r2d2::{Pool, PooledConnection};
use r2d2_sqlite::SqliteConnectionManager;
pub fn establish_connection() -> Pool<SqliteConnectionManager> {
dotenvy::dotenv().ok();
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let manager = SqliteConnectionManager::file(database_url);
let pool = Pool::new(manager).expect("Error creating SQLite pool");
pool
}
pub fn initialize_database(
connection: &PooledConnection<SqliteConnectionManager>,
) -> Result<(), r2d2_sqlite::rusqlite::Error> {
connection.execute(
"
CREATE TABLE IF NOT EXISTS library_paths (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
path TEXT NOT NULL
);
",
[],
)?;
connection.execute(
"CREATE TABLE IF NOT EXISTS artists (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL
)",
[],
)?;
connection
.execute(
"
CREATE TABLE IF NOT EXISTS tracks (
hash TEXT PRIMARY KEY NOT NULL,
library_path_id INTEGER NOT NULL,
name TEXT NOT NULL,
artist_id INTEGER NOT NULL,
path TEXT NOT NULL,
FOREIGN KEY (library_path_id) REFERENCES library_paths (id) ON DELETE CASCADE,
FOREIGN KEY (artist_id) REFERENCES artists (id) ON DELETE CASCADE
);
",
[],
)?;
Ok(())
}

64
src/database/paths.rs Normal file
View File

@@ -0,0 +1,64 @@
use r2d2::PooledConnection;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::Row;
#[derive(Debug)]
pub struct LibraryPath {
pub id: u64,
pub path: String,
}
pub type LibraryPathInsertData = String;
fn map_library_path(row: &Row) -> Result<LibraryPath, rusqlite::Error> {
Ok(LibraryPath {
id: row.get(0)?,
path: row.get(1)?,
})
}
pub fn get_library_paths(
connection: &PooledConnection<SqliteConnectionManager>,
) -> Result<Vec<LibraryPath>, rusqlite::Error> {
let mut statement = connection.prepare("SELECT id, path FROM library_paths")?;
let rows = statement.query_map([], map_library_path)?;
let mut paths: Vec<LibraryPath> = Vec::new();
for row in rows {
if let Ok(row) = row {
paths.push(row);
}
}
Ok(paths)
}
pub fn get_library_path(
connection: &PooledConnection<SqliteConnectionManager>,
id: u64,
) -> Result<LibraryPath, rusqlite::Error> {
Ok(connection.query_row(
"SELECT id, path FROM library_paths WHERE id = ?1",
[id],
map_library_path,
)?)
}
pub fn insert_library_path(
connection: &PooledConnection<SqliteConnectionManager>,
path: LibraryPathInsertData,
) -> Result<bool, rusqlite::Error> {
let result = connection.execute("INSERT INTO library_paths (path) VALUES (?1)", [path])?;
Ok(result > 0)
}
pub fn delete_library_path(
connection: &PooledConnection<SqliteConnectionManager>,
path_id: u64,
) -> Result<bool, rusqlite::Error> {
let result = connection.execute("DELETE FROM library_paths WHERE id = ?1", [path_id])?;
Ok(result > 0)
}

154
src/database/tracks.rs Normal file
View File

@@ -0,0 +1,154 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
thread::JoinHandle,
};
use r2d2::PooledConnection;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{params, Row};
use crate::{
covers::{get_all_cover_hashes, write_cover},
music::metadata::TrackMetadata,
};
use super::artists::get_artists;
#[derive(Debug)]
pub struct Track {
pub hash: String,
pub name: String,
pub artist_name: String,
pub artist_id: u64,
}
fn map_track(row: &Row) -> Result<Track, rusqlite::Error> {
Ok(Track {
hash: row.get(0)?,
name: row.get(1)?,
artist_id: row.get(2)?,
artist_name: row.get(3)?,
})
}
pub fn get_tracks(
connection: &PooledConnection<SqliteConnectionManager>,
) -> Result<Vec<Track>, rusqlite::Error> {
let mut statement = connection.prepare("SELECT t.hash, t.name, t.artist_id, a.name AS artist_name FROM tracks t INNER JOIN artists a ON a.id = t.artist_id")?;
let rows = statement.query_map([], map_track)?;
let mut tracks: Vec<Track> = Vec::new();
for row in rows {
if let Ok(row) = row {
tracks.push(row);
}
}
Ok(tracks)
}
pub fn get_track(
connection: &PooledConnection<SqliteConnectionManager>,
hash: &str,
) -> Result<Track, rusqlite::Error> {
connection.query_row("SELECT t.hash, t.name, 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 fn get_track_full_path(
connection: &PooledConnection<SqliteConnectionManager>,
hash: &str,
) -> Result<PathBuf, rusqlite::Error> {
let (relative_path, library_path): (String, String) = connection.query_row(
"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",
[hash],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
let library_root = library_path.replace("~", home::home_dir().unwrap().to_str().unwrap());
let path = Path::new(&library_root).join(&relative_path);
Ok(path)
}
pub fn insert_tracks(
mut connection: PooledConnection<SqliteConnectionManager>,
tracks: HashMap<String, TrackMetadata>,
library_path_id: u64,
) -> Result<(), rusqlite::Error> {
let existing_covers = get_all_cover_hashes();
let artists = get_artists(&connection)?;
let mut artist_names_to_id: HashMap<String, u64> = HashMap::new();
for artist in artists {
artist_names_to_id.insert(artist.name, artist.id);
}
let mut new_artists: Vec<String> = Vec::new();
for (_hash, meta) in &tracks {
if artist_names_to_id.contains_key(&meta.artist_name)
|| new_artists.contains(&meta.artist_name)
{
continue;
}
new_artists.push(meta.artist_name.clone());
}
// BEGIN TRANSACTION
let tx = connection.transaction()?;
{
let mut insert_artist_statement =
tx.prepare("INSERT INTO artists (name) VALUES (?1) RETURNING id")?;
for artist in new_artists {
let id: u64 = insert_artist_statement.query_row([&artist], |r| r.get(0))?;
artist_names_to_id.insert(artist, id);
}
}
// COMMIT
tx.commit()?;
// BEGIN TRANSACTION
let tx = connection.transaction()?;
let mut cover_handles: Vec<JoinHandle<()>> = Vec::new();
{
let mut statement =
tx.prepare("INSERT OR REPLACE INTO tracks (hash, library_path_id, name, artist_id, path) VALUES (?1, ?2, ?3, ?4, ?5)")?;
for (hash, meta) in tracks {
statement.execute(params![
&hash,
library_path_id,
meta.name,
artist_names_to_id[&meta.artist_name],
meta.path,
])?;
if let Some(cover) = meta.cover {
if !existing_covers.contains(&hash) {
cover_handles.push(std::thread::spawn(|| {
let _ = write_cover(hash, cover);
}));
}
}
}
}
// COMMIT
tx.commit()?;
for handle in cover_handles {
let _ = handle.join();
}
Ok(())
}

120
src/library.rs Normal file
View File

@@ -0,0 +1,120 @@
use rayon::prelude::*;
use std::{collections::HashMap, fs, path::PathBuf, time::Instant};
use r2d2::{Pool, PooledConnection};
use r2d2_sqlite::SqliteConnectionManager;
use walkdir::{DirEntry, WalkDir};
use crate::{
checksum::generate_hash,
database::tracks::{get_tracks, insert_tracks},
music::metadata::{extract_track_data, TrackMetadata},
proto::{self, library_server::Library},
state::GrooveState,
};
pub struct LibraryService {
#[allow(dead_code)]
state: GrooveState,
pool: Pool<SqliteConnectionManager>,
}
impl LibraryService {
pub fn new(state: GrooveState, pool: Pool<SqliteConnectionManager>) -> Self {
Self { state, pool }
}
}
#[tonic::async_trait]
impl Library for LibraryService {
async fn list_tracks(
&self,
_request: tonic::Request<()>,
) -> Result<tonic::Response<proto::TrackList>, tonic::Status> {
let Ok(db) = self.pool.get() else {
return Err(tonic::Status::internal(""));
};
let Ok(tracks) = get_tracks(&db) else {
return Err(tonic::Status::internal(""));
};
let response = proto::TrackList {
tracks: tracks
.iter()
.map(|t| proto::Track {
hash: t.hash.clone(),
name: t.name.clone(),
artist_name: t.artist_name.clone(),
artist_id: t.artist_id,
})
.collect::<Vec<proto::Track>>(),
};
Ok(tonic::Response::new(response))
}
}
pub fn index_path(
path: PathBuf,
db: PooledConnection<SqliteConnectionManager>,
path_id: u64,
) -> Result<(), rusqlite::Error> {
let home = home::home_dir().unwrap();
let correct_path = path.to_str().unwrap().replace("~", home.to_str().unwrap());
let path_offset = correct_path.len() + 1;
let now = Instant::now();
let entries: Vec<DirEntry> = WalkDir::new(correct_path)
.into_iter()
.filter_map(Result::ok)
.collect();
let hashmaps: Vec<HashMap<String, TrackMetadata>> = entries
.par_iter()
.fold(
|| HashMap::new(),
|mut acc: HashMap<String, TrackMetadata>, entry| {
if entry.file_type().is_file()
&& entry.path().extension().is_some_and(|ext| ext == "mp3")
{
let file_path = entry.path();
let content = fs::read(file_path).unwrap();
let hash = generate_hash(&content);
let relative_path =
file_path.to_str().unwrap().to_string()[path_offset..].to_string();
if let Some(metadata) = extract_track_data(content, relative_path) {
acc.insert(hash, metadata);
}
}
acc
},
)
.collect();
let mut tracks = HashMap::<String, TrackMetadata>::new();
for tracks_chunk in hashmaps {
tracks.extend(tracks_chunk);
}
let elapsed = now.elapsed();
println!("indexing took {:.2?}", elapsed);
let now = Instant::now();
insert_tracks(db, tracks, path_id)?;
let elapsed = now.elapsed();
println!("inserting took {:.2?}", elapsed);
Ok(())
}

71
src/main.rs Normal file
View File

@@ -0,0 +1,71 @@
use covers::create_cover_server;
use database::{establish_connection, initialize_database};
use library::LibraryService;
use music::player::AudioPlayer;
use player::PlayerService;
use proto::library_server::LibraryServer;
use proto::player_server::PlayerServer;
use proto::settings_server::SettingsServer;
use rodio::{OutputStream, Sink};
use state::{GrooveState, GrooveStateData};
use tokio::sync::RwLock;
use tonic::transport::Server;
pub mod checksum;
pub mod covers;
pub mod database;
pub mod library;
pub mod music;
pub mod player;
pub mod settings;
pub mod state;
use settings::SettingsService;
pub mod proto {
tonic::include_proto!("settings");
tonic::include_proto!("library");
tonic::include_proto!("player");
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let pool = &mut establish_connection();
let connection = pool.get().unwrap();
initialize_database(&connection).expect("Error initializing database");
let address = "[::1]:39993".parse()?;
let (_stream, stream_handle) =
OutputStream::try_default().expect("Error getting audio output stream");
let sink = Sink::try_new(&stream_handle).expect("Error getting audio sink");
let player = AudioPlayer::new(sink);
let state = GrooveState::new(RwLock::new(GrooveStateData::new(player)));
let settings = SettingsService::new(state.clone(), pool.clone());
let library = LibraryService::new(state.clone(), pool.clone());
let player_service = PlayerService::new(state, pool.clone());
let cover_server_handle = tokio::spawn(async move {
create_cover_server()
.await
.expect("Error creating cover server");
});
Server::builder()
.accept_http1(true)
.layer(tower_http::cors::CorsLayer::permissive())
.add_service(tonic_web::enable(SettingsServer::new(settings)))
.add_service(tonic_web::enable(LibraryServer::new(library)))
.add_service(tonic_web::enable(PlayerServer::new(player_service)))
.serve(address)
.await?;
let _ = cover_server_handle.await;
Ok(())
}

101
src/music/metadata.rs Normal file
View File

@@ -0,0 +1,101 @@
use std::io::Cursor;
use symphonia::core::{
formats::FormatOptions,
io::{MediaSourceStream, MediaSourceStreamOptions},
meta::MetadataOptions,
probe::Hint,
units::TimeBase,
};
#[derive(Debug)]
pub struct TrackMetadata {
pub name: String,
pub artist_name: String,
pub album: Option<String>,
pub album_artist: Option<String>,
pub total_seconds: u64,
pub path: String,
pub cover: Option<CoverData>,
}
#[derive(Debug)]
pub struct CoverData {
pub bytes: Vec<u8>,
pub mime_type: String,
}
pub fn extract_track_data(file: Vec<u8>, path: String) -> Option<TrackMetadata> {
let probe = symphonia::default::get_probe();
let format_options = FormatOptions::default();
let metadata_options = MetadataOptions::default();
let source = Box::new(Cursor::new(file));
let source_stream = MediaSourceStream::new(source, MediaSourceStreamOptions::default());
let probe_result = probe.format(
&Hint::new(),
source_stream,
&format_options,
&metadata_options,
);
let Ok(mut probe_result) = probe_result else {
return None;
};
// Amount of samples divided by the sample rate is your duration in seconds.
let track = probe_result.format.default_track().unwrap();
let n_frames = track.codec_params.n_frames.unwrap();
let total_seconds =
TimeBase::calc_time(&track.codec_params.time_base.unwrap(), n_frames).seconds;
let Some(metadata) = probe_result.metadata.get() else {
return None;
};
let Some(current_metadata) = metadata.current() else {
return None;
};
let mut title: Option<String> = None;
let mut artist: Option<String> = None;
let mut album_artist: Option<String> = None;
let mut album: Option<String> = None;
for tag in current_metadata.tags() {
match tag.key.as_str() {
"TIT2" => title = Some(tag.value.to_string()),
"TPE1" => artist = Some(tag.value.to_string()),
"TPE2" => album_artist = Some(tag.value.to_string()),
"TALB" => album = Some(tag.value.to_string()),
_ => (),
}
}
let mut cover: Option<CoverData> = None;
for visual in current_metadata.visuals() {
let mime_type = visual.media_type.clone();
if mime_type != "image/png" && mime_type != "image/jpeg" && mime_type != "image/webp" {
continue;
}
cover = Some(CoverData {
bytes: visual.data.to_vec(),
mime_type,
});
}
Some(TrackMetadata {
name: title?,
artist_name: artist?,
album,
album_artist,
total_seconds,
path,
cover,
})
}

2
src/music/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod metadata;
pub mod player;

40
src/music/player.rs Normal file
View File

@@ -0,0 +1,40 @@
use std::{
fs,
io::{BufReader, Cursor}, path::Path,
};
use rodio::{Decoder, Sink, Source};
pub struct AudioPlayer {
pub sink: Sink,
}
impl AudioPlayer {
pub fn new(sink: Sink) -> Self {
Self {
sink
}
}
pub fn play_song<P>(&mut self, path: P) -> Result<(), Box<dyn std::error::Error>> where P: AsRef<Path> {
self.sink.clear();
let file = BufReader::new(Cursor::new(fs::read(path)?));
let source = Decoder::new(file)?.amplify(0.2);
self.sink.append(source);
self.sink.play();
Ok(())
}
pub fn resume(&mut self) {
self.sink.play();
}
pub fn pause(&mut self) {
self.sink.pause();
}
}

66
src/player.rs Normal file
View File

@@ -0,0 +1,66 @@
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use crate::{
database::tracks::get_track_full_path, proto::{player_server::Player, PlayTrackRequest, PlayTrackResponse}, state::GrooveState
};
pub struct PlayerService {
state: GrooveState,
pool: Pool<SqliteConnectionManager>,
}
impl PlayerService {
pub fn new(state: GrooveState, pool: Pool<SqliteConnectionManager>) -> Self {
Self { state, pool }
}
}
#[tonic::async_trait]
impl Player for PlayerService {
async fn play_track(
&self,
request: tonic::Request<PlayTrackRequest>,
) -> Result<tonic::Response<PlayTrackResponse>, tonic::Status> {
let Ok(db) = self.pool.get() else {
return Err(tonic::Status::internal(""));
};
let input = request.get_ref();
let Ok(track_path) = get_track_full_path(&db, input.hash.as_str()) else {
return Err(tonic::Status::not_found(""));
};
let mut state = self.state.write().await;
let _ = state.player.play_song(track_path);
let response = PlayTrackResponse {
};
Ok(tonic::Response::new(response))
}
async fn resume_track(
&self,
_request: tonic::Request<()>,
) -> Result<tonic::Response<()>, tonic::Status> {
let mut state = self.state.write().await;
state.player.resume();
Ok(tonic::Response::new(()))
}
async fn pause_track(
&self,
_request: tonic::Request<()>,
) -> Result<tonic::Response<()>, tonic::Status> {
let mut state = self.state.write().await;
state.player.pause();
Ok(tonic::Response::new(()))
}
}

117
src/settings.rs Normal file
View File

@@ -0,0 +1,117 @@
use crate::database::paths::{
delete_library_path, get_library_path, get_library_paths, insert_library_path,
};
use crate::library::index_path;
use crate::proto;
use crate::state::GrooveState;
use proto::settings_server::Settings;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
pub struct SettingsService {
#[allow(dead_code)]
state: GrooveState,
pool: Pool<SqliteConnectionManager>,
}
impl SettingsService {
pub fn new(state: GrooveState, pool: Pool<SqliteConnectionManager>) -> Self {
Self { state, pool }
}
}
#[tonic::async_trait]
impl Settings for SettingsService {
async fn list_paths(
&self,
_request: tonic::Request<()>,
) -> Result<tonic::Response<proto::SettingsData>, tonic::Status> {
let Ok(db) = self.pool.get() else {
return Err(tonic::Status::internal(""));
};
let Ok(library_paths) = get_library_paths(&db) else {
return Err(tonic::Status::internal(""));
};
let response = proto::SettingsData {
library_paths: library_paths
.iter()
.map(|p| proto::LibraryPath {
id: p.id,
path: p.path.clone(),
})
.collect(),
};
Ok(tonic::Response::new(response))
}
async fn add_path(
&self,
request: tonic::Request<proto::AddPathRequest>,
) -> Result<tonic::Response<proto::AddPathResponse>, tonic::Status> {
let input = request.into_inner();
let Ok(db) = self.pool.get() else {
return Err(tonic::Status::internal(""));
};
let Ok(insert_result) = insert_library_path(&db, input.path) else {
return Err(tonic::Status::internal(""));
};
if !insert_result {
return Err(tonic::Status::internal(""));
}
let response = proto::AddPathResponse { id: 0 };
Ok(tonic::Response::new(response))
}
async fn delete_path(
&self,
request: tonic::Request<proto::DeletePathRequest>,
) -> Result<tonic::Response<proto::DeletePathResponse>, tonic::Status> {
let input = request.into_inner();
let Ok(db) = self.pool.get() else {
return Err(tonic::Status::internal(""));
};
let Ok(delete_result) = delete_library_path(&db, input.id) else {
return Err(tonic::Status::internal(""));
};
if !delete_result {
return Err(tonic::Status::not_found(""));
}
let response = proto::DeletePathResponse {};
Ok(tonic::Response::new(response))
}
async fn refresh_path(
&self,
request: tonic::Request<proto::RefreshPathRequest>,
) -> Result<tonic::Response<proto::RefreshPathResponse>, tonic::Status> {
let input = request.into_inner();
let Ok(db) = self.pool.get() else {
return Err(tonic::Status::internal(""));
};
let Ok(library_path) = get_library_path(&db, input.id) else {
return Err(tonic::Status::not_found(""));
};
let _ = index_path(library_path.path.into(), db, library_path.id);
let response = proto::RefreshPathResponse {};
Ok(tonic::Response::new(response))
}
}

17
src/state.rs Normal file
View File

@@ -0,0 +1,17 @@
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::music::player::AudioPlayer;
pub type GrooveState = Arc<RwLock<GrooveStateData>>;
pub struct GrooveStateData {
pub player: AudioPlayer,
}
impl GrooveStateData {
pub fn new(player: AudioPlayer) -> Self {
Self { player }
}
}