Skip to main content

selene_core/library/
import.rs

1use std::{path::Path, sync::Arc};
2
3use barber::{ProgressBar, ProgressRenderer};
4use rayon::{
5    ThreadPoolBuilder,
6    iter::{IntoParallelIterator, ParallelIterator},
7};
8
9use crate::{
10    config::common::common_config,
11    database::{DatabaseEntry, DatabaseError, writer::db_sync_transaction},
12    errors::{FfmpegError, ImportError, LibraryError},
13    ffmpeg::{command_mutators::FfmpegPresets, ffmpeg, loudnorm::LoudnormAnalysis, output_ffmpeg},
14    library::{album::Album, artist::Artist, track::Track},
15    media_container::MediaContainer,
16};
17
18#[derive(Debug, Clone)]
19pub struct ExtractResult {
20    pub track: Track,
21    pub album: Option<Album>,
22    pub artists: Vec<Artist>,
23}
24
25/// Optional flags when importing a track
26#[derive(Debug, Clone, Copy)]
27pub struct TrackImportOptions {
28    pub dry: bool,
29    pub apply_loudnorm: bool,
30}
31
32/// Scans the input `tracks` for tracks that need to be reimported
33///
34/// Tracks need to be reimported if:
35/// - Their source file exists, but their library file does not
36/// - Loudnorm config is input, but their loudnorm config does not match the input config
37pub fn scan_for_reimport(loudnorm: bool) -> Result<Vec<Track>, DatabaseError> {
38    let mut tracks = Track::db_get_all()?;
39    let loudnorm_with = loudnorm.then_some(common_config().loudnorm_config);
40
41    tracks.retain(|track| {
42        let missing_lib = track.lib_container().is_none_or(|c| !c.path().is_file());
43        let needs_loudnorm = loudnorm_with.is_some_and(|cfg| track.loudnorm() != Some(&cfg));
44
45        missing_lib || needs_loudnorm
46    });
47
48    Ok(tracks)
49}
50
51/// Reimports the input tracks
52///
53/// # Errors
54///
55/// Errors if
56pub fn reimport(
57    tracks: Vec<Track>,
58    progress_renderer: Arc<dyn ProgressRenderer>,
59    options: TrackImportOptions,
60) -> Result<(), ImportError> {
61    if tracks.is_empty() {
62        return Ok(());
63    }
64
65    let library_dir = common_config()
66        .library_dir()
67        .ok_or(LibraryError::NoLibrary)?
68        .to_path_buf();
69
70    for track in &tracks {
71        track.ensure_parent_dirs(&library_dir)?;
72    }
73
74    let progress_bar = ProgressBar::new(0, tracks.len(), progress_renderer);
75    progress_bar.set_label("Importing files...");
76
77    let max_threads = (rayon::current_num_threads() as f32 * 0.33).ceil() as usize;
78
79    let thread_pool = ThreadPoolBuilder::new()
80        .num_threads(max_threads)
81        .build()
82        .unwrap();
83
84    thread_pool.install(|| {
85        tracks
86            .into_par_iter()
87            .try_for_each(|track| -> Result<(), DatabaseError> {
88                let import_to = library_dir.join(&track.relative_library_path);
89                let track_path = track.relative_library_path.display().to_string();
90
91                if let Err(err) = import(track, import_to, options) {
92                    progress_bar.set_label(&format!("Failed to import {track_path}: {err}",));
93                } else {
94                    progress_bar.set_label(&format!("Imported '{track_path}'",));
95                }
96
97                progress_bar.increment();
98                Ok(())
99            })
100    })?;
101
102    progress_bar.flush();
103    Ok(())
104}
105
106pub fn import(
107    mut track: Track,
108    to: impl AsRef<Path>,
109    options: TrackImportOptions,
110) -> Result<(), ImportError> {
111    let audio_source = track.src_container().path();
112
113    let (container, codec) = track
114        .src_container()
115        .transcode_to()
116        .ok_or(FfmpegError::Other(
117        "Cannot transcode the input container: Unsupported or unrecognized container/codec pair"
118            .to_owned(),
119    ))?;
120
121    let container = container.format_name();
122    let codec = codec.codec_name();
123
124    let mut command = ffmpeg();
125
126    command.input_file(audio_source);
127
128    if let Some(cover) = &track.metadata.cover_art {
129        if cover.source() == audio_source {
130            command.cover_art_from(0);
131        } else {
132            command.input_file(cover.source());
133            command.cover_art_from(1);
134        }
135    }
136
137    command.set_codec(codec);
138
139    if options.apply_loudnorm {
140        let measured = if let Some(loudnorm_analysis) = track.loudnorm_analysis {
141            loudnorm_analysis
142        } else {
143            LoudnormAnalysis::from_file(audio_source)?
144        };
145
146        track.loudnorm_analysis = Some(measured);
147
148        command.apply_loudnorm_filter(measured);
149    }
150
151    command.map_audio_from(0);
152    command.drop_subtitles();
153    command.set_container(container);
154
155    command.drop_metadata();
156
157    let metadata = track.metadata_key_values()?;
158    command.add_metadata_group(metadata.iter());
159
160    let output_file = to.as_ref();
161
162    command.output_file(output_file);
163
164    output_ffmpeg(command)?;
165
166    let lib_container = MediaContainer::from_file(output_file.to_path_buf())?;
167    track.lib_container = Some(lib_container);
168
169    if options.apply_loudnorm {
170        let loudnorm_config = common_config().loudnorm_config;
171        track.set_loudnorm(loudnorm_config);
172    }
173
174    db_sync_transaction(move |cas_tx| cas_tx.tx_patch(track.clone()), false)?;
175
176    Ok(())
177}
178
179/// Returns an error with the corrected value if one is detected, else returns ()
180pub fn valdiate_tag_value(title: &str) -> Result<(), String> {
181    if title.chars().any(char::is_control) {
182        let fixed: String = title.chars().filter(|c| !c.is_control()).collect();
183        Err(fixed)
184    } else {
185        Ok(())
186    }
187}