···11use crate::cmd::*;
22use crate::db;
33use crate::ffmpeg;
44+use crate::ffmpeg::TranscodeResult;
45use crate::gpod;
56use crate::model;
67use anyhow::Ok;
···276277 false => (),
277278 }
278279 let dest_path = ipm.path_for_file(fp.as_path()).unwrap();
279279- let copied_track = copy(&track, dest_db, dest_path.clone(), trs).await?;
280280+ let (copied_track, tr) = copy(&track, dest_db, dest_path.clone(), trs).await?;
280281281281- Ok((copied_track, dest_path))
282282+ Ok((copied_track, dest_path, tr.unwrap()))
282283 }
283284 })
284285 .buffer_unordered(threads)
···286287 .await?;
287288288289 log::info!("finalizing...");
289289- for (track, path) in converted {
290290- ipod_manager.finalize_track(&track.clone(), path.clone(), track.extension, 44100, 256)?;
290290+ for (track, path, trans_res) in converted {
291291+ ipod_manager.finalize_track(
292292+ &track.clone(),
293293+ path.clone(),
294294+ track.extension,
295295+ 44100,
296296+ 256,
297297+ trans_res.replaygain.map(|r| r.soundcheck_value()),
298298+ )?;
291299 }
292300293301 return Ok(());
···384392 dest_db: &db::Instance,
385393 dest_fname: PathBuf,
386394 transcoding_settings: Option<ffmpeg::Quality>,
387387-) -> Result<model::Track> {
395395+) -> Result<(model::Track, Option<TranscodeResult>)> {
388396 // We replicate src database on dest 1:1
389397 // We should create the same logical database (tracks with the same tags have the same hash)
390398 // ignoring the format.
···424432425433 let opts = CopyOptions::new().overwrite(true);
426434427427- async_std::task::spawn_blocking({
435435+ let maybe_transcode_result = async_std::task::spawn_blocking({
428436 let track_for_thread = track.clone();
429437 let ts_for_thread = transcoding_settings.clone();
430438···435443 log::info!("transcoding & copying {} ({})", track_for_thread, ts);
436444 log::debug!("transcoding path: {}", track_dest_path.display());
437445438438- ffmpeg::convert(&track_for_thread, track_dest_path, &ts)?;
439439- return Ok(());
446446+ return Ok(Some(ffmpeg::convert(
447447+ &track_for_thread,
448448+ track_dest_path,
449449+ &ts,
450450+ )?));
440451 }
441452442453 log::info!("copying {}", track_for_thread);
···460471 }
461472 };
462473463463- Ok(())
474474+ Ok(None)
464475 }
465476 })
466477 .await?;
···471482 .await
472483 .with_context(|| "Cannot insert copy finished track in destination database")?;
473484474474- Ok(track.clone())
485485+ Ok((track.clone(), maybe_transcode_result))
475486}
+117-3
src/ffmpeg.rs
···11use anyhow::{Result, anyhow};
22use clap::{builder::TypedValueParser, error::ErrorKind};
33-use std::{path::PathBuf, str::FromStr};
33+use regex::Regex;
44+use std::{path::PathBuf, str::FromStr, sync::LazyLock};
4556use crate::model::Track;
67···245246 }
246247}
247248248248-pub fn convert(track: &Track, dest: PathBuf, quality: &Quality) -> Result<()> {
249249+pub struct TranscodeResult {
250250+ pub replaygain: Option<ReplayGain>,
251251+}
252252+253253+pub fn convert(track: &Track, dest: PathBuf, quality: &Quality) -> Result<TranscodeResult> {
249254 let mut cmd = std::process::Command::new("ffmpeg");
250255 let res = cmd
251256 .arg("-y")
···273278 .split(" ")
274279 .collect::<Vec<&str>>(),
275280 )
281281+ .args(["-filter:a", "replaygain"])
276282 .arg(dest.display().to_string())
277283 .args(quality.codec.ffmpeg_flags())
278284 .output()?;
285285+286286+ let rg = ReplayGain::parse_from_str(&String::from_utf8(res.stderr.clone()).unwrap());
287287+288288+ log::debug!("replaygain metadata: {:?}", rg);
279289280290 match res.status.success() {
281281- true => Ok(()),
291291+ true => Ok(TranscodeResult { replaygain: rg }),
282292 false => Err(anyhow!(
283293 "{}: {}",
284294 res.status,
···286296 )),
287297 }
288298}
299299+300300+#[derive(Debug)]
301301+pub struct ReplayGain {
302302+ pub track_gain: f64,
303303+}
304304+305305+static RG_REGEXP: LazyLock<Regex> =
306306+ std::sync::LazyLock::new(|| regex::Regex::new("track_gain = ((.+) dB)").unwrap());
307307+308308+impl ReplayGain {
309309+ fn parse_from_str(out: &str) -> Option<Self> {
310310+ let gain_str = match RG_REGEXP.captures(out) {
311311+ Some(g) => g,
312312+ None => return None,
313313+ };
314314+315315+ Some(ReplayGain {
316316+ track_gain: gain_str.get(2).unwrap().as_str().parse().unwrap(),
317317+ })
318318+ }
319319+320320+ // From iPodLinux iTunesDB's page:
321321+ // 4 The SoundCheck value to apply to the song, when SoundCheck is switched on in the iPod settings.
322322+ // The value to put in this field can be determined by the equation: X = 1000 * 10 ^ (-.1 * Y)
323323+ // where Y is the adjustment value in dB and X is the value that goes into the SoundCheck field.
324324+ // The value 0 is special, the equation is not used and it is treated as "no Soundcheck" (basically the same as the value 1000).
325325+ // This equation works perfectly well with ReplayGain derived data instead of the iTunes SoundCheck derived information.
326326+ pub fn soundcheck_value(&self) -> u32 {
327327+ let ten: f64 = 10.0;
328328+ let res = (1000.0 * ten.powf(-0.1 * self.track_gain)).trunc();
329329+ res as u32
330330+ }
331331+}
332332+333333+#[cfg(test)]
334334+mod tests {
335335+ use super::*;
336336+337337+ #[test]
338338+ fn replaygain_parse() {
339339+ let out = r#"ffmpeg version 7.1.3 Copyright (c) 2000-2025 the FFmpeg developers
340340+ built with gcc 14 (Debian 14.2.0-19)
341341+ configuration: --disable-decoder=amrnb --disable-gnutls --disable-liblensfun --disable-libopencv --disable-podpages --disable-sndio --disable-stripping --disable-omx --enable-avfilter --enable-chromaprint --enable-frei0r --enable-gcrypt --enable-gpl --enable-ladspa --enable-libaom --enable-libaribb24 --enable-libass --enable-libbluray --enable-libbs2b --enable-libcaca --enable-libcdio --enable-libcodec2 --enable-libdav1d --enable-libdavs2 --enable-libdc1394 --enable-libdrm --enable-libdvdnav --enable-libdvdread --enable-libfdk-aac --enable-libflite --enable-libfontconfig --enable-libfreetype --enable-libfribidi --enable-libgme --enable-libgsm --enable-libharfbuzz --enable-libiec61883 --enable-libilbc --enable-libjack --enable-libjxl --enable-libklvanc --enable-libkvazaar --enable-libmp3lame --enable-libmysofa --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenh264 --enable-libopenjpeg --enable-libopenmpt --enable-libopus --enable-libplacebo --enable-libpulse --enable-librabbitmq --enable-librist --enable-librsvg --enable-librubberband --enable-libshine --enable-libsmbclient --enable-libsnappy --enable-libsoxr --enable-libspeex --enable-libsrt --enable-libsvtav1 --enable-libtesseract --enable-libtheora --enable-libtwolame --enable-libvidstab --enable-libvmaf --enable-libvo-amrwbenc --enable-libvorbis --enable-libvpx --enable-libwebp --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxavs2 --enable-libxml2 --enable-libxvid --enable-libzimg --enable-libzmq --enable-libzvbi --enable-lv2 --enable-nonfree --enable-openal --enable-opencl --enable-opengl --enable-openssl --enable-postproc --enable-pthreads --enable-shared --enable-version3 --incdir=/usr/include/aarch64-linux-gnu --libdir=/usr/lib/aarch64-linux-gnu --prefix=/usr --toolchain=hardened --cc=aarch64-linux-gnu-gcc --cxx=aarch64-linux-gnu-g++ --disable-altivec --shlibdir=/usr/lib/aarch64-linux-gnu
342342+ libavutil 59. 39.100 / 59. 39.100
343343+ libavcodec 61. 19.101 / 61. 19.101
344344+ libavformat 61. 7.100 / 61. 7.100
345345+ libavdevice 61. 3.100 / 61. 3.100
346346+ libavfilter 10. 4.100 / 10. 4.100
347347+ libswscale 8. 3.100 / 8. 3.100
348348+ libswresample 5. 3.100 / 5. 3.100
349349+ libpostproc 58. 3.100 / 58. 3.100
350350+ Input #0, flac, from 'elegy.flac':
351351+ Metadata:
352352+ TITLE : Elegy
353353+ ARTIST : Architects
354354+ album_artist : Architects
355355+ ALBUM : The Sky, The Earth & All Between
356356+ disc : 1
357357+ track : 1
358358+ DATE : 2025
359359+ TRACKTOTAL : 12
360360+ DISCTOTAL : 1
361361+ ISRC : USEP42418003
362362+ Duration: 00:03:35.57, start: 0.000000, bitrate: 1003 kb/s
363363+ Stream #0:0: Audio: flac, 44100 Hz, stereo, s16
364364+ File 'elegy.opus' already exists. Overwrite? [y/N] y
365365+ Stream mapping:
366366+ Stream #0:0 -> #0:0 (flac (native) -> opus (libopus))
367367+ Press [q] to stop, [?] for help
368368+ Output #0, opus, to 'elegy.opus':
369369+ Metadata:
370370+ TITLE : Elegy
371371+ ARTIST : Architects
372372+ album_artist : Architects
373373+ ALBUM : The Sky, The Earth & All Between
374374+ disc : 1
375375+ track : 1
376376+ DATE : 2025
377377+ TRACKTOTAL : 12
378378+ DISCTOTAL : 1
379379+ ISRC : USEP42418003
380380+ encoder : Lavf61.7.100
381381+ Stream #0:0: Audio: opus, 48000 Hz, stereo, flt, 64 kb/s
382382+ Metadata:
383383+ encoder : Lavc61.19.101 libopus
384384+ TITLE : Elegy
385385+ ARTIST : Architects
386386+ ALBUMARTIST : Architects
387387+ ALBUM : The Sky, The Earth & All Between
388388+ DISCNUMBER : 1
389389+ TRACKNUMBER : 1
390390+ DATE : 2025
391391+ TRACKTOTAL : 12
392392+ DISCTOTAL : 1
393393+ ISRC : USEP42418003
394394+ [Parsed_replaygain_0 @ 0xffff6c002ed0] track_gain = -10.61 dB=72.2x
395395+ [Parsed_replaygain_0 @ 0xffff6c002ed0] track_peak = 1.020527
396396+ [out#0/opus @ 0xaaaad66a9100] video:0KiB audio:1637KiB subtitle:0KiB other streams:0KiB global headers:0KiB muxing overhead: 1.022192%
397397+ size= 1654KiB time=00:03:35.56 bitrate= 62.8kbits/s speed=72.3x
398398+"#;
399399+ let rp = ReplayGain::parse_from_str(out).unwrap();
400400+ println!("rp: {} sc: {}", rp.track_gain, rp.soundcheck_value());
401401+ }
402402+}
+5-27
src/gpod/gpod.rs
···7171 }
7272 }
73737474- // TODO: refactor this to write track to ipod while transcoding.
7575- /*
7676- * this is how libgpod cp_track_to_ipod does it:
7777- *
7878- // we can pass NULL as track, since we're copying a new track for the first time
7979- // and the expected perf optimization won't apply.
8080- // conversely, the second parameter must be the ipod mount_point, not NULL.
8181- dest_filename = itdb_cp_get_dest_filename (track, NULL, filename, error);
8282-8383- if (dest_filename)
8484- {
8585- if (itdb_cp (filename, dest_filename, error))
8686- {
8787- if (itdb_cp_finalize (track, NULL, dest_filename, error))
8888- {
8989- result = TRUE;
9090- }
9191- }
9292- g_free (dest_filename);
9393- }
9494-9595- we can return the dest filename calculated based on the filename, give it to ffmpeg,
9696- then finalize?
9797-9898- requires refactoring how we do copies, but we don't litter the hosts' temp dir
9999- and should be faster
100100- */
10174 pub fn path_for_file(&self, path: &Path) -> Result<PathBuf> {
10275 unsafe {
10376 let mp = std::ffi::CString::new(self.mountpoint.clone()).unwrap();
···134107 codec: String,
135108 sample_rate: u16,
136109 bitrate: i32,
110110+ soundcheck: Option<u32>,
137111 ) -> Result<()> {
138112 unsafe {
139113 let mp = std::ffi::CString::new(self.mountpoint.clone()).unwrap();
···156130 .st_size()
157131 .try_into()
158132 .unwrap();
133133+134134+ if let Some(sc) = soundcheck {
135135+ (*ipod_track).soundcheck = sc;
136136+ }
159137160138 let mut error: *mut GError = ptr::null_mut();
161139 let path = CString::new(file.display().to_string()).unwrap();