feat: recursively scan through directory, refactor library and import functions, add more basic information to schema

This commit is contained in:
skybldev
2026-10-24 00:59:34 -04:00
parent f62a99feb6
commit c5c3f1ebc0
10 changed files with 408 additions and 153 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/target /target
/test/fixtures/ /test/fixtures/
.env

96
Cargo.lock generated
View File

@@ -118,6 +118,17 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "diesel_table_macro_syntax" name = "diesel_table_macro_syntax"
version = "0.3.0" version = "0.3.0"
@@ -261,6 +272,28 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" 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]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.9.1" version = "0.9.1"
@@ -344,6 +377,16 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" 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]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.229" version = "1.0.229"
@@ -364,6 +407,15 @@ dependencies = [
"syn 3.0.9", "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]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
@@ -466,11 +518,43 @@ dependencies = [
"time-core", "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]] [[package]]
name = "tuneparty_pre1" name = "tuneparty_pre1"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"diesel", "diesel",
"diesel_migrations",
"dotenvy", "dotenvy",
"lofty", "lofty",
] ]
@@ -532,6 +616,18 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "zlib-rs" name = "zlib-rs"
version = "0.6.8" version = "0.6.8"

View File

@@ -5,5 +5,6 @@ edition = "2024"
[dependencies] [dependencies]
diesel = { version = "2.3.14", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] } 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" dotenvy = "0.15.7"
lofty = "0.25.4" lofty = "0.25.4"

View File

