From c5c3f1ebc04c3f83ca5ea797b08e68019930dc38 Mon Sep 17 00:00:00 2001 From: skybldev Date: Sat, 24 Oct 2026 00:59:34 -0400 Subject: [PATCH] feat: recursively scan through directory, refactor library and import functions, add more basic information to schema --- .gitignore | 3 +- Cargo.lock | 96 +++++++ Cargo.toml | 1 + .../up.sql | 13 +- src/library.rs | 257 ++++++++++++++++++ src/library_scan.rs | 92 ------- src/main.rs | 80 ++---- src/models.rs | 8 +- src/schema.rs | 5 +- src/types.rs | 6 +- 10 files changed, 408 insertions(+), 153 deletions(-) create mode 100644 src/library.rs delete mode 100644 src/library_scan.rs diff --git a/.gitignore b/.gitignore index e17032c..fae156d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target -/test/fixtures/ \ No newline at end of file +/test/fixtures/ +.env \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 81d5880..17d9587 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "diesel_migrations" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0f4a98124ba6d4ca75da535f65984badec16a003b6e2f94a01e31a79490b8" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + [[package]] name = "diesel_table_macro_syntax" version = "0.3.0" @@ -261,6 +272,28 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "migrations_internals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" +dependencies = [ + "serde", + "toml", +] + +[[package]] +name = "migrations_macros" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9423d6affd681fb4d25d35885d0d0262c52f8ade16e96f87c064c442e5aa5d47" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "miniz_oxide" version = "0.9.1" @@ -344,6 +377,16 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -364,6 +407,15 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + [[package]] name = "shlex" version = "2.0.1" @@ -466,11 +518,43 @@ dependencies = [ "time-core", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + [[package]] name = "tuneparty_pre1" version = "0.1.0" dependencies = [ "diesel", + "diesel_migrations", "dotenvy", "lofty", ] @@ -532,6 +616,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/Cargo.toml b/Cargo.toml index 6f5ed03..a110bd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,5 +5,6 @@ edition = "2024" [dependencies] diesel = { version = "2.3.14", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] } +diesel_migrations = { version = "2.3.2", features = ["sqlite"] } dotenvy = "0.15.7" lofty = "0.25.4" diff --git a/migrations/2026-10-23-174301-0000_create_artists_collections_tracks/up.sql b/migrations/2026-10-23-174301-0000_create_artists_collections_tracks/up.sql index d6de1a7..91c4ef9 100644 --- a/migrations/2026-10-23-174301-0000_create_artists_collections_tracks/up.sql +++ b/migrations/2026-10-23-174301-0000_create_artists_collections_tracks/up.sql @@ -1,19 +1,26 @@ CREATE TABLE artists ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, original_name TEXT NOT NULL, - display_name TEXT NOT NULL + display_name TEXT NOT NULL, + UNIQUE (original_name) ); CREATE TABLE collections ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + artist_id INTEGER, collection_type TEXT NOT NULL, original_name TEXT NOT NULL, - display_name TEXT NOT NULL + display_name TEXT NOT NULL, + UNIQUE (original_name), + CONSTRAINT fk_collections_artist + FOREIGN KEY (artist_id) + REFERENCES artists (id) ); CREATE TABLE tracks ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - artist_id INTEGER NOT NULL, + file_path TEXT NOT NULL, + artist_id INTEGER, collection_id INTEGER, original_title TEXT, display_title TEXT, diff --git a/src/library.rs b/src/library.rs new file mode 100644 index 0000000..9025155 --- /dev/null +++ b/src/library.rs @@ -0,0 +1,257 @@ +use std::{ + error::Error, + fs::{self, File}, + path::{Path, PathBuf}, +}; + +use diesel::{prelude::*, sqlite::SqliteConnection}; +use lofty::{prelude::*, read_from}; + +use crate::{ + models::{Artist, Collection, NewArtist, NewCollection, NewTrack, Track}, + schema, + types::{ + errors::{ScanError, TrackScanError}, + library::{ScannedTrack, ScannedTrackMetadata}, + }, +}; + +pub struct Library { + conn: SqliteConnection, +} + +impl Library { + pub fn new(conn: SqliteConnection) -> Self { + Self { conn } + } + + pub fn print(&mut self) -> QueryResult<()> { + let artists = schema::artists::table + .order(schema::artists::id.asc()) + .load::(&mut self.conn)?; + + let collections = schema::collections::table + .order(schema::collections::id.asc()) + .load::(&mut self.conn)?; + + let tracks = schema::tracks::table + .order(schema::tracks::id.asc()) + .load::(&mut self.conn)?; + + println!("Artists:\n{artists:#?}"); + println!("Collections:\n{collections:#?}"); + println!("Tracks:\n{tracks:#?}"); + + Ok(()) + } +} + +pub struct Importer; + +impl Importer { + pub fn scan(path: &Path) -> Result, ScanError> { + println!("Scanning library..."); + + if !path.is_dir() { + return Err(ScanError::NotDirectory(path.to_path_buf())); + } + + let mut tracks = Vec::new(); + Self::scan_directory(path, &mut tracks)?; + Ok(tracks) + } + + fn scan_directory(path: &Path, tracks: &mut Vec) -> Result<(), ScanError> { + let entries = fs::read_dir(path).map_err(|source| ScanError::ReadDir { + path: path.to_path_buf(), + source, + })?; + + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + eprintln!("Could not read an entry in {}: {error}", path.display()); + continue; + } + }; + + let entry_path = entry.path(); + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(error) => { + eprintln!("Could not inspect {}: {error}", entry_path.display()); + continue; + } + }; + + if file_type.is_dir() { + Self::scan_directory(&entry_path, tracks)?; + } else if file_type.is_file() { + tracks.push(ScannedTrack { + metadata: Self::read_metadata(&entry_path), + path: entry_path, + }); + } + } + + Ok(()) + } + + fn read_metadata(path: &Path) -> Result { + let mut file = File::open(path).map_err(|source| TrackScanError::FileOpen { + path: path.to_path_buf(), + source, + })?; + + let tagged_file = read_from(&mut file).map_err(|source| TrackScanError::FileParse { + path: path.to_path_buf(), + source, + })?; + + let length_sec = + i32::try_from(tagged_file.properties().duration().as_secs()).unwrap_or(i32::MAX); + + let Some(tags) = tagged_file.primary_tag() else { + return Ok(ScannedTrackMetadata { + artist: None, + collection: None, + title: None, + number: None, + genre: None, + datetime: None, + length_sec, + }); + }; + + Ok(ScannedTrackMetadata { + artist: tags.artist().map(String::from), + collection: tags.album().map(String::from), + title: tags.title().map(String::from), + number: tags + .track() + .map(|number| i32::try_from(number).unwrap_or(i32::MAX)), + genre: tags.genre().map(String::from), + datetime: tags.date(), + length_sec, + }) + } + + pub fn import_scan(library: &mut Library, path: &Path) -> Result<(), Box> { + for track in Self::scan(path)? { + let file_path = track.path.to_string_lossy(); + + println!("-- {file_path}"); + + match track.metadata { + Ok(metadata) => { + Self::import_tagged_track(library, &file_path, &metadata)?; + + println!( + "artist: {}", + metadata.artist.as_deref().unwrap_or("unknown") + ); + println!( + "collection: {}", + metadata.collection.as_deref().unwrap_or("unknown") + ); + println!("title: {}", metadata.title.as_deref().unwrap_or("unknown")); + println!( + "number: {}", + metadata + .number + .map(|value| value.to_string()) + .unwrap_or_else(|| "unknown".to_owned()) + ); + println!("genre: {}", metadata.genre.as_deref().unwrap_or("unknown")); + println!( + "datetime: {}", + metadata + .datetime + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| "unknown".to_owned()) + ); + println!("length_sec: {}", metadata.length_sec); + } + Err(error) => eprintln!("{error}"), + } + + println!(); + } + + Ok(()) + } + + fn get_or_create_artist(conn: &mut SqliteConnection, name: &str) -> QueryResult { + diesel::insert_into(schema::artists::table) + .values(NewArtist { + original_name: name, + display_name: name, + }) + .on_conflict(schema::artists::original_name) + .do_nothing() + .execute(conn)?; + + schema::artists::table + .filter(schema::artists::original_name.eq(name)) + .select(schema::artists::id) + .first(conn) + } + + fn get_or_create_collection( + conn: &mut SqliteConnection, + artist_id: Option, + name: &str, + ) -> QueryResult { + diesel::insert_into(schema::collections::table) + .values(NewCollection { + artist_id, + collection_type: "album", + original_name: name, + display_name: name, + }) + .on_conflict(schema::collections::original_name) + .do_nothing() + .execute(conn)?; + + schema::collections::table + .filter(schema::collections::original_name.eq(name)) + .select(schema::collections::id) + .first(conn) + } + + fn import_tagged_track( + library: &mut Library, + path: &str, + metadata: &ScannedTrackMetadata, + ) -> QueryResult { + let artist_id = metadata + .artist + .as_deref() + .map(|name| Self::get_or_create_artist(&mut library.conn, name)) + .transpose()?; + + let collection_id = metadata + .collection + .as_deref() + .map(|name| Self::get_or_create_collection(&mut library.conn, artist_id, name)) + .transpose()?; + + let track_datetime = metadata.datetime.as_ref().map(ToString::to_string); + + diesel::insert_into(schema::tracks::table) + .values(NewTrack { + file_path: path, + artist_id, + collection_id, + original_title: metadata.title.as_deref(), + display_title: metadata.title.as_deref(), + track_number: metadata.number, + genre: metadata.genre.as_deref(), + track_datetime: track_datetime.as_deref(), + length_sec: metadata.length_sec, + }) + .execute(&mut library.conn) + } +} diff --git a/src/library_scan.rs b/src/library_scan.rs deleted file mode 100644 index fec7281..0000000 --- a/src/library_scan.rs +++ /dev/null @@ -1,92 +0,0 @@ -use std::error::Error; -use std::fmt; -use std::fs; -use std::fs::File; -use std::io; -use std::path::{Path, PathBuf}; - -use lofty::file; -use lofty::prelude::*; -use lofty::read_from; - -use crate::types::errors::{ScanError, TrackScanError}; -use crate::types::library::ScannedTrack; -use crate::types::library::ScannedTrackMetadata; - -pub fn scan(path: &Path) -> Result, ScanError> { - println!("Scanning library..."); - - if !path.is_dir() { - return Err(ScanError::NotDirectory(path.to_path_buf())); - } - - let entries = fs::read_dir(path).map_err(|source| ScanError::ReadDir { - path: path.to_path_buf(), - source, - })?; - - let mut tracks: Vec = Vec::new(); - - // iterate through entries in directory (depth of one) - for entry in entries { - // validate if entry is even valid (this annoys me) - if let Ok(entry) = entry { - let entry_path = entry.path(); - - // try to read file and come up with metadata - let metadata = || -> Result { - let mut file = - File::open(&entry_path).map_err(|source| TrackScanError::FileOpen { - path: entry_path.clone(), - source, - })?; - - let tagged_file = - read_from(&mut file).map_err(|source| TrackScanError::FileParse { - path: entry_path.clone(), - source, - })?; - - let props = tagged_file.properties(); - - let track_length_sec = u32::try_from(props.duration().as_secs()) - .unwrap_or(u32::MAX); - - if let Some(tags) = tagged_file.primary_tag() { - Ok(ScannedTrackMetadata { - artist: tags.artist().as_deref().map(|tag| String::from(tag)), - collection: tags.album().as_deref().map(|tag| String::from(tag)), - title: tags.title().as_deref().map(|tag| String::from(tag)), - number: tags.track(), - genre: tags.genre().as_deref().map(|tag| String::from(tag)), - datetime: tags.date(), - length_sec: track_length_sec - }) - } else { - Ok(ScannedTrackMetadata { - artist: None, - collection: None, - title: None, - number: None, - genre: None, - datetime: None, - length_sec: track_length_sec - }) - } - }(); - - tracks.push(ScannedTrack { - path: entry_path, - metadata, - }); - } else { - eprintln!( - "there was an error reading some file in {}", - path.to_str().unwrap() - ); - } - } - - Ok(tracks) -} - diff --git a/src/main.rs b/src/main.rs index f20aba0..3ecd649 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,72 +1,50 @@ -use std::{fs::metadata, path::Path}; +use std::{ + fs::metadata, + path::{Path, PathBuf}, + string, +}; use diesel::prelude::*; use dotenvy::dotenv; use std::env; -mod library_scan; -mod types; +mod library; mod models; mod schema; +mod types; + +use library::{Importer, Library}; fn open_db_file() -> SqliteConnection { - dotenv().ok(); - - let database_url = env::var("DATABASE_URL") - .expect("DATABASE_URL missing"); + let database_url = env::var("DATABASE_URL").expect("DATABASE_URL missing"); SqliteConnection::establish(&database_url) .unwrap_or_else(|_| panic!("Error connecting to {}", database_url)) } fn open_db_in_memory() -> SqliteConnection { - SqliteConnection::establish(":memory:") - .unwrap_or_else(|_| panic!("Error starting in-memory database")) + use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; + + const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); + + let mut conn = SqliteConnection::establish(":memory:") + .unwrap_or_else(|_| panic!("Error starting in-memory database")); + + conn.run_pending_migrations(MIGRATIONS) + .unwrap_or_else(|e| panic!("Error applying in-memory migrations: {}", e)); + + conn } - -fn import_scan(conn: &mut SqliteConnection) { - for track in library_scan::scan(Path::new("./test/fixtures")).expect("oh no") { - println!("-- {}", track.path.into_string().unwrap()); - - match track.metadata { - Ok(metadata) => { - println!("artist: {}", metadata.artist.as_deref().unwrap_or("unknown")); - println!( - "collection: {}", - metadata.collection.as_deref().unwrap_or("unknown") - ); - println!("title: {}", metadata.title.as_deref().unwrap_or("unknown")); - println!( - "number: {}", - metadata - .number - .map(|value| value.to_string()) - .unwrap_or_else(|| "unknown".to_owned()) - ); - println!("genre: {}", metadata.genre.as_deref().unwrap_or("unknown")); - println!( - "datetime: {}", - metadata - .datetime - .as_ref() - .map(|value| format!("{value:?}")) - .unwrap_or_else(|| "unknown".to_owned()) - ); - println!("length_sec: {}", metadata.length_sec); - } - Err(e) => { - eprint!("{e}"); - } - } - println!(""); - } -} - fn main() { println!("Hello, world!"); + dotenv().ok(); - let conn = &mut open_db_in_memory(); + let mut library_instance = Library::new(open_db_in_memory()); - import_scan(conn); - + let import_path = env::var("IMPORT_PATH").expect("IMPORT_PATH not set!"); + + Importer::import_scan(&mut library_instance, Path::new(&import_path)) + .expect("Error importing files"); + + library_instance.print().expect("Error printing library"); } diff --git a/src/models.rs b/src/models.rs index 187692f..bb6aee7 100644 --- a/src/models.rs +++ b/src/models.rs @@ -16,6 +16,7 @@ pub struct Artist { #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct Collection { pub id: i32, + pub artist_id: Option, pub collection_type: String, pub original_name: String, pub display_name: String, @@ -28,7 +29,8 @@ pub struct Collection { #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct Track { pub id: i32, - pub artist_id: i32, + pub file_path: String, + pub artist_id: Option, pub collection_id: Option, pub original_title: Option, pub display_title: Option, @@ -48,6 +50,7 @@ pub struct NewArtist<'a> { #[derive(Debug, Clone, Insertable)] #[diesel(table_name = collections)] pub struct NewCollection<'a> { + pub artist_id: Option, pub collection_type: &'a str, pub original_name: &'a str, pub display_name: &'a str, @@ -56,7 +59,8 @@ pub struct NewCollection<'a> { #[derive(Debug, Clone, Insertable)] #[diesel(table_name = tracks)] pub struct NewTrack<'a> { - pub artist_id: i32, + pub file_path: &'a str, + pub artist_id: Option, pub collection_id: Option, pub original_title: Option<&'a str>, pub display_title: Option<&'a str>, diff --git a/src/schema.rs b/src/schema.rs index 691f6c9..e84f44b 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -11,6 +11,7 @@ diesel::table! { diesel::table! { collections (id) { id -> Integer, + artist_id -> Nullable, collection_type -> Text, original_name -> Text, display_name -> Text, @@ -20,7 +21,8 @@ diesel::table! { diesel::table! { tracks (id) { id -> Integer, - artist_id -> Integer, + file_path -> Text, + artist_id -> Nullable, collection_id -> Nullable, original_title -> Nullable, display_title -> Nullable, @@ -31,6 +33,7 @@ diesel::table! { } } +diesel::joinable!(collections -> artists (artist_id)); diesel::joinable!(tracks -> artists (artist_id)); diesel::joinable!(tracks -> collections (collection_id)); diff --git a/src/types.rs b/src/types.rs index 891e6c7..c0707bc 100644 --- a/src/types.rs +++ b/src/types.rs @@ -3,7 +3,7 @@ pub mod library { use lofty::tag::items::Timestamp; -use crate::types::errors; + use crate::types::errors; #[derive(Debug)] struct Artist { @@ -22,10 +22,10 @@ use crate::types::errors; pub artist: Option, pub collection: Option, pub title: Option, - pub number: Option, + pub number: Option, pub genre: Option, pub datetime: Option, - pub length_sec: u32 + pub length_sec: i32, } #[derive(Debug)]