Skip to main content

selene_core/library/
extract.rs

1use std::{
2    collections::{HashMap, hash_map::Entry},
3    convert::Infallible,
4    path::{Path, PathBuf},
5    sync::Arc,
6};
7
8use blake3::hash;
9use lofty::{
10    file::TaggedFileExt,
11    picture::PictureType,
12    tag::{ItemKey, TagItem, TagType},
13};
14use lunar_lib::{
15    database::{
16        CompareAndSwapTransaction, CustomTransactionError, DatabaseEntry, DbHandle,
17        TransactionError, db_transaction, writer::DatabaseWriter,
18    },
19    vec_ext::VecExtensions,
20};
21
22use barber::{ProgressBar, ProgressRenderer};
23use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
24use thiserror::Error;
25
26use crate::{
27    config::common_config,
28    database::LibraryDb,
29    library::{
30        album::{Album, TrackReference},
31        artist::{Artist, ArtistId},
32        hash_source_files,
33        metadata::{LoftyTagTakeAccessors, extract_instrumental},
34        track::{
35            ResolvedTrack, Track, TrackId, cover_art::CoverArt, lyric_data::LyricData,
36            track_meta::TrackMeta,
37        },
38    },
39    media_container::ContainerError,
40    symphonia_helpers::{ContainerExtractResult, extract_from_file},
41};
42
43#[derive(Debug, Error)]
44pub enum ExtractError {
45    #[error("IoError: {0}")]
46    Io(#[from] std::io::Error),
47
48    #[error("LoftyError: {0}")]
49    Lofty(#[from] lofty::error::LoftyError),
50
51    #[error("DatabaseError: {0}")]
52    Database(#[from] lunar_lib::database::DatabaseError),
53
54    #[error("Transaction Error: {0}")]
55    Transaction(#[from] TransactionError),
56
57    #[error("Container Error: {0}")]
58    Container(#[from] ContainerError),
59
60    #[error("Template Error: {0}")]
61    Template(#[from] lunar_lib::formatter::TemplateError),
62
63    #[error("InvalidContainer Error")]
64    InvalidContainer,
65}
66
67/// Filters a slice of [`TrackId`]'s using the input
68pub fn find_needs_extract(
69    progress_renderer: Arc<dyn ProgressRenderer>,
70    db: &LibraryDb,
71) -> Result<Vec<PathBuf>, ExtractError> {
72    let sources = hash_source_files(progress_renderer)?;
73    let known_tracks = Track::db_get_all(db)?;
74    let known_track_ids: Vec<TrackId> = known_tracks.iter().map(Track::id).collect();
75
76    Ok(sources
77        .into_iter()
78        .filter_map(|(id, path)| (!known_track_ids.contains(&id)).then_some(path))
79        .collect())
80}
81
82pub fn extract(
83    progress_renderer: Arc<dyn ProgressRenderer>,
84    dry: bool,
85) -> Result<(), ExtractError> {
86    let db = DbHandle::<LibraryDb>::open()?;
87    let files = find_needs_extract(progress_renderer.clone(), &db)?;
88
89    if files.is_empty() {
90        return Ok(());
91    }
92
93    let progress_bar = ProgressBar::new(0, files.len(), progress_renderer);
94    progress_bar.set_label("Extracting metadata from files...");
95
96    #[inline]
97    fn transaction_fn(
98        cas_tx: &mut CompareAndSwapTransaction<LibraryDb>,
99        result: &ResolvedTrack,
100    ) -> Result<(), TransactionError> {
101        cas_tx.tx_upsert((*result.track).clone())?;
102
103        if let Some((album, artists, ..)) = result.album_info() {
104            cas_tx.tx_patch((**album).clone())?;
105            for artist in artists {
106                cas_tx.tx_patch((**artist).clone())?;
107            }
108        }
109
110        for artist in result.artists() {
111            cas_tx.tx_patch((**artist).clone())?;
112        }
113
114        Ok(())
115    }
116
117    if common_config().main.multithreading {
118        let writer = DatabaseWriter::<LibraryDb>::spawn();
119        files
120            .par_iter()
121            .try_for_each(|source| -> Result<(), ExtractError> {
122                if writer.is_closed() {
123                    return Ok(());
124                }
125
126                let result = extract_metadata(source)?;
127
128                if writer.is_closed() {
129                    return Ok(());
130                }
131
132                if !dry {
133                    writer.transaction(move |cas_tx| transaction_fn(cas_tx, &result));
134                }
135
136                progress_bar.set_label(&format!(
137                    "Extracted metadata from '{path}'",
138                    path = source.display()
139                ));
140                progress_bar.increment();
141
142                Ok(())
143            })?;
144        writer.finish()?;
145    } else {
146        for source in files {
147            let result = extract_metadata(&source)?;
148
149            if !dry {
150                db_transaction(
151                    |cas_tx| -> Result<(), CustomTransactionError<Infallible>> {
152                        transaction_fn(cas_tx, &result)?;
153                        Ok(())
154                    },
155                    DbHandle::<LibraryDb>::open().unwrap(),
156                    false,
157                )
158                .map_err(TransactionError::from)?;
159            }
160
161            progress_bar.set_label(&format!(
162                "Extracted metadata from '{path}'",
163                path = source.display()
164            ));
165            progress_bar.increment();
166        }
167    }
168
169    progress_bar.flush();
170    Ok(())
171}
172
173/// Analyzes and extracts the input file metadata into an [`ExtractResult`]
174pub fn extract_metadata(source_file: impl AsRef<Path>) -> Result<ResolvedTrack, ExtractError> {
175    let source_file = source_file.as_ref();
176
177    let ContainerExtractResult {
178        container,
179        // TODO: Replace lofty reading with symphonia
180        metadata: _metadata,
181    } = extract_from_file(source_file)?;
182
183    let mut metadata = lofty::read_from_path(source_file)?;
184    let tags = metadata.primary_tag_mut().unwrap();
185
186    let (title, track_artists) = tags.track_title_and_artists();
187
188    let (title, instrumental) = match title {
189        Some(t) => {
190            let (extracted, instrumental) = extract_instrumental(&t);
191            (Some(extracted.into_owned()), Some(instrumental))
192        }
193        None => (None, None),
194    };
195
196    let mut all_artists: HashMap<ArtistId, Artist> =
197        track_artists.iter().cloned().map(|a| (a.id(), a)).collect();
198
199    let date = tags.date();
200
201    let track_num = tags.track_num();
202    let disc_num = tags.disc_num();
203
204    let genre: Vec<String> = tags.take_strings(ItemKey::Genre).collect();
205
206    let mut album = {
207        let (album_title, album_artists) = tags.album_title_and_artists();
208        let track_total = tags.track_total();
209        let disc_total = tags.disc_total();
210
211        if let Some(album_title) = album_title
212            && has_album(
213                title.as_deref(),
214                &album_title,
215                &track_artists,
216                &album_artists,
217                track_num,
218                track_total,
219                disc_num,
220                disc_total,
221            )
222        {
223            let mut album = Album::new(
224                album_title,
225                album_artists.iter().map(Artist::id).collect(),
226                Vec::new(),
227            );
228
229            for mut artist in album_artists {
230                artist.albums.push(album.id());
231
232                match all_artists.entry(artist.id()) {
233                    Entry::Occupied(mut entry) => {
234                        entry.get_mut().albums.extend_unique(artist.albums);
235                    }
236                    Entry::Vacant(entry) => {
237                        entry.insert(artist);
238                    }
239                }
240            }
241
242            album.date = date;
243            album.track_total = track_total;
244            album.disc_total = disc_total;
245            album.genre = genre.clone();
246            Some(album)
247        } else {
248            None
249        }
250    };
251
252    let lyrics = tags.lyrics();
253    let lyrics = if instrumental == Some(true) {
254        Some(LyricData::Instrumental)
255    } else {
256        lyrics
257    };
258
259    let other = tags
260        .items()
261        .cloned()
262        .map(TagItem::consume)
263        .filter_map(|(k, v)| {
264            let v = v.text()?.to_owned();
265            let k = k.map_key(TagType::VorbisComments)?.to_owned();
266
267            Some((k, v))
268        });
269
270    let art = tags
271        .get_picture_type(PictureType::CoverFront)
272        .or(tags.pictures().first())
273        .map(|p| CoverArt::Embedded {
274            hash: hash(p.data()),
275            source: source_file.to_path_buf(),
276        });
277
278    let metadata = TrackMeta {
279        album: album.as_ref().map(Album::id),
280        artists: track_artists.iter().map(Artist::id).collect(),
281        date,
282        genre,
283        lyric_data: lyrics,
284        other: other.collect(),
285        title,
286        art,
287    };
288
289    let track = Track::new(container, metadata)?;
290
291    if let Some(album) = &mut album {
292        album.tracks.push(TrackReference {
293            id: track.id(),
294            track_num,
295            disc_num,
296        });
297    }
298
299    for artist in all_artists.values_mut() {
300        if track_artists.contains(artist) {
301            artist.tracks.push(track.id());
302        }
303    }
304
305    let all_artists: HashMap<_, _> = all_artists
306        .into_iter()
307        .map(|(id, a)| (id, Arc::new(a)))
308        .collect();
309
310    let artists = track_artists
311        .iter()
312        .map(|a| all_artists[&a.id()].clone())
313        .collect();
314
315    let album_artists = album.as_ref().map(|album| {
316        album
317            .artists
318            .iter()
319            .map(|id| all_artists[id].clone())
320            .collect()
321    });
322
323    Ok(ResolvedTrack {
324        track: Arc::new(track),
325        album: album.map(Arc::new),
326        artists,
327        album_artists,
328    })
329}
330
331fn has_album(
332    track_title: Option<&str>,
333    album_title: &str,
334    track_artists: &[Artist],
335    album_artists: &[Artist],
336    track_num: Option<u32>,
337    track_total: Option<u32>,
338    disc_num: Option<u32>,
339    disc_total: Option<u32>,
340) -> bool {
341    if track_total == Some(1) && disc_total.is_none_or(|d| d <= 1) {
342        return false;
343    }
344
345    // Returns true if the album name != track name
346    if track_title.is_none_or(|t| t != album_title) {
347        return true;
348    }
349
350    // Returns true if the album artists are not the same as the track artists
351    if *album_artists != *track_artists {
352        return true;
353    }
354
355    // Returns true if the track/disc value or total is greater is some and is greater than 1
356    track_num.is_some_and(|v| v > 1)
357        || track_total.is_some_and(|v| v > 1)
358        || disc_num.is_some_and(|v| v > 1)
359        || disc_total.is_some_and(|v| v > 1)
360}