Re-implement indexing; Implement ReplayGain

This commit is contained in:
Agatha Lovelace 2024-02-29 00:28:53 +01:00
parent 22bb854862
commit 8771669a47
Signed by: sorceress
GPG Key ID: 01D0B3AB10CED4F8
15 changed files with 687 additions and 55 deletions

255
Cargo.lock generated
View File

@ -474,6 +474,34 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cace84e55f07e7301bae1c519df89cdad8cc3cd868413d3fdbdeca9ff3db484"
[[package]]
name = "crc32fast"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.8"
@ -486,12 +514,9 @@ dependencies = [
[[package]]
name = "crossbeam-utils"
version = "0.8.16"
version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294"
dependencies = [
"cfg-if",
]
checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345"
[[package]]
name = "crypto-common"
@ -503,6 +528,12 @@ dependencies = [
"typenum",
]
[[package]]
name = "data-encoding"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5"
[[package]]
name = "der"
version = "0.7.8"
@ -591,15 +622,19 @@ dependencies = [
"chrono",
"dirs",
"kdl",
"lofty",
"miette",
"mime",
"mime_guess",
"owo-colors",
"rayon",
"replaygain",
"sea-orm",
"sea-orm-migration",
"sea-query",
"symphonia",
"thiserror",
"time",
"tokio",
"tracing",
"tracing-core",
@ -649,6 +684,12 @@ version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
[[package]]
name = "extended"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
[[package]]
name = "fastrand"
version = "2.0.1"
@ -661,6 +702,16 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6"
[[package]]
name = "flate2"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "flume"
version = "0.11.0"
@ -1061,6 +1112,32 @@ dependencies = [
"scopeguard",
]
[[package]]
name = "lofty"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f75066eb1d25a7047fb2667edb410ae2592439ed81546f95c28b0a1c7d7d3818"
dependencies = [
"byteorder",
"data-encoding",
"flate2",
"lofty_attr",
"log",
"ogg_pager",
"paste",
]
[[package]]
name = "lofty_attr"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764b60e1ddd07e5665a6a17636a95cd7d8f3b86c73503a69c32979d05f72f3cf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.48",
]
[[package]]
name = "log"
version = "0.4.20"
@ -1232,6 +1309,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-integer"
version = "0.1.45"
@ -1282,6 +1365,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "ogg_pager"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c949d63b387b25c332f6e39d1762dd4b405008289dd7681f02c258b1294653ca"
dependencies = [
"byteorder",
]
[[package]]
name = "once_cell"
version = "1.18.0"
@ -1585,6 +1677,26 @@ dependencies = [
"getrandom",
]
[[package]]
name = "rayon"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7237101a77a10773db45d62004a272517633fbcc3df19d96455ede1122e051"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.2.16"
@ -1676,6 +1788,12 @@ dependencies = [
"bytecheck",
]
[[package]]
name = "replaygain"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de46745c4722b3312b44f1aa45c9eb813059cab25705de6c69180f3715c4decc"
[[package]]
name = "rkyv"
version = "0.7.42"
@ -2413,28 +2531,32 @@ dependencies = [
[[package]]
name = "symphonia"
version = "0.5.3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62e48dba70095f265fdb269b99619b95d04c89e619538138383e63310b14d941"
checksum = "815c942ae7ee74737bb00f965fa5b5a2ac2ce7b6c01c0cc169bbeaf7abd5f5a9"
dependencies = [
"lazy_static",
"symphonia-bundle-flac",
"symphonia-bundle-mp3",
"symphonia-codec-aac",
"symphonia-codec-adpcm",
"symphonia-codec-alac",
"symphonia-codec-pcm",
"symphonia-codec-vorbis",
"symphonia-core",
"symphonia-format-caf",
"symphonia-format-isomp4",
"symphonia-format-mkv",
"symphonia-format-ogg",
"symphonia-format-wav",
"symphonia-format-riff",
"symphonia-metadata",
]
[[package]]
name = "symphonia-bundle-flac"
version = "0.5.3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f23b0482a7cb18fcdf9981ab0b78df800ef0080187d294650023c462439058d"
checksum = "72e34f34298a7308d4397a6c7fbf5b84c5d491231ce3dd379707ba673ab3bd97"
dependencies = [
"log",
"symphonia-core",
@ -2444,11 +2566,10 @@ dependencies = [
[[package]]
name = "symphonia-bundle-mp3"
version = "0.5.3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f31d7fece546f1e6973011a9eceae948133bbd18fd3d52f6073b1e38ae6368a"
checksum = "c01c2aae70f0f1fb096b6f0ff112a930b1fb3626178fba3ae68b09dce71706d4"
dependencies = [
"bitflags 1.3.2",
"lazy_static",
"log",
"symphonia-core",
@ -2456,10 +2577,31 @@ dependencies = [
]
[[package]]
name = "symphonia-codec-adpcm"
version = "0.5.3"
name = "symphonia-codec-aac"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "870e7dc1865d818c7b6318879d060553a73a3b2a3b8443dff90910f10ac41150"
checksum = "cdbf25b545ad0d3ee3e891ea643ad115aff4ca92f6aec472086b957a58522f70"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-adpcm"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c94e1feac3327cd616e973d5be69ad36b3945f16b06f19c6773fc3ac0b426a0f"
dependencies = [
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-alac"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d8a6666649a08412906476a8b0efd9b9733e241180189e9f92b09c08d0e38f3"
dependencies = [
"log",
"symphonia-core",
@ -2467,9 +2609,9 @@ dependencies = [
[[package]]
name = "symphonia-codec-pcm"
version = "0.5.3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47f1fbd220a06a641c8ce2ddad10f5ef6ee5cc0c54d9044d25d43b0d3119deaa"
checksum = "f395a67057c2ebc5e84d7bb1be71cce1a7ba99f64e0f0f0e303a03f79116f89b"
dependencies = [
"log",
"symphonia-core",
@ -2477,9 +2619,9 @@ dependencies = [
[[package]]
name = "symphonia-codec-vorbis"
version = "0.5.3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3953397e3506aa01350c4205817e4f95b58d476877a42f0458d07b665749e203"
checksum = "5a98765fb46a0a6732b007f7e2870c2129b6f78d87db7987e6533c8f164a9f30"
dependencies = [
"log",
"symphonia-core",
@ -2488,9 +2630,9 @@ dependencies = [
[[package]]
name = "symphonia-core"
version = "0.5.3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c73eb88fee79705268cc7b742c7bc93a7b76e092ab751d0833866970754142"
checksum = "798306779e3dc7d5231bd5691f5a813496dc79d3f56bf82e25789f2094e022c3"
dependencies = [
"arrayvec",
"bitflags 1.3.2",
@ -2500,10 +2642,34 @@ dependencies = [
]
[[package]]
name = "symphonia-format-mkv"
version = "0.5.3"
name = "symphonia-format-caf"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5c61dfc851ad25d4043d8c231d8617e8f7cd02a6cc0edad21ade21848d58895"
checksum = "e43c99c696a388295a29fe71b133079f5d8b18041cf734c5459c35ad9097af50"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-format-isomp4"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abfdf178d697e50ce1e5d9b982ba1b94c47218e03ec35022d9f0e071a16dc844"
dependencies = [
"encoding_rs",
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-mkv"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bb43471a100f7882dc9937395bd5ebee8329298e766250b15b3875652fe3d6f"
dependencies = [
"lazy_static",
"log",
@ -2514,9 +2680,9 @@ dependencies = [
[[package]]
name = "symphonia-format-ogg"
version = "0.5.3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bf1a00ccd11452d44048a0368828040f778ae650418dbd9d8765b7ee2574c8d"
checksum = "ada3505789516bcf00fc1157c67729eded428b455c27ca370e41f4d785bfa931"
dependencies = [
"log",
"symphonia-core",
@ -2525,11 +2691,12 @@ dependencies = [
]
[[package]]
name = "symphonia-format-wav"
version = "0.5.3"
name = "symphonia-format-riff"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da76614728fa27c003bdcdfbac51396bd8fcbf94c95fe8e62f1d2bac58ef03a4"
checksum = "05f7be232f962f937f4b7115cbe62c330929345434c834359425e043bfd15f50"
dependencies = [
"extended",
"log",
"symphonia-core",
"symphonia-metadata",
@ -2537,9 +2704,9 @@ dependencies = [
[[package]]
name = "symphonia-metadata"
version = "0.5.3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89c3e1937e31d0e068bbe829f66b2f2bfaa28d056365279e0ef897172c3320c0"
checksum = "bc622b9841a10089c5b18e99eb904f4341615d5aa55bbf4eedde1be721a4023c"
dependencies = [
"encoding_rs",
"lazy_static",
@ -2549,9 +2716,9 @@ dependencies = [
[[package]]
name = "symphonia-utils-xiph"
version = "0.5.3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a450ca645b80d69aff8b35576cbfdc7f20940b29998202aab910045714c951f8"
checksum = "484472580fa49991afda5f6550ece662237b00c6f562c7d9638d1b086ed010fe"
dependencies = [
"symphonia-core",
"symphonia-metadata",
@ -2651,12 +2818,13 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.30"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5"
checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde",
"time-core",
@ -2671,10 +2839,11 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
[[package]]
name = "time-macros"
version = "0.2.15"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ad70d68dba9e1f8aceda7aa6711965dfec1cac869f311a51bd08b3a2ccbce20"
checksum = "7ba3a3ef41e6672a2f0f001392bb5dcd3ff0a9992d618ca761a11c3121547774"
dependencies = [
"num-conv",
"time-core",
]
@ -2778,20 +2947,20 @@ dependencies = [
[[package]]
name = "tracing-log"
version = "0.1.3"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ddad33d2d10b1ed7eb9d1f518a5674713876e97e5bb9b7345a7984fbb4f922"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"lazy_static",
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.17"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77"
checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b"
dependencies = [
"matchers",
"nu-ansi-term",

View File

@ -10,17 +10,21 @@ adler = "1.0.2"
chrono = "0.4.31"
dirs = "5.0.1"
kdl = "4.6.0"
lofty = "0.18.2"
miette = { version = "5.10.0", features = ["fancy"] }
mime = "0.3.17"
mime_guess = "2.0.4"
owo-colors = "3.5.0"
rayon = "1.8.1"
replaygain = "1.0.1"
sea-orm = { version = "0.12.4", features = ["sqlx-sqlite", "runtime-tokio-native-tls", "macros"] }
sea-orm-migration = "0.12.4"
sea-query = "0.30.2"
symphonia = { version = "0.5.3", features = ["flac", "mp3", "vorbis", "ogg", "wav"] }
symphonia = { version = "0.5.4", features = ["all"] }
thiserror = "1.0.56"
time = "0.3.34"
tokio = { version = "1.33.0", features = ["full"] }
tracing = { version = "0.1.40" }
tracing-core = "0.1.32"
tracing-subscriber = "0.3.17"
tracing-subscriber = "0.3.18"
walkdir = "2.4.0"

View File

@ -8,7 +8,7 @@ use super::{
use kdl::{KdlDocument, KdlNode};
use miette::miette;
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct Source {
pub id: u32,
pub name: String,

View File

@ -1,4 +1,8 @@
use std::{fmt::Debug, num::TryFromIntError, sync::PoisonError};
use std::{
fmt::Debug,
num::{ParseFloatError, ParseIntError, TryFromIntError},
sync::PoisonError,
};
use kdl::KdlError;
use miette::Diagnostic;
@ -9,12 +13,16 @@ use tokio::task::JoinError;
pub enum EleanorError {
#[error("Couldn't unlock mutex")]
LockFailed,
#[error("Failed to convert types")]
CastError,
#[error("Failed to convert from integer: {0}")]
TryFromIntError(#[from] TryFromIntError),
#[error("Failed to parse integer: {0}")]
ParseIntError(#[from] ParseIntError),
#[error("Failed to parse float")]
ParseFloatError(#[from] ParseFloatError),
#[error("Failed to create probe: {0}")]
SymponiaError(#[from] symphonia::core::errors::Error),
#[error("Failed to read song metadata: {0}")]
LoftyError(#[from] lofty::LoftyError),
#[error("Database error: {0}")]
DatabaseError(#[from] sea_orm::DbErr),
#[error("An IO error occured: {0}")]

250
src/backend/indexing.rs Normal file
View File

@ -0,0 +1,250 @@
use std::{ffi::OsStr, fs::File, hash::Hasher, path::Path};
use adler::Adler32;
use lofty::{AudioFile, ItemKey, TaggedFileExt};
use miette::{miette, IntoDiagnostic, Result};
use rayon::prelude::*;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QuerySelect, Set};
use symphonia::{
core::{
formats::{FormatOptions, FormatReader},
io::{MediaSourceStream, MediaSourceStreamOptions},
meta::{Limit, MetadataOptions},
probe::Hint,
},
default::get_probe,
};
use time::OffsetDateTime;
use tracing::debug;
use walkdir::{DirEntry, WalkDir};
use crate::backend::replaygain::{format_gain, ReplayGain};
use super::{
config::Source,
error::EleanorError,
model::{library, library::Column, sources},
};
/// Get audio packets, ignoring metadata
fn get_packets(path: &Path) -> Result<Box<dyn FormatReader>> {
let file = Box::new(File::open(path).into_diagnostic()?);
let probe = get_probe();
let ext = path.extension().and_then(OsStr::to_str).unwrap_or("");
let source = MediaSourceStream::new(file, MediaSourceStreamOptions::default());
probe
.format(
Hint::new().with_extension(ext),
source,
&FormatOptions::default(),
&MetadataOptions {
limit_metadata_bytes: Limit::Maximum(0),
limit_visual_bytes: Limit::Maximum(0),
},
)
.into_diagnostic()
.map(|v| v.format)
}
fn hash_packets(data: &mut Box<dyn FormatReader>) -> u64 {
let mut adler = Adler32::new();
while let Ok(packet) = data.next_packet() {
adler.write(&packet.data);
}
adler.finish()
}
fn index_song(
file: &DirEntry,
source: &Source,
force: bool,
indexed_ts: OffsetDateTime,
) -> Result<Option<library::ActiveModel>, EleanorError> {
// Re-index previously indexed files
if !force {
let modified = file
.metadata()
.into_diagnostic()?
.modified()?
.duration_since(indexed_ts.into())
.is_ok();
if modified {
debug!("Skipping file {}", file.path().display());
return Ok(None);
}
}
debug!("Indexing file {}", file.path().display());
let tagged_file = lofty::read_from_path(file.path())?;
let tags = tagged_file.primary_tag().or(tagged_file.first_tag());
let properties = tagged_file.properties();
// Hash audio packets
let mut packets = get_packets(file.path())?;
let hash = hash_packets(&mut packets);
let rg_track_gain = tags
.and_then(|t| t.get_string(&ItemKey::ReplayGainTrackGain))
.and_then(|v| format_gain(v).ok());
let rg_track_peak = tags
.and_then(|t| t.get_string(&ItemKey::ReplayGainTrackPeak))
.and_then(|v| format_gain(v).ok());
let rg_album_gain = tags
.and_then(|t| t.get_string(&ItemKey::ReplayGainAlbumGain))
.and_then(|v| format_gain(v).ok());
let rg_album_peak = tags
.and_then(|t| t.get_string(&ItemKey::ReplayGainAlbumPeak))
.and_then(|v| format_gain(v).ok());
// Check for existing ReplayGain tags.
let mut rg = if let (Some(track_gain), Some(track_peak)) = (rg_track_gain, rg_track_peak) {
Ok(ReplayGain {
track_gain,
track_peak,
album_gain: None,
album_peak: None,
})
} else {
// Calculate replaygain values for the audio track
let mut packets = get_packets(file.path())?;
ReplayGain::try_calculate(&mut packets)
};
// Set album gain and peak, if present in metadata.
if let Ok(rg) = &mut rg {
rg.album_gain = rg_album_gain;
rg.album_peak = rg_album_peak;
}
let song: library::ActiveModel = library::ActiveModel {
path: Set(file
.path()
.parent()
.and_then(Path::to_str)
.ok_or(miette!("Couldn't get path for file {:?}", file))?
.to_string()),
filename: Set(file
.file_name()
.to_str()
.ok_or(miette!("Couldn't get filename for file {:?}", file))?
.to_string()),
source_id: Set(source.id),
hash: Set(hash.try_into()?),
artist: Set(tags
.and_then(lofty::Accessor::artist)
.map(|t| t.to_string())),
album_artist: Set(tags
.and_then(|t| t.get_string(&lofty::ItemKey::AlbumArtist))
.map(|t| t.to_string())),
name: Set(tags.and_then(lofty::Accessor::title).map(|t| t.to_string())),
album: Set(tags.and_then(lofty::Accessor::album).map(|t| t.to_string())),
genres: Set(tags.and_then(lofty::Accessor::genre).map(|t| t.to_string())),
track: Set(tags.and_then(lofty::Accessor::track).map(|t| t as i32)),
disc: Set(tags.and_then(lofty::Accessor::disk).map(|t| t as i32)),
year: Set(tags.and_then(lofty::Accessor::year).map(|t| t as i32)),
duration: Set(properties.duration().as_millis().try_into()?),
rg_track_gain: Set(rg.as_ref().map(|v| f64::from(v.track_gain)).ok()),
rg_track_peak: Set(rg.as_ref().map(|v| f64::from(v.track_peak)).ok()),
rg_album_gain: Set(rg
.as_ref()
.map(|v| v.album_gain.map(f64::from))
.ok()
.flatten()),
rg_album_peak: Set(rg
.as_ref()
.map(|v| v.album_peak.map(f64::from))
.ok()
.flatten()),
..Default::default()
};
Ok(Some(song))
}
pub async fn index_source(
source: Source,
force: bool,
db: &DatabaseConnection,
) -> Result<(), EleanorError> {
// Get timestamp of last successful scan for current source, or fall back to
// a timestamp that's unlikely to be encountered
let indexed_ts = sources::Entity::find()
.filter(sources::Column::Id.eq(source.id))
.column(sources::Column::LastIndexed)
.all(db)
.await?
.into_iter()
.next()
.and_then(|v| v.last_indexed)
.unwrap_or(String::from("0"))
.parse::<i64>()?;
let indexed_ts = OffsetDateTime::from_unix_timestamp(indexed_ts).into_diagnostic()?;
let songs: Vec<library::ActiveModel> = WalkDir::new(&source.path)
.into_iter()
.filter_map(Result::ok)
.collect::<Vec<_>>()
.par_iter()
.filter(|e| !e.file_type().is_dir()) // Exclude directories
.filter(|e| {
mime_guess::from_path(e.path())
.first()
.is_some_and(|v| v.type_() == mime::AUDIO) // Exclude non-audio files
})
.map(|file| index_song(file, &source, force, indexed_ts))
.collect::<Result<Vec<_>, EleanorError>>()?
.into_iter()
.flatten()
.collect();
// Write metadata to database
library::Entity::insert_many(songs)
.on_conflict(
sea_query::OnConflict::column(Column::Hash)
.update_columns([
Column::Artist,
Column::AlbumArtist,
Column::Name,
Column::Album,
Column::Duration,
Column::Genres,
Column::Track,
Column::Disc,
Column::Year,
Column::RgTrackGain,
Column::RgTrackPeak,
Column::RgAlbumGain,
Column::RgAlbumPeak,
])
.clone(),
)
.on_empty_do_nothing()
.exec(db)
.await?;
// Update last indexed timestamp
sources::Entity::insert(sources::ActiveModel {
id: Set(source.id),
last_indexed: Set(Some(
time::OffsetDateTime::now_utc().unix_timestamp().to_string(),
)),
})
.on_conflict(
sea_query::OnConflict::column(sources::Column::Id)
.update_column(sources::Column::LastIndexed)
.clone(),
)
.exec(db)
.await?;
Ok(())
}

View File

@ -29,6 +29,7 @@ impl MigrationTrait for Migration {
.col(ColumnDef::new(Song::Duration).integer().not_null())
.col(ColumnDef::new(Song::Genres).string())
.col(ColumnDef::new(Song::Track).integer())
.col(ColumnDef::new(Song::Disc).integer())
.col(ColumnDef::new(Song::Year).integer())
.col(ColumnDef::new(Song::RGTrackGain).double())
.col(ColumnDef::new(Song::RGTrackPeak).double())
@ -69,6 +70,7 @@ pub enum Song {
Genres,
/// Number of the track in the album
Track,
Disc,
Year,
RGTrackGain,
RGTrackPeak,

View File

@ -0,0 +1,39 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Source::Table)
.if_not_exists()
.col(
ColumnDef::new(Source::Id)
.integer()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(Source::LastIndexed).string())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Source::Table).to_owned())
.await
}
}
#[derive(Iden)]
pub enum Source {
#[iden = "sources"]
Table,
Id,
LastIndexed,
}

View File

@ -3,6 +3,7 @@ use sea_orm_migration::prelude::*;
mod m20220803_000001_create_library;
mod m20220803_000001_create_playlist_entries;
mod m20220803_000001_create_playlists;
mod m20240223_185340_create_sources;
pub struct Migrator;
@ -11,8 +12,9 @@ impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20220803_000001_create_library::Migration),
Box::new(m20220803_000001_create_playlists::Migration),
Box::new(m20220803_000001_create_playlist_entries::Migration),
Box::new(m20220803_000001_create_playlists::Migration),
Box::new(m20240223_185340_create_sources::Migration),
]
}
}

View File

@ -1,12 +1,14 @@
pub mod config;
pub mod error;
pub mod indexing;
mod kdl_utils;
pub mod logging;
#[allow(clippy::pedantic)]
mod migrator;
#[allow(clippy::pedantic)]
pub mod model;
// pub mod playback;
pub mod error;
mod kdl_utils;
pub mod logging;
pub mod playback;
pub mod replaygain;
pub mod utils;
use std::fs::{create_dir_all, File};

View File

@ -9,7 +9,7 @@ pub struct Model {
pub id: i32,
pub path: String,
pub filename: String,
pub source_id: i32,
pub source_id: u32,
pub hash: u32,
pub artist: Option<String>,
pub album_artist: Option<String>,
@ -18,6 +18,7 @@ pub struct Model {
pub duration: u32,
pub genres: Option<String>,
pub track: Option<i32>,
pub disc: Option<i32>,
pub year: Option<i32>,
#[sea_orm(column_type = "Double", nullable)]
pub rg_track_gain: Option<f64>,

View File

@ -5,3 +5,4 @@ pub mod prelude;
pub mod library;
pub mod playlist_entries;
pub mod playlists;
pub mod sources;

View File

@ -3,3 +3,4 @@
pub use super::library::Entity as Library;
pub use super::playlist_entries::Entity as PlaylistEntries;
pub use super::playlists::Entity as Playlists;
pub use super::sources::Entity as Sources;

View File

@ -0,0 +1,16 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.2
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "sources")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: u32,
pub last_indexed: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

1
src/backend/playback.rs Normal file
View File

@ -0,0 +1 @@

136
src/backend/replaygain.rs Normal file
View File

@ -0,0 +1,136 @@
use miette::miette;
use symphonia::core::{audio::SampleBuffer, codecs::DecoderOptions, formats::FormatReader};
use super::error::EleanorError;
#[derive(Debug, Clone)]
pub struct ReplayGain {
pub track_gain: f32,
pub track_peak: f32,
pub album_gain: Option<f32>,
pub album_peak: Option<f32>,
}
impl ReplayGain {
pub(crate) fn try_calculate(audio: &mut Box<dyn FormatReader>) -> Result<Self, EleanorError> {
let track = audio
.default_track()
.ok_or_else(|| miette!("No default track was found"))?;
let params = &track.codec_params;
let (sample_rate, channels) = (params.sample_rate, params.channels);
let Some(sample_rate) = sample_rate else {
return Err(miette!("Sample rate must be known").into());
};
// Only stereo is supported.
if !channels.is_some_and(|x| x.count() == 2) {
return Err(miette!("Unsupported channel configuration: {:?}", channels).into());
}
// Only 44.1kHz and 48kHz are supported.
let Some(mut rg) = replaygain::ReplayGain::new(sample_rate as usize) else {
return Err(miette!("Unsupported sample rate: {:?}", sample_rate).into());
};
let mut decoder =
symphonia::default::get_codecs().make(params, &DecoderOptions::default())?;
let track_id = track.id;
let mut samples: Vec<f32> = vec![];
let mut sample_buf = None;
loop {
let packet = match audio.next_packet() {
Ok(packet) => packet,
Err(symphonia::core::errors::Error::IoError(ref packet_error))
if packet_error.kind() == std::io::ErrorKind::UnexpectedEof =>
{
// End of audio stream
break;
}
Err(e) => {
return Err(e.into());
}
};
// Skip packets belonging to other audio tracks
if packet.track_id() != track_id {
continue;
}
match decoder.decode(&packet) {
Ok(buffer) => {
if sample_buf.is_none() {
let spec = *buffer.spec();
let duration = buffer.capacity() as u64;
sample_buf = Some(SampleBuffer::<f32>::new(duration, spec));
}
if let Some(target) = &mut sample_buf {
target.copy_interleaved_ref(buffer);
}
}
Err(symphonia::core::errors::Error::DecodeError(_)) => (),
Err(_) => break,
}
if let Some(buf) = &mut sample_buf {
samples.extend(buf.samples());
}
}
if samples.is_empty() {
return Err(miette!("No samples were decoded from input audio").into());
};
rg.process_samples(&samples);
let (track_gain, track_peak) = rg.finish();
Ok(Self {
track_gain,
track_peak,
album_gain: None,
album_peak: None,
})
}
}
pub(crate) fn format_gain(gain: &str) -> Result<f32, EleanorError> {
gain.chars()
.filter(|c| c.is_numeric() || matches!(*c, '-' | '+' | '.'))
.collect::<String>()
.parse::<f32>()
.map_err(EleanorError::from)
}
#[test]
fn rg_parse_expected() {
for (input, expected) in [("-8.97 dB", -8.97), ("12.75 dB", 12.75), ("0.00 dB", 0.0)] {
assert_eq!(format_gain(input).unwrap(), expected)
}
}
#[test]
fn rg_parse_malformed() {
for (input, expected) in [
("-8.5712 dB", -8.5712),
("+2.3 dB", 2.3),
(" 13.12 dB", 13.12),
("006.66 Db", 6.66),
("0.93dB", 0.93),
("24.12 deutscheBahn", 24.12),
] {
assert_eq!(format_gain(input).unwrap(), expected)
}
}
#[test]
fn rg_parse_invalid() {
for input in ["", "akjdfnhlkjfh", "🥺", " DB "] {
assert!(format_gain(input).is_err())
}
}