@@ -1,19 +1,26 @@
CREATE TABLE artists ( CREATE TABLE artists (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
original_name TEXT NOT NULL, original_name TEXT NOT NULL,
display_name TEXT NOT NULL display_name TEXT NOT NULL,
UNIQUE (original_name)
); );
CREATE TABLE collections ( CREATE TABLE collections (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
artist_id INTEGER,
collection_type TEXT NOT NULL, collection_type TEXT NOT NULL,
original_name 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 ( CREATE TABLE tracks (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
artist_id INTEGER NOT NULL, file_path TEXT NOT NULL,
artist_id INTEGER,
collection_id INTEGER, collection_id INTEGER,
original_title TEXT, original_title TEXT,
display_title TEXT, display_title TEXT,

257
src/library.rs Normal file
View File

@@ -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::<Artist>(&mut self.conn)?;
let collections = schema::collections::table
.order(schema::collections::id.asc())
.load::<Collection>(&mut self.conn)?;
let tracks = schema::tracks::table
.order(schema::tracks::id.asc())
.load::<Track>(&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<Vec<ScannedTrack>, 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<ScannedTrack>) -> 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<ScannedTrackMetadata, TrackScanError> {
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<dyn Error>> {
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<i32> {
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<i32>,
name: &str,
) -> QueryResult<i32> {
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<usize> {
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)
}
}

View File

@@ -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<Vec<ScannedTrack>, 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<ScannedTrack> = 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<ScannedTrackMetadata, TrackScanError> {
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)
}

View File

@@ -1,72 +1,50 @@
use std::{fs::metadata, path::Path}; use std::{
fs::metadata,
path::{Path, PathBuf},
string,
};
use diesel::prelude::*; use diesel::prelude::*;
use dotenvy::dotenv; use dotenvy::dotenv;
use std::env; use std::env;
mod library_scan; mod library;
mod types;
mod models; mod models;
mod schema; mod schema;
mod types;
use library::{Importer, Library};
fn open_db_file() -> SqliteConnection { 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) SqliteConnection::establish(&database_url)
.unwrap_or_else(|_| panic!("Error connecting to {}", database_url)) .unwrap_or_else(|_| panic!("Error connecting to {}", database_url))
} }
fn open_db_in_memory() -> SqliteConnection { fn open_db_in_memory() -> SqliteConnection {
SqliteConnection::establish(":memory:") use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
.unwrap_or_else(|_| panic!("Error starting in-memory database"))
}
fn import_scan(conn: &mut SqliteConnection) { const MIGRATIONS: EmbeddedMigrations = embed_migrations!();
for track in library_scan::scan(Path::new("./test/fixtures")).expect("oh no") {
println!("-- {}", track.path.into_string().unwrap());
match track.metadata { let mut conn = SqliteConnection::establish(":memory:")
Ok(metadata) => { .unwrap_or_else(|_| panic!("Error starting in-memory database"));
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!("");
}
}
conn.run_pending_migrations(MIGRATIONS)
.unwrap_or_else(|e| panic!("Error applying in-memory migrations: {}", e));
conn
}
fn main() { fn main() {
println!("Hello, world!"); 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");
} }

View File

@@ -16,6 +16,7 @@ pub struct Artist {
#[diesel(check_for_backend(diesel::sqlite::Sqlite))] #[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct Collection { pub struct Collection {
pub id: i32, pub id: i32,
pub artist_id: Option<i32>,
pub collection_type: String, pub collection_type: String,
pub original_name: String, pub original_name: String,
pub display_name: String, pub display_name: String,
@@ -28,7 +29,8 @@ pub struct Collection {
#[diesel(check_for_backend(diesel::sqlite::Sqlite))] #[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct Track { pub struct Track {
pub id: i32, pub id: i32,
pub artist_id: i32, pub file_path: String,
pub artist_id: Option<i32>,
pub collection_id: Option<i32>, pub collection_id: Option<i32>,
pub original_title: Option<String>, pub original_title: Option<String>,
pub display_title: Option<String>, pub display_title: Option<String>,
@@ -48,6 +50,7 @@ pub struct NewArtist<'a> {
#[derive(Debug, Clone, Insertable)] #[derive(Debug, Clone, Insertable)]
#[diesel(table_name = collections)] #[diesel(table_name = collections)]
pub struct NewCollection<'a> { pub struct NewCollection<'a> {
pub artist_id: Option<i32>,
pub collection_type: &'a str, pub collection_type: &'a str,
pub original_name: &'a str, pub original_name: &'a str,
pub display_name: &'a str, pub display_name: &'a str,
@@ -56,7 +59,8 @@ pub struct NewCollection<'a> {
#[derive(Debug, Clone, Insertable)] #[derive(Debug, Clone, Insertable)]
#[diesel(table_name = tracks)] #[diesel(table_name = tracks)]
pub struct NewTrack<'a> { pub struct NewTrack<'a> {
pub artist_id: i32, pub file_path: &'a str,
pub artist_id: Option<i32>,
pub collection_id: Option<i32>, pub collection_id: Option<i32>,
pub original_title: Option<&'a str>, pub original_title: Option<&'a str>,
pub display_title: Option<&'a str>, pub display_title: Option<&'a str>,

View File

@@ -11,6 +11,7 @@ diesel::table! {
diesel::table! { diesel::table! {
collections (id) { collections (id) {
id -> Integer, id -> Integer,
artist_id -> Nullable<Integer>,
collection_type -> Text, collection_type -> Text,
original_name -> Text, original_name -> Text,
display_name -> Text, display_name -> Text,
@@ -20,7 +21,8 @@ diesel::table! {
diesel::table! { diesel::table! {
tracks (id) { tracks (id) {
id -> Integer, id -> Integer,
artist_id -> Integer, file_path -> Text,
artist_id -> Nullable<Integer>,
collection_id -> Nullable<Integer>, collection_id -> Nullable<Integer>,
original_title -> Nullable<Text>, original_title -> Nullable<Text>,
display_title -> Nullable<Text>, display_title -> Nullable<Text>,
@@ -31,6 +33,7 @@ diesel::table! {
} }
} }
diesel::joinable!(collections -> artists (artist_id));
diesel::joinable!(tracks -> artists (artist_id)); diesel::joinable!(tracks -> artists (artist_id));
diesel::joinable!(tracks -> collections (collection_id)); diesel::joinable!(tracks -> collections (collection_id));

View File

@@ -22,10 +22,10 @@ use crate::types::errors;
pub artist: Option<String>, pub artist: Option<String>,
pub collection: Option<String>, pub collection: Option<String>,
pub title: Option<String>, pub title: Option<String>,
pub number: Option<u32>, pub number: Option<i32>,
pub genre: Option<String>, pub genre: Option<String>,
pub datetime: Option<Timestamp>, pub datetime: Option<Timestamp>,
pub length_sec: u32 pub length_sec: i32,
} }
#[derive(Debug)] #[derive(Debug)]