1use std::{
2 collections::{HashMap, hash_map::Entry},
3 path::{Path, PathBuf},
4 sync::Arc,
5};
6
7use blake3::hash;
8use lofty::{
9 file::TaggedFileExt,
10 picture::PictureType,
11 tag::{ItemKey, TagItem, TagType},
12};
13use lunar_lib::database::{DatabaseEntry, writer::DatabaseWriter};
14
15use barber::{ProgressBar, ProgressRenderer};
16use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
17
18use crate::{
19 database::{LibraryDb, Patchable, tx_extensions::CasTxExtensions},
20 errors::ExtractError,
21 library::{
22 album::{Album, TrackReference, UNKNOWN_ALBUM},
23 artist::{Artist, ArtistGroup, ArtistId},
24 hash_source_files,
25 metadata::{LoftyTagExtensions, extract_instrumental},
26 track::{
27 Track, TrackId, cover_art::CoverArt, lyric_data::LyricData, track_meta::TrackMeta,
28 },
29 },
30 symphonia_helpers::{ContainerExtractResult, extract_from_file},
31 utils::hash_file,
32};
33
34#[derive(Debug, Clone)]
35pub struct ExtractResult {
36 pub track: Track,
37 pub album: Option<Album>,
38 pub artists: Vec<Artist>,
39}
40
41pub fn find_needs_extract(
43 progress_renderer: Arc<dyn ProgressRenderer>,
44) -> Result<Vec<PathBuf>, ExtractError> {
45 let sources = hash_source_files(progress_renderer)?;
46 let known_tracks = Track::db_get_all()?;
47 let known_track_ids: Vec<TrackId> = known_tracks.iter().map(Track::id).collect();
48
49 Ok(sources
50 .into_iter()
51 .filter_map(|(id, path)| (!known_track_ids.contains(&id)).then_some(path))
52 .collect())
53}
54
55pub fn extract(
56 files: &[PathBuf],
57 progress_renderer: Arc<dyn ProgressRenderer>,
58 dry: bool,
59) -> Result<(), ExtractError> {
60 let files: Vec<_> = files.iter().collect();
61
62 if files.is_empty() {
63 return Ok(());
64 }
65
66 let progress_bar = ProgressBar::new(0, files.len(), progress_renderer);
67 progress_bar.set_label("Extracting metadata from files...");
68
69 let writer = DatabaseWriter::<LibraryDb>::spawn();
70
71 files
72 .par_iter()
73 .try_for_each(|source| -> Result<(), ExtractError> {
74 if writer.is_closed() {
75 return Ok(());
76 }
77
78 let ExtractResult {
79 track,
80 album,
81 artists,
82 } = extract_metadata(source)?;
83
84 if writer.is_closed() {
85 return Ok(());
86 }
87
88 if !dry {
89 writer.transaction(move |cas_tx| {
90 cas_tx.tx_patch(track.clone())?;
91
92 if let Some(album) = &album {
93 cas_tx.tx_patch(album.clone())?;
94 }
95
96 for artist in &artists {
97 cas_tx.tx_patch(artist.clone())?;
98 }
99
100 Ok(())
101 });
102 }
103
104 progress_bar.set_label(&format!(
105 "Extracted metadata from '{path}'",
106 path = source.display()
107 ));
108 progress_bar.increment();
109
110 Ok(())
111 })?;
112
113 writer.finish()?;
114
115 progress_bar.flush();
116 Ok(())
117}
118
119pub fn extract_metadata(source_file: impl AsRef<Path>) -> Result<ExtractResult, ExtractError> {
121 let source_file = source_file.as_ref();
122
123 let ContainerExtractResult {
124 container,
125 metadata: _metadata,
127 } = extract_from_file(source_file)?;
128
129 let mut metadata = lofty::read_from_path(source_file)?;
130 let tags = metadata.primary_tag_mut().unwrap();
131
132 let (title, track_artists) = tags.track_title_and_artists();
133
134 let (title, instrumental) = match title {
135 Some(t) => {
136 let (extracted, instrumental) = extract_instrumental(&t);
137 (Some(extracted.into_owned()), Some(instrumental))
138 }
139 None => (None, None),
140 };
141
142 let mut all_artists: HashMap<ArtistId, Artist> =
143 track_artists.iter().cloned().map(|a| (a.id(), a)).collect();
144
145 let date = tags.date();
146
147 let track_num = tags.track_num();
148 let disc_num = tags.disc_num();
149
150 let genre: Vec<String> = tags.take_strings(ItemKey::Genre).collect();
151
152 let mut album = {
153 let (album_title, album_artists) = tags.album_title_and_artists();
154 let track_total = tags.track_total();
155 let disc_total = tags.disc_total();
156
157 let has_album = has_album(
158 title.as_deref(),
159 album_title.as_deref(),
160 &track_artists,
161 &album_artists,
162 track_num,
163 track_total,
164 disc_num,
165 disc_total,
166 );
167
168 if has_album {
169 let mut album = Album::new(
170 album_title.unwrap_or(UNKNOWN_ALBUM.to_owned()),
171 ArtistGroup::from_artists(&album_artists),
172 Vec::new(),
173 );
174
175 for mut artist in album_artists {
176 artist.albums.push(album.id());
177
178 match all_artists.entry(artist.id()) {
179 Entry::Occupied(mut entry) => entry.get_mut().patch(artist),
180 Entry::Vacant(entry) => {
181 entry.insert(artist);
182 }
183 }
184 }
185
186 album.date = date;
187 album.track_total = track_total;
188 album.disc_total = disc_total;
189 album.genre = genre.clone();
190 Some(album)
191 } else {
192 None
193 }
194 };
195
196 let lyrics = tags.lyrics();
197 let lyrics = if instrumental == Some(true) {
198 Some(LyricData::Instrumental)
199 } else {
200 lyrics
201 };
202
203 let other = tags
204 .items()
205 .cloned()
206 .map(TagItem::consume)
207 .filter_map(|(k, v)| {
208 let v = v.text()?.to_owned();
209 let k = k.map_key(TagType::VorbisComments)?.to_owned();
210
211 Some((k, v))
212 });
213
214 let art = tags
215 .get_picture_type(PictureType::CoverFront)
216 .or(tags.pictures().first())
217 .map(|p| CoverArt::Embedded {
218 hash: hash(p.data()),
219 source: source_file.to_path_buf(),
220 });
221
222 let metadata = TrackMeta {
223 album: album.as_ref().map(Album::id),
224 artists: ArtistGroup::from_artists(&track_artists),
225 date,
226 genre,
227 lyric_data: lyrics,
228 other: other.collect(),
229 title,
230 art,
231 };
232
233 let track = Track::new(hash_file(source_file)?, container, metadata);
234
235 if let Some(album) = &mut album {
236 album.tracks.push(TrackReference {
237 id: track.id(),
238 track_num,
239 disc_num,
240 });
241 }
242
243 let mut all_artists: Vec<Artist> = all_artists.into_values().collect();
244
245 for artist in &mut all_artists {
246 if track_artists.contains(artist) {
247 artist.tracks.push(track.id());
248 }
249 }
250
251 let result = ExtractResult {
252 track,
253 album,
254 artists: all_artists,
255 };
256
257 Ok(result)
258}
259
260fn has_album(
261 track_title: Option<&str>,
262 album_title: Option<&str>,
263 track_artists: &[Artist],
264 album_artists: &[Artist],
265 track_num: Option<u32>,
266 track_total: Option<u32>,
267 disc_num: Option<u32>,
268 disc_total: Option<u32>,
269) -> bool {
270 if track_total == Some(1) && disc_total.is_none_or(|d| d <= 1) {
271 return false;
272 }
273
274 if album_title.zip(track_title).is_none_or(|(a, b)| a != b) {
276 return true;
277 }
278
279 if *album_artists != *track_artists {
281 return true;
282 }
283
284 track_num.is_some_and(|v| v > 1)
286 || track_total.is_some_and(|v| v > 1)
287 || disc_num.is_some_and(|v| v > 1)
288 || disc_total.is_some_and(|v| v > 1)
289}