diff --git a/apps/web/app/(app)/search/SearchComponent.tsx b/apps/web/app/(app)/search/SearchComponent.tsx
index 1fe73d28..c80fb101 100644
--- a/apps/web/app/(app)/search/SearchComponent.tsx
+++ b/apps/web/app/(app)/search/SearchComponent.tsx
@@ -1,18 +1,18 @@
"use client";
-import { useState, useEffect } from "react";
-import Link from "next/link";
import HorizontalCard from "@/components/Music/Card/HorizontalCard";
-import { useSearchParams } from "next/navigation";
-import { searchLibrary, searchYouTube } from "@music/sdk";
import TopResultsCard from "@/components/Music/Card/Search/TopResultsCard";
+import { searchLibrary, searchYouTube } from "@music/sdk";
+import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+import { useEffect, useState } from "react";
-import { DialogFooter, Dialog } from "@music/ui/components/dialog";
-import { DialogTrigger, DialogContent } from "@radix-ui/react-dialog";
-import ReactPlayer from "react-player";
-import Image from "next/image";
-import { YoutubeIcon } from "lucide-react";
import PageGradient from "@/components/Layout/PageGradient";
+import { Dialog, DialogFooter } from "@music/ui/components/dialog";
+import { DialogContent, DialogTrigger } from "@radix-ui/react-dialog";
+import { YoutubeIcon } from "lucide-react";
+import Image from "next/image";
+import ReactPlayer from "react-player";
interface YouTubeVideo {
id: string;
@@ -120,7 +120,7 @@ export default function SearchComponent() {
) : (
- results && results[0] && <> >
+ results && results[0] && <> >
)}
diff --git a/apps/web/components/Layout/SplashScreen.tsx b/apps/web/components/Layout/SplashScreen.tsx
index 41aa4b2d..1b01a255 100644
--- a/apps/web/components/Layout/SplashScreen.tsx
+++ b/apps/web/components/Layout/SplashScreen.tsx
@@ -1,13 +1,13 @@
"use client";
import pl from "@/assets/pl-tp.png";
+import { isValid } from "@music/sdk";
+import { deleteCookie } from "cookies-next";
import { Loader2Icon } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { useSession } from "../Providers/AuthProvider";
-import { isValid } from "@music/sdk";
-import { deleteCookie } from "cookies-next";
interface SplashScreenProps {
children: React.ReactNode;
@@ -26,10 +26,9 @@ const SplashScreen: React.FC = ({ children }) => {
setLoading(true);
const validationResult = await isValid();
-
if (!validationResult.status) {
- deleteCookie('plm_accessToken');
deleteCookie('plm_refreshToken');
+ deleteCookie('plm_accessToken');
push('/login');
return;
}
@@ -101,4 +100,4 @@ const SplashScreen: React.FC = ({ children }) => {
return <>{children}>;
};
-export default SplashScreen;
+export default SplashScreen;
\ No newline at end of file
diff --git a/apps/web/components/Music/Card/Search/TopResultsCard.tsx b/apps/web/components/Music/Card/Search/TopResultsCard.tsx
index 288869ed..0ec95185 100644
--- a/apps/web/components/Music/Card/Search/TopResultsCard.tsx
+++ b/apps/web/components/Music/Card/Search/TopResultsCard.tsx
@@ -61,7 +61,7 @@ export default function TopResultsCard({ result }: ResultCardProps) {
}, [result.id, result.item_type]);
const coverUrl = result.item_type === "song"
- ? song?.album_object.cover_url
+ ? song?.album_object?.cover_url
: result.item_type === "album"
? album?.cover_url
: result.item_type === "artist"
@@ -89,7 +89,7 @@ export default function TopResultsCard({ result }: ResultCardProps) {
if (!song) return;
setImageSrc(imageSrc);
setPlayerArtist({ id: song.artist_object.id, name: song.artist });
- setPlayerAlbum({ id: song.album_object.id, name: song.album_object.name, cover_url: song.album_object.cover_url });
+ setPlayerAlbum({ id: song?.album_object?.id, name: song?.album_object?.name, cover_url: song?.album_object?.cover_url });
try {
const songInfo = await getSongInfo(song.id);
setPlayerSong(songInfo);
diff --git a/crates/backend/Cargo.toml b/crates/backend/Cargo.toml
index 6648d181..55c6a586 100644
--- a/crates/backend/Cargo.toml
+++ b/crates/backend/Cargo.toml
@@ -33,6 +33,7 @@ levenshtein = "1.0.5"
libsqlite3-sys = { version = "0.29.0", features = ["bundled"] }
lofty = "0.18.2"
mime_guess = "2.0.5"
+notify = "7.0.0"
rand = "0.8.5"
ravif = "0.11.9"
rayon = "1.8.0"
diff --git a/crates/backend/src/main.rs b/crates/backend/src/main.rs
index 3cfcafa0..085d5a26 100644
--- a/crates/backend/src/main.rs
+++ b/crates/backend/src/main.rs
@@ -10,9 +10,11 @@ use actix_cors::Cors;
use actix_web::HttpResponse;
use actix_web::{middleware, web, App, HttpServer};
use actix_web_httpauth::middleware::HttpAuthentication;
+use notify::{Event, RecursiveMode, Watcher};
use routes::authentication::{admin_guard, is_valid, refresh};
+use tokio::sync::broadcast;
use tokio::task;
-use tracing::{info, Level};
+use tracing::{error, info, Level};
use tracing_subscriber::FmtSubscriber;
use routes::{album, database, genres, music};
@@ -21,7 +23,7 @@ use routes::authentication::{login, register, validator};
use routes::filesystem;
use routes::image::image;
use routes::music::{
- index_library_no_cover_url, process_library,
+ index, index_library_no_cover_url, library_refresh, process_music_library_no_ws, read_library_paths, Libraries
};
use routes::playlist;
use routes::search::{self, populate_search_data};
@@ -31,7 +33,7 @@ use routes::song;
use routes::user;
use routes::web as web_routes;
-use utils::config;
+use utils::config::{self, get_libraries_config_path};
use utils::database::database::{migrations_ran, redo_migrations};
use utils::database::database::run_migrations;
// use utils::update::check_for_updates;
@@ -131,6 +133,56 @@ async fn main() -> std::io::Result<()> {
info!("Starting server on port {}", port);
+ let (tx, _rx) = broadcast::channel(16);
+ let tx_clone = tx.clone();
+
+ std::thread::spawn(move || {
+ let mut watcher = notify::recommended_watcher(move |res: Result| {
+ match res {
+ Ok(_) => {
+ if let Err(e) = tx_clone.send(()) {
+ error!("Failed to send watch event: {}", e);
+ }
+ }
+ Err(e) => error!("Watch error: {}", e),
+ }
+ }).expect("Failed to create watcher");
+
+ let config_content = std::fs::read_to_string(get_libraries_config_path())
+ .expect("Failed to read libraries config");
+ let libraries: Libraries = serde_json::from_str(&config_content)
+ .expect("Failed to parse libraries config");
+
+ for library_path in &libraries.paths {
+ let path = std::path::Path::new(library_path);
+ let watch_path = path.parent().unwrap_or(path);
+
+ watcher.watch(watch_path, RecursiveMode::NonRecursive)
+ .expect("Failed to watch library path");
+
+ std::thread::park();
+ watcher.watch(watch_path, RecursiveMode::NonRecursive)
+ .expect("Failed to watch libraries path");
+ }
+
+ std::thread::park();
+});
+
+ let mut rx = tx.subscribe();
+ std::thread::spawn(move || {
+ let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime");
+
+ while rt.block_on(rx.recv()).is_ok() {
+ info!("Libraries file changed");
+ let paths = rt.block_on(read_library_paths());
+ for path in paths {
+ if let Err(e) = rt.block_on(process_music_library_no_ws(&path)) {
+ error!("Failed to process library {}: {}", path, e);
+ }
+ }
+ }
+ });
+
task::spawn(async move {
if !migrations_ran() {
if let Err(e) = run_migrations() {
@@ -141,10 +193,9 @@ async fn main() -> std::io::Result<()> {
if let Err(e) = populate_search_data().await {
eprintln!("Failed to populate search data: {}", e);
}
-
// run_modules().await;
});
-
+
HttpServer::new(move || {
let authentication = HttpAuthentication::with_fn(validator);
let admin = HttpAuthentication::with_fn(admin_guard);
@@ -170,7 +221,8 @@ async fn main() -> std::io::Result<()> {
let library_routes = web::scope("/library")
.wrap(admin)
.service(index_library_no_cover_url)
- .service(process_library);
+ .service(index)
+ .service(library_refresh);
App::new()
.wrap(
diff --git a/crates/backend/src/routes/music.rs b/crates/backend/src/routes/music.rs
index 51515db8..51c96dc2 100644
--- a/crates/backend/src/routes/music.rs
+++ b/crates/backend/src/routes/music.rs
@@ -1,3 +1,4 @@
+use std::fs;
use std::process::Stdio;
use std::time::Instant;
@@ -5,17 +6,17 @@ use actix_web::http::header;
use actix_web::web::Bytes;
use actix_web::{get, web, HttpRequest, HttpResponse, Responder};
use futures::Stream;
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio::process::Command;
use tokio_util::io::ReaderStream;
-use tracing::{error, info, warn};
+use tracing::{error, info};
use walkdir::WalkDir;
use crate::routes::search::populate_search_data;
use crate::structures::structures::Artist;
use crate::utils::compare::compare;
-use crate::utils::config::{get_config, save_config};
+use crate::utils::config::{get_config, get_libraries_config_path, refresh_cache, save_config};
use crate::utils::format::format_contributing_artists;
use crate::utils::library::index_library;
use crate::utils::metadata::{get_access_token, process_album, process_albums, process_artist, process_artists, refresh_audio_db_info};
@@ -41,104 +42,70 @@ pub async fn songs_list(path: web::Path) -> impl Responder {
HttpResponse::Ok().body(message)
}
-#[get("/index/{pathToLibrary}")]
-pub async fn process_library(path_to_library: web::Path) -> impl Responder {
- info!("Indexing library...");
- log_to_ws("Indexing library...".to_string()).await;
-
+async fn process_music_library(path: &str) -> Result> {
let now = Instant::now();
- let library = index_library(path_to_library.as_str()).await.unwrap();
+ let library = index_library(path).await?;
let client = reqwest::Client::builder()
.user_agent("ParsonLabsMusic/0.1 (will@parsonlabs.com)")
- .build()
- .unwrap();
-
- let config_data = get_config().await.unwrap_or("".to_string());
+ .build()?;
+ let config_data = get_config().await.unwrap_or_default();
let mut current_library: Vec = if config_data.is_empty() {
Vec::new()
} else {
serde_json::from_str(&config_data).unwrap_or_else(|_| Vec::new())
};
-
+
if current_library.is_empty() {
let mut library_guard = library.lock().unwrap();
process_artists(&client, &mut *library_guard).await;
process_albums(&client, &mut *library_guard).await;
}
-
- if let Ok((mut new_artist_entries, mut new_album_entries, _new_song_entries)) =
- compare(&library).await
- {
+
+ if let Ok((mut new_artist_entries, mut new_album_entries, _new_song_entries)) = compare(&library).await {
if !new_artist_entries.is_empty() {
let artists_without_icon_count = new_artist_entries
.iter()
.filter(|artist| artist.icon_url.is_empty())
.count();
- let log = format!(
- "Searching icon art for {} artists...",
- artists_without_icon_count
- );
+ let log = format!("Searching icon art for {} artists...", artists_without_icon_count);
info!(log);
log_to_ws(log).await;
-
- match get_access_token().await {
- Ok(access_token) => {
- for artist in new_artist_entries.iter_mut() {
- process_artist(&client, artist, access_token.clone()).await;
- current_library.push(artist.clone());
- }
+
+ if let Ok(access_token) = get_access_token().await {
+ for artist in new_artist_entries.iter_mut() {
+ process_artist(&client, artist, access_token.clone()).await;
+ current_library.push(artist.clone());
}
- Err(e) => warn!("Failed to get access token. Error: {}", e),
}
}
-
+
if !new_album_entries.is_empty() {
let albums_without_cover_count = new_album_entries
.iter()
.filter(|modified_album| modified_album.album.cover_url.is_empty())
.count();
- let log = format!(
- "Searching cover art for {} albums...",
- albums_without_cover_count
- );
+ let log = format!("Searching cover art for {} albums...", albums_without_cover_count);
info!(log);
log_to_ws(log).await;
-
+
for modified_album in new_album_entries.iter_mut() {
- process_album(
- &client,
- modified_album.artist_name.clone(),
- &mut modified_album.album,
- )
- .await;
- if let Some(artist) = current_library
- .iter_mut()
- .find(|a| a.id == modified_album.artist_id)
- {
- match artist
- .albums
- .iter_mut()
- .find(|a| a.id == modified_album.album.id)
- {
+ process_album(&client, modified_album.artist_name.clone(), &mut modified_album.album).await;
+ if let Some(artist) = current_library.iter_mut().find(|a| a.id == modified_album.artist_id) {
+ match artist.albums.iter_mut().find(|a| a.id == modified_album.album.id) {
Some(existing_album) => *existing_album = modified_album.album.clone(),
None => artist.albums.push(modified_album.album.clone()),
}
-
refresh_audio_db_info(artist);
}
}
}
}
- // https://musicbrainz.org/ws/2/release/cbaf43b4-0d8f-4b58-9173-9fe7298e04e9?inc=aliases+artist-credits+labels+discids+recordings+release-groups+media+discids+recordings+artist-credits+isrcs+artist-rels+release-rels+url-rels+recording-rels+work-rels+label-rels+place-rels+event-rels+area-rels+instrument-rels+series-rels+work-rels&fmt=json
-
let library_guard = library.lock().unwrap();
let elapsed = now.elapsed().as_secs();
-
- let log = format!("Finished Indexing Library in {} seconds", elapsed);
- info!(log);
- log_to_ws(log).await;
+ info!("Finished Indexing Library in {} seconds", elapsed);
+ log_to_ws(format!("Finished Indexing Library in {} seconds", elapsed)).await;
let data_to_serialize = if current_library.is_empty() {
&*library_guard
@@ -146,20 +113,176 @@ pub async fn process_library(path_to_library: web::Path) -> impl Respond
¤t_library
};
- let json = serde_json::to_string(data_to_serialize).unwrap();
+ let json = serde_json::to_string(data_to_serialize)?;
+ save_config(&json, true).await?;
+ populate_search_data().await.expect("Could not Populate the Search Data");
+ refresh_cache().await.expect("Could not Refresh Music Data Cache");
- save_config(&json, true).await.unwrap();
+ Ok(json)
+}
- match populate_search_data().await {
- Ok(_) => {},
+#[get("/index/{pathToLibrary}")]
+pub async fn index(path_to_library: web::Path) -> impl Responder {
+ info!("Indexing new library path...");
+ log_to_ws("Indexing new library path...".to_string()).await;
+
+ if let Err(e) = save_library_path(&path_to_library).await {
+ error!("Failed to save library path: {:?}", e);
+ }
+
+ match process_music_library(&path_to_library).await {
+ Ok(json) => HttpResponse::Ok().content_type("application/json; charset=utf-8").body(json),
Err(e) => {
- error!("Failed to populate search data: {:?}", e);
+ error!("Failed to process library: {:?}", e);
+ HttpResponse::InternalServerError().finish()
}
}
+}
- HttpResponse::Ok()
- .content_type("application/json; charset=utf-8")
- .body(json)
+pub async fn process_music_library_no_ws(path: &str) -> Result> {
+ let now = Instant::now();
+ let library = index_library(path).await?;
+
+ let client = reqwest::Client::builder()
+ .user_agent("ParsonLabsMusic/0.1 (will@parsonlabs.com)")
+ .build()?;
+ let config_data = get_config().await.unwrap_or_default();
+ let mut current_library: Vec = if config_data.is_empty() {
+ Vec::new()
+ } else {
+ serde_json::from_str(&config_data).unwrap_or_else(|_| Vec::new())
+ };
+
+ if current_library.is_empty() {
+ let mut library_guard = library.lock().unwrap();
+ process_artists(&client, &mut *library_guard).await;
+ process_albums(&client, &mut *library_guard).await;
+ }
+
+ if let Ok((mut new_artist_entries, mut new_album_entries, _new_song_entries)) = compare(&library).await {
+ if !new_artist_entries.is_empty() {
+ let artists_without_icon_count = new_artist_entries
+ .iter()
+ .filter(|artist| artist.icon_url.is_empty())
+ .count();
+ info!("Searching icon art for {} artists...", artists_without_icon_count);
+
+ if let Ok(access_token) = get_access_token().await {
+ for artist in new_artist_entries.iter_mut() {
+ process_artist(&client, artist, access_token.clone()).await;
+ current_library.push(artist.clone());
+ }
+ }
+ }
+
+ if !new_album_entries.is_empty() {
+ let albums_without_cover_count = new_album_entries
+ .iter()
+ .filter(|modified_album| modified_album.album.cover_url.is_empty())
+ .count();
+ info!("Searching cover art for {} albums...", albums_without_cover_count);
+
+ for modified_album in new_album_entries.iter_mut() {
+ process_album(&client, modified_album.artist_name.clone(), &mut modified_album.album).await;
+ if let Some(artist) = current_library.iter_mut().find(|a| a.id == modified_album.artist_id) {
+ match artist.albums.iter_mut().find(|a| a.id == modified_album.album.id) {
+ Some(existing_album) => *existing_album = modified_album.album.clone(),
+ None => artist.albums.push(modified_album.album.clone()),
+ }
+ refresh_audio_db_info(artist);
+ }
+ }
+ }
+ }
+
+ let library_guard = library.lock().unwrap();
+ let elapsed = now.elapsed().as_secs();
+ info!("Finished Indexing Library in {} seconds", elapsed);
+
+ let data_to_serialize = if current_library.is_empty() {
+ &*library_guard
+ } else {
+ ¤t_library
+ };
+
+ let json = serde_json::to_string(data_to_serialize)?;
+ save_config(&json, true).await?;
+ populate_search_data().await.expect("Could not Populate the Search Data");
+ refresh_cache().await.expect("Could not Refresh Music Data Cache");
+
+ Ok(json)
+}
+
+pub async fn refresh_libraries() -> Result, Box> {
+ info!("Refreshing all library paths...");
+ log_to_ws("Refreshing all library paths...".to_string()).await;
+
+ let paths = read_library_paths().await;
+ let mut results = Vec::new();
+
+ for path in paths {
+ match process_music_library(&path).await {
+ Ok(json) => results.push(json),
+ Err(e) => {
+ error!("Failed to process library {}: {:?}", path, e);
+ }
+ }
+ }
+
+ Ok(results)
+}
+
+#[get("/refresh")]
+pub async fn library_refresh() -> impl Responder {
+ match refresh_libraries().await {
+ Ok(results) => {
+ if results.is_empty() {
+ HttpResponse::InternalServerError().finish()
+ } else {
+ HttpResponse::Ok()
+ .content_type("application/json; charset=utf-8")
+ .body(results.join(","))
+ }
+ }
+ Err(_) => HttpResponse::InternalServerError().finish()
+ }
+}
+
+#[derive(Serialize, Deserialize, Debug, Default)]
+pub struct Libraries {
+ pub paths: Vec
+}
+
+pub async fn read_library_paths() -> Vec {
+ let libraries_file= get_libraries_config_path();
+
+ if !libraries_file.exists() {
+ return Vec::new();
+ }
+
+ let content = fs::read_to_string(libraries_file).expect("Could not read the Libraries JSON File");
+ let libraries: Libraries = serde_json::from_str(&content).expect("Could not Parse the JSON as a String");
+
+ libraries.paths
+}
+
+async fn save_library_path(path: &str) -> Result<(), Box> {
+ let libraries_file = get_libraries_config_path();
+
+ let mut libraries = if libraries_file.exists() {
+ let content = fs::read_to_string(&libraries_file)?;
+ serde_json::from_str(&content).unwrap_or_default()
+ } else {
+ Libraries { paths: Vec::new() }
+ };
+
+ if !libraries.paths.contains(&path.to_string()) {
+ libraries.paths.push(path.to_string());
+ let json = serde_json::to_string_pretty(&libraries)?;
+ fs::write(libraries_file, json)?;
+ }
+
+ Ok(())
}
#[derive(Deserialize)]
diff --git a/crates/backend/src/utils/config.rs b/crates/backend/src/utils/config.rs
index 8501ff01..2d462163 100644
--- a/crates/backend/src/utils/config.rs
+++ b/crates/backend/src/utils/config.rs
@@ -133,6 +133,26 @@ pub fn get_config_path() -> PathBuf {
path
}
+pub fn get_libraries_config_path() -> PathBuf {
+ let path = if is_docker() {
+ Path::new("/ParsonLabsMusic/Config/libraries.json").to_path_buf()
+ } else {
+ let mut path = dirs::data_local_dir().unwrap_or_else(|| PathBuf::from("."));
+ path.push("ParsonLabs");
+ path.push("Music");
+ path.push("Config");
+ path.push("libraries.json");
+ path
+ };
+
+ if let Some(parent) = path.parent() {
+ if let Err(e) = fs::create_dir_all(parent) {
+ eprintln!("Failed to create directories: {}", e);
+ }
+ }
+
+ path
+}
#[get("/has_config")]
async fn has_config() -> impl Responder {