forked from Keelrleek/SoulStream
Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so history is unrecoverable locally. This commit restores version control with the tree exactly as it stands: the working app, the preview-tracks launcher on the artist hero, the scrollable-track-previews design bundle (docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
commit
a667b4b28e
161 files changed
+53170
No files matched your search
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "tagger"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
lofty = "0.22"
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Read tags from every audio file in a folder and print the outcome.
|
||||
//! cargo run -p tagger --example read -- "C:\folder"
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).expect("usage: read <dir>");
|
||||
for entry in std::fs::read_dir(&dir).expect("read_dir").flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
match tagger::read_summary(&path) {
|
||||
Ok(s) => println!(
|
||||
"OK {} | artist={:?} album={:?} title={:?} track={:?} dur={:?}",
|
||||
path.file_name().unwrap().to_string_lossy(),
|
||||
s.artist,
|
||||
s.album,
|
||||
s.title,
|
||||
s.track_no,
|
||||
s.duration_secs
|
||||
),
|
||||
Err(e) => println!("ERR {} | {e}", path.file_name().unwrap().to_string_lossy()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Tag reading/writing and album-art embedding via lofty.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use lofty::config::WriteOptions;
|
||||
use lofty::file::{AudioFile, TaggedFileExt};
|
||||
use lofty::picture::{MimeType, Picture, PictureType};
|
||||
use lofty::probe::Probe;
|
||||
use lofty::tag::{Accessor, Tag};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TagError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("lofty: {0}")]
|
||||
Lofty(#[from] lofty::error::LoftyError),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, TagError>;
|
||||
|
||||
/// What we know about a file from its embedded tags + audio properties.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TagSummary {
|
||||
pub artist: Option<String>,
|
||||
pub album_artist: Option<String>,
|
||||
pub album: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub track_no: Option<u32>,
|
||||
pub year: Option<u32>,
|
||||
pub duration_secs: Option<u32>,
|
||||
pub bitrate_kbps: Option<u32>,
|
||||
pub sample_rate: Option<u32>,
|
||||
pub bit_depth: Option<u32>,
|
||||
pub has_art: bool,
|
||||
}
|
||||
|
||||
pub fn read_summary(path: &Path) -> Result<TagSummary> {
|
||||
let tagged = Probe::open(path)?.read()?;
|
||||
let props = tagged.properties();
|
||||
let mut summary = TagSummary {
|
||||
duration_secs: Some(props.duration().as_secs() as u32),
|
||||
bitrate_kbps: props.audio_bitrate(),
|
||||
sample_rate: props.sample_rate(),
|
||||
bit_depth: props.bit_depth().map(|d| d as u32),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) {
|
||||
summary.artist = non_empty(tag.artist().as_deref());
|
||||
summary.album = non_empty(tag.album().as_deref());
|
||||
summary.title = non_empty(tag.title().as_deref());
|
||||
summary.track_no = tag.track();
|
||||
summary.year = tag.year();
|
||||
summary.album_artist = non_empty(
|
||||
tag.get_string(&lofty::tag::ItemKey::AlbumArtist)
|
||||
.map(|s| s.to_string())
|
||||
.as_deref(),
|
||||
);
|
||||
summary.has_art = !tag.pictures().is_empty();
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// Canonical values to write. `None` fields are left untouched.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TagWrite {
|
||||
pub artist: Option<String>,
|
||||
pub album_artist: Option<String>,
|
||||
pub album: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub track_no: Option<u32>,
|
||||
pub year: Option<u32>,
|
||||
}
|
||||
|
||||
/// Cover bytes come from several providers; PNG originals exist (Cover Art
|
||||
/// Archive scans), so the embedded mime is sniffed rather than assumed.
|
||||
fn sniff_mime(bytes: &[u8]) -> MimeType {
|
||||
if bytes.starts_with(b"\x89PNG") {
|
||||
MimeType::Png
|
||||
} else {
|
||||
MimeType::Jpeg
|
||||
}
|
||||
}
|
||||
|
||||
/// Write tags and (optionally) embed front-cover art. With `replace_art`
|
||||
/// false the cover is only added when the file has none; true drops whatever
|
||||
/// pictures the file arrived with first, so the canonical cover always wins.
|
||||
pub fn write_tags(
|
||||
path: &Path,
|
||||
write: &TagWrite,
|
||||
art_jpeg: Option<&[u8]>,
|
||||
replace_art: bool,
|
||||
) -> Result<()> {
|
||||
let mut tagged = Probe::open(path)?.read()?;
|
||||
|
||||
if tagged.primary_tag().is_none() && tagged.first_tag().is_none() {
|
||||
let tag_type = tagged.primary_tag_type();
|
||||
tagged.insert_tag(Tag::new(tag_type));
|
||||
}
|
||||
let tag = tagged.primary_tag_mut().expect("tag inserted above");
|
||||
|
||||
if let Some(v) = &write.artist {
|
||||
tag.set_artist(v.clone());
|
||||
}
|
||||
if let Some(v) = &write.album {
|
||||
tag.set_album(v.clone());
|
||||
}
|
||||
if let Some(v) = &write.title {
|
||||
tag.set_title(v.clone());
|
||||
}
|
||||
if let Some(v) = write.track_no {
|
||||
tag.set_track(v);
|
||||
}
|
||||
if let Some(v) = write.year {
|
||||
tag.set_year(v);
|
||||
}
|
||||
if let Some(v) = &write.album_artist {
|
||||
tag.insert_text(lofty::tag::ItemKey::AlbumArtist, v.clone());
|
||||
}
|
||||
|
||||
if let Some(bytes) = art_jpeg {
|
||||
if replace_art {
|
||||
while !tag.pictures().is_empty() {
|
||||
tag.remove_picture(0);
|
||||
}
|
||||
}
|
||||
if tag.pictures().is_empty() {
|
||||
let picture = Picture::new_unchecked(
|
||||
PictureType::CoverFront,
|
||||
Some(sniff_mime(bytes)),
|
||||
None,
|
||||
bytes.to_vec(),
|
||||
);
|
||||
tag.push_picture(picture);
|
||||
}
|
||||
}
|
||||
|
||||
tagged.save_to_path(path, WriteOptions::default())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace any existing embedded pictures with the given front cover.
|
||||
pub fn replace_art(path: &Path, art_jpeg: &[u8]) -> Result<()> {
|
||||
let mut tagged = Probe::open(path)?.read()?;
|
||||
if tagged.primary_tag().is_none() && tagged.first_tag().is_none() {
|
||||
let tag_type = tagged.primary_tag_type();
|
||||
tagged.insert_tag(Tag::new(tag_type));
|
||||
}
|
||||
let tag = tagged.primary_tag_mut().expect("tag inserted above");
|
||||
while !tag.pictures().is_empty() {
|
||||
tag.remove_picture(0);
|
||||
}
|
||||
tag.push_picture(Picture::new_unchecked(
|
||||
PictureType::CoverFront,
|
||||
Some(sniff_mime(art_jpeg)),
|
||||
None,
|
||||
art_jpeg.to_vec(),
|
||||
));
|
||||
tagged.save_to_path(path, WriteOptions::default())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn non_empty(s: Option<&str>) -> Option<String> {
|
||||
s.map(str::trim).filter(|s| !s.is_empty()).map(String::from)
|
||||
}
|
||||
|
||||
/// Best-effort metadata parse from a Soulseek path when tags are missing.
|
||||
/// Handles `Artist - Album (Year)` folders and `NN - Title` / `NN Title`
|
||||
/// filenames.
|
||||
pub fn parse_from_path(remote_or_local_path: &str) -> TagWrite {
|
||||
let parts: Vec<&str> = remote_or_local_path.split(['\\', '/']).collect();
|
||||
let file = parts.last().copied().unwrap_or_default();
|
||||
let folder = parts
|
||||
.len()
|
||||
.checked_sub(2)
|
||||
.and_then(|i| parts.get(i))
|
||||
.copied()
|
||||
.unwrap_or("");
|
||||
|
||||
let stem = file.rsplit_once('.').map(|(s, _)| s).unwrap_or(file);
|
||||
let mut write = TagWrite::default();
|
||||
|
||||
// "01 - Title", "01. Title", "01 Title"
|
||||
let trimmed = stem.trim();
|
||||
let (track, title) = match trimmed.split_once(|c: char| !c.is_ascii_digit()) {
|
||||
Some((digits, rest)) if !digits.is_empty() && digits.len() <= 3 => {
|
||||
let title = rest.trim_start_matches(['-', '.', ' ', '_']).trim();
|
||||
(
|
||||
digits.parse::<u32>().ok(),
|
||||
if title.is_empty() { trimmed } else { title },
|
||||
)
|
||||
}
|
||||
_ => (None, trimmed),
|
||||
};
|
||||
write.track_no = track;
|
||||
write.title = Some(title.to_string());
|
||||
|
||||
// Folder: "Artist - Album (1997)" | "Album (1997)" | "1997 - Album"
|
||||
let mut folder = folder.trim().to_string();
|
||||
if let Some(idx) = folder.rfind('(') {
|
||||
let inner: String = folder[idx + 1..]
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect();
|
||||
if inner.len() == 4 {
|
||||
write.year = inner.parse().ok();
|
||||
folder = folder[..idx].trim().to_string();
|
||||
}
|
||||
}
|
||||
if write.year.is_none() {
|
||||
let lead: String = folder.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if lead.len() == 4 {
|
||||
write.year = lead.parse().ok();
|
||||
folder = folder[lead.len()..]
|
||||
.trim_start_matches(['-', '.', ' '])
|
||||
.to_string();
|
||||
}
|
||||
}
|
||||
if let Some((artist, album)) = folder.split_once(" - ") {
|
||||
write.artist = Some(artist.trim().to_string());
|
||||
write.album = Some(album.trim().to_string());
|
||||
} else if !folder.is_empty() {
|
||||
write.album = Some(folder);
|
||||
}
|
||||
write
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_full_convention() {
|
||||
let w = parse_from_path(
|
||||
r"Music\Radiohead - OK Computer (1997)\03 - Subterranean Homesick Alien.flac",
|
||||
);
|
||||
assert_eq!(w.artist.as_deref(), Some("Radiohead"));
|
||||
assert_eq!(w.album.as_deref(), Some("OK Computer"));
|
||||
assert_eq!(w.year, Some(1997));
|
||||
assert_eq!(w.track_no, Some(3));
|
||||
assert_eq!(w.title.as_deref(), Some("Subterranean Homesick Alien"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_year_leading_folder() {
|
||||
let w = parse_from_path(r"share\1997 - OK Computer\01 Airbag.flac");
|
||||
assert_eq!(w.album.as_deref(), Some("OK Computer"));
|
||||
assert_eq!(w.year, Some(1997));
|
||||
assert_eq!(w.track_no, Some(1));
|
||||
assert_eq!(w.title.as_deref(), Some("Airbag"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_no_track_number() {
|
||||
let w = parse_from_path(r"stuff\Nevermind (1991)\Lithium.flac");
|
||||
assert_eq!(w.title.as_deref(), Some("Lithium"));
|
||||
assert_eq!(w.track_no, None);
|
||||
assert_eq!(w.album.as_deref(), Some("Nevermind"));
|
||||
assert_eq!(w.year, Some(1991));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user