src/library/track.rs (6366B)
1 use lofty::config::ParseOptions;
2 use lofty::file::AudioFile;
3 use lofty::prelude::*;
4 use lofty::probe::Probe;
5 use serde::{Deserialize, Serialize};
6 use std::path::{Path, PathBuf};
7 use std::time::Duration;
8
9 /// Serializable so the scan cache can store parsed tags verbatim; every field
10 /// here came out of the file, so restoring one is equivalent to re-reading it.
11 #[derive(Debug, Clone, Serialize, Deserialize)]
12 pub struct Track {
13 pub path: PathBuf,
14 pub title: String,
15 pub artist: String,
16 pub album: String,
17 pub album_artist: String,
18 pub genre: String,
19 pub year: Option<u32>,
20 pub track_number: Option<u32>,
21 pub track_total: Option<u32>,
22 pub disc_number: Option<u32>,
23 pub disc_total: Option<u32>,
24 pub comment: Option<String>,
25 pub duration: Duration,
26 pub bitrate: Option<u32>,
27 pub sample_rate: Option<u32>,
28 pub bit_depth: Option<u8>,
29 pub channels: Option<u8>,
30 }
31
32 impl Track {
33 pub fn from_path(path: &Path) -> Option<Self> {
34 // Skip cover art: scanning never keeps it, and the album art view reads
35 // the picture straight from the file when it actually needs one. lofty
36 // otherwise decodes and allocates every embedded image — around a
37 // megabyte per track — only to have it dropped.
38 let tagged_file = Probe::open(path)
39 .ok()?
40 .options(ParseOptions::new().read_cover_art(false))
41 .read()
42 .ok()?;
43
44 let tag = tagged_file
45 .primary_tag()
46 .or_else(|| tagged_file.first_tag());
47
48 let properties = tagged_file.properties();
49
50 let title = tag
51 .and_then(|t| t.title())
52 .map(|s| s.to_string())
53 .filter(|s| !s.is_empty())
54 .unwrap_or_else(|| {
55 path.file_stem()
56 .map(|s| s.to_string_lossy().to_string())
57 .unwrap_or_else(|| "Unknown".to_string())
58 });
59
60 Some(Self {
61 path: path.to_path_buf(),
62 title,
63 artist: tag.and_then(|t| t.artist()).map(|s| s.to_string()).unwrap_or_default(),
64 album: tag.and_then(|t| t.album()).map(|s| s.to_string()).unwrap_or_default(),
65 album_artist: tag
66 .and_then(|t| t.get_string(&ItemKey::AlbumArtist))
67 .map(|s| s.to_string())
68 .unwrap_or_default(),
69 genre: tag.and_then(|t| t.genre()).map(|s| s.to_string()).unwrap_or_default(),
70 year: tag.and_then(|t| t.year()),
71 track_number: tag.and_then(|t| t.track()),
72 track_total: tag.and_then(|t| t.track_total()),
73 disc_number: tag.and_then(|t| t.disk()),
74 disc_total: tag.and_then(|t| t.disk_total()),
75 comment: tag
76 .and_then(|t| t.comment())
77 .map(|s| s.to_string())
78 .filter(|s| !s.is_empty()),
79 duration: properties.duration(),
80 // FLAC and other lossless formats often only carry the overall rate.
81 bitrate: properties.audio_bitrate().or_else(|| properties.overall_bitrate()),
82 sample_rate: properties.sample_rate(),
83 bit_depth: properties.bit_depth(),
84 channels: properties.channels(),
85 })
86 }
87
88 pub fn display_artist(&self) -> &str {
89 if self.artist.is_empty() {
90 "Unknown Artist"
91 } else {
92 &self.artist
93 }
94 }
95
96 pub fn display_album(&self) -> &str {
97 if self.album.is_empty() {
98 "Unknown Album"
99 } else {
100 &self.album
101 }
102 }
103
104 pub fn format_duration(&self) -> String {
105 let secs = self.duration.as_secs();
106 let mins = secs / 60;
107 let secs = secs % 60;
108 format!("{}:{:02}", mins, secs)
109 }
110
111 /// "1/12", or just "1" when the total is unknown.
112 pub fn format_index(n: Option<u32>, total: Option<u32>) -> String {
113 match (n, total) {
114 (Some(n), Some(total)) => format!("{}/{}", n, total),
115 (Some(n), None) => n.to_string(),
116 _ => "N/A".to_string(),
117 }
118 }
119
120 /// "44.1 kHz · 16-bit · Stereo" from whichever parts the file reports.
121 pub fn format_quality(&self) -> String {
122 let mut parts = Vec::new();
123 if let Some(hz) = self.sample_rate {
124 parts.push(format!("{:.1} kHz", hz as f64 / 1000.0));
125 }
126 if let Some(bits) = self.bit_depth {
127 parts.push(format!("{}-bit", bits));
128 }
129 match self.channels {
130 Some(1) => parts.push("Mono".to_string()),
131 Some(2) => parts.push("Stereo".to_string()),
132 Some(n) => parts.push(format!("{} ch", n)),
133 None => {}
134 }
135 if parts.is_empty() {
136 "N/A".to_string()
137 } else {
138 parts.join(" \u{00B7} ")
139 }
140 }
141 }
142
143 #[cfg(test)]
144 mod tests {
145 use super::*;
146
147 fn track(sample_rate: Option<u32>, bit_depth: Option<u8>, channels: Option<u8>) -> Track {
148 Track {
149 path: PathBuf::new(),
150 title: String::new(),
151 artist: String::new(),
152 album: String::new(),
153 album_artist: String::new(),
154 genre: String::new(),
155 year: None,
156 track_number: None,
157 track_total: None,
158 disc_number: None,
159 disc_total: None,
160 comment: None,
161 duration: Duration::ZERO,
162 bitrate: None,
163 sample_rate,
164 bit_depth,
165 channels,
166 }
167 }
168
169 #[test]
170 fn index_uses_total_only_when_present() {
171 assert_eq!(Track::format_index(Some(1), Some(12)), "1/12");
172 assert_eq!(Track::format_index(Some(1), None), "1");
173 assert_eq!(Track::format_index(None, Some(12)), "N/A");
174 assert_eq!(Track::format_index(None, None), "N/A");
175 }
176
177 #[test]
178 fn quality_skips_parts_the_file_does_not_report() {
179 assert_eq!(
180 track(Some(44100), Some(16), Some(2)).format_quality(),
181 "44.1 kHz \u{00B7} 16-bit \u{00B7} Stereo"
182 );
183 assert_eq!(track(Some(48000), None, Some(1)).format_quality(), "48.0 kHz \u{00B7} Mono");
184 assert_eq!(track(None, None, Some(6)).format_quality(), "6 ch");
185 assert_eq!(track(None, None, None).format_quality(), "N/A");
186 }
187 }