Skip to main content

selene_core/library/
import.rs

1use std::{
2    fs,
3    path::{Path, PathBuf},
4    sync::Arc,
5};
6
7use barber::{ProgressBar, ProgressRenderer};
8use lunar_lib::{
9    database::{DatabaseEntry, DatabaseError, writer::DatabaseWriter},
10    error,
11};
12use rayon::{
13    ThreadPoolBuilder,
14    iter::{IntoParallelIterator, ParallelIterator},
15};
16
17use crate::{
18    VALID_LIBRARY_CODECS, VALID_LIBRARY_FORMATS,
19    config::common::common_config,
20    database::{LibraryDb, tx_extensions::CasTxExtensions},
21    errors::{CodecError, ContainerError, ImportError, LibraryError},
22    ffmpeg::{FfmpegPresets, ffmpeg, output_to_string},
23    library::{album::Album, artist::Artist, track::Track},
24    media_container::MediaContainer,
25    utils::pair_extension,
26};
27
28#[derive(Debug, Clone)]
29pub struct ExtractResult {
30    pub track: Track,
31    pub album: Option<Album>,
32    pub artists: Vec<Artist>,
33}
34
35/// Optional flags when importing a track
36#[derive(Debug, Clone, Copy)]
37pub struct TrackImportOptions {
38    pub dry: bool,
39    pub apply_loudnorm: bool,
40}
41
42/// Scans the input `tracks` for tracks that need to be reimported
43///
44/// Tracks need to be reimported if:
45/// - Their source file exists, but their library file does not
46/// - Loudnorm config is input, but their loudnorm config does not match the input config
47pub fn find_needs_import(loudnorm: bool) -> Result<Vec<Track>, DatabaseError> {
48    let mut tracks = Track::db_get_all()?;
49    let loudnorm_with = loudnorm.then_some(common_config().loudnorm);
50
51    tracks.retain(|track| {
52        let missing_lib = track.lib_container().is_none_or(|c| !c.path().is_file());
53        let needs_loudnorm = loudnorm_with.is_some_and(|cfg| track.loudnorm() != Some(&cfg));
54
55        missing_lib || needs_loudnorm
56    });
57
58    Ok(tracks)
59}
60
61/// Reimports the input tracks
62///
63/// # Errors
64///
65/// Errors if
66pub fn reimport(
67    tracks: Vec<Track>,
68    progress_renderer: Arc<dyn ProgressRenderer>,
69    options: TrackImportOptions,
70) -> Result<(), ImportError> {
71    if tracks.is_empty() {
72        return Ok(());
73    }
74
75    let library_dir = common_config()
76        .library_dir()
77        .ok_or(LibraryError::NoLibrary)?
78        .to_path_buf();
79
80    for track in &tracks {
81        track.ensure_parent_dirs(&library_dir)?;
82    }
83
84    let progress_bar = ProgressBar::new(0, tracks.len(), progress_renderer);
85    progress_bar.set_label("Importing files...");
86
87    let max_threads = (rayon::current_num_threads() as f32 * 0.33).ceil() as usize;
88    let thread_pool = ThreadPoolBuilder::new()
89        .num_threads(max_threads)
90        .build()
91        .unwrap();
92
93    // Initializes the common config before making threads compete to initialize it
94    drop(common_config());
95
96    let writer = DatabaseWriter::<LibraryDb>::spawn();
97
98    thread_pool.install(|| {
99        tracks
100            .into_par_iter()
101            .try_for_each(|track| -> Result<(), DatabaseError> {
102                if writer.is_closed() {
103                    return Ok(());
104                }
105
106                let import_to = library_dir.join(&track.relative_library_path);
107                let track_path = track.relative_library_path.display().to_string();
108
109                match import(track, import_to, options) {
110                    Ok(track) => {
111                        if writer.is_closed() {
112                            return Ok(());
113                        }
114
115                        writer.transaction(move |cas_tx| cas_tx.tx_patch(track.clone()));
116                        progress_bar.set_label(&format!("Imported '{track_path}'",));
117                    }
118                    Err(err) => {
119                        let args = format_args!("Failed to import {track_path}: {err}");
120                        error!("{args}");
121                        progress_bar.set_label(&format!("{args}"));
122                    }
123                }
124
125                progress_bar.increment();
126                Ok(())
127            })
128    })?;
129
130    writer.finish()?;
131
132    progress_bar.flush();
133    Ok(())
134}
135
136pub fn import(
137    mut track: Track,
138    to: impl AsRef<Path>,
139    options: TrackImportOptions,
140) -> Result<Track, ImportError> {
141    let audio_source = track.src_container().path();
142
143    let src_container = *track.src_container().container();
144    let src_codec = *track.src_container().codec();
145
146    let common_config = common_config();
147    let transcode_target = common_config.transcode_to(src_container, src_codec);
148
149    let (target_container, target_codec) = if let Some(transcode_target) = transcode_target {
150        transcode_target.container_codec()
151    } else {
152        (src_container, src_codec)
153    };
154
155    if !VALID_LIBRARY_FORMATS.contains(&target_container) {
156        return Err(ImportError::Container(
157            ContainerError::UnsupportedContainer(target_container),
158        ));
159    }
160
161    if !VALID_LIBRARY_CODECS.contains(&target_codec) {
162        return Err(ImportError::Codec(CodecError::UnsupportedCodec(
163            target_codec,
164        )));
165    }
166
167    let mut command = ffmpeg();
168
169    command.input_file(audio_source);
170
171    if let Some(cover) = track.metadata.cover_art() {
172        if cover.source() == audio_source {
173            command.cover_art_from(0);
174        } else {
175            command.input_file(cover.source());
176            command.cover_art_from(1);
177        }
178    }
179
180    if let Some(transcode_target) = transcode_target {
181        transcode_target.add_ffmpeg_args(&mut command);
182    } else {
183        command.set_container(target_container.format_name());
184        if options.apply_loudnorm {
185            command.set_codec(target_codec.ffmpeg_encoder()?);
186        } else {
187            command.copy_codec();
188        }
189    }
190
191    if options.apply_loudnorm
192        && let Some(loudnorm_analysis) = track.loudnorm_analysis
193    {
194        command.apply_loudnorm_filter(loudnorm_analysis);
195    }
196
197    command.map_audio_from(0);
198
199    command.drop_subtitles();
200    command.drop_metadata();
201
202    let metadata = track.metadata_key_values(&target_container)?;
203    command.add_metadata_group(metadata);
204
205    let mut output_file = to.as_ref().as_os_str().to_owned();
206    output_file.push(".");
207    output_file.push(pair_extension(target_container, target_codec).unwrap());
208    let output_file = PathBuf::from(output_file);
209    command.output_file(&output_file);
210
211    output_to_string(command, false)?;
212
213    let opened_file = fs::File::open(&output_file)?;
214    track.lib_container = Some(MediaContainer::from_file(opened_file, output_file)?);
215
216    Ok(track)
217}