feat: basic music backend
This commit is contained in:
2
.env
Normal file
2
.env
Normal 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
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
2984
Cargo.lock
generated
Normal file
2984
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
Cargo.toml
Normal file
28
Cargo.toml
Normal 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
9
build.rs
Normal 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
20
proto/library.proto
Normal 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
18
proto/player.proto
Normal 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
43
proto/settings.proto
Normal 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
11
src/checksum.rs
Normal 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
96
src/covers.rs
Normal 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
37
src/database/albums.rs
Normal 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
33
src/database/artists.rs
Normal 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
57
src/database/mod.rs
Normal 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
64
src/database/paths.rs
Normal 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
154
src/database/tracks.rs
Normal 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
120
src/library.rs
Normal 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
71
src/main.rs
Normal 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
101
src/music/metadata.rs
Normal 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
2
src/music/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod metadata;
|
||||
pub mod player;
|
||||
40
src/music/player.rs
Normal file
40
src/music/player.rs
Normal 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
66
src/player.rs
Normal 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
117
src/settings.rs
Normal 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
17
src/state.rs
Normal 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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user