From fbd254e097e959984a4a9ea90df716fd93d48791 Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Sat, 21 Dec 2024 22:13:05 +0000 Subject: [PATCH 1/7] Feat(SearchComponent): Handle Objects without album_object (Artists) --- apps/web/app/(app)/search/SearchComponent.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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] && <> )} From 10c03e1ad422dc477685d834eb055b6f0a86b19c Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Sat, 21 Dec 2024 22:14:15 +0000 Subject: [PATCH 2/7] Fix(SplashScreen.tsx): Delete Refresh Token First to Prevent Refreshing --- apps/web/components/Layout/SplashScreen.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 From 6a9e65744ad4dbf77c34cc2da88abe4d4eaef962 Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Sat, 21 Dec 2024 22:14:29 +0000 Subject: [PATCH 3/7] Feat(TopResultsCard.tsx): Handle Undefined Album Objects (Artists) --- apps/web/components/Music/Card/Search/TopResultsCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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); From 47d1643340b24e02d8f7413f2ccf3771d148177b Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Sat, 21 Dec 2024 22:14:48 +0000 Subject: [PATCH 4/7] PKG(Cargo.toml): Add Filesystem Notify Crate --- crates/backend/Cargo.toml | 1 + 1 file changed, 1 insertion(+) 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" From 35b40e5b506c067e6048abec6b290fe0128617ad Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Sat, 21 Dec 2024 22:15:31 +0000 Subject: [PATCH 5/7] Feat(main.rs): Listen for FileSystem Changes & Index Library, Populate Search & Refresh Cache if Changed --- crates/backend/src/main.rs | 64 ++++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 6 deletions(-) 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( From 40e0a688d103d2add2e7920d88e83f1bf6c09e7b Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Sat, 21 Dec 2024 22:15:58 +0000 Subject: [PATCH 6/7] Feat(music.rs): Refresh Libraries with libraries.json --- crates/backend/src/routes/music.rs | 255 +++++++++++++++++++++-------- 1 file changed, 189 insertions(+), 66 deletions(-) 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)] From a6c82e16cc61652292d112dccf15db88846f2759 Mon Sep 17 00:00:00 2001 From: WillKirkmanM Date: Sat, 21 Dec 2024 22:16:12 +0000 Subject: [PATCH 7/7] Feat(config.rs): Libraries Location File --- crates/backend/src/utils/config.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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 {