commit af0a367c276bea4f3fdb0a9aa5f25130cb4e793d
parent d275cb255b96560f7271b959d27ae8fde323b098
Author: mtmn <miro@haravara.org>
Date: Tue, 7 Jul 2026 11:59:42 +0200
add o2aif script
Diffstat:
2 files changed, 251 insertions(+), 0 deletions(-)
diff --git a/modules/mixins/dotfiles/bin/o2aif b/modules/mixins/dotfiles/bin/o2aif
@@ -0,0 +1,250 @@
+#!/usr/bin/env python3
+
+import argparse
+import subprocess
+import sys
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from dataclasses import dataclass
+from pathlib import Path
+
+
+@dataclass
+class ConversionStats:
+ converted: int = 0
+ artwork_preserved: int = 0
+ no_artwork: int = 0
+ artwork_lost: int = 0
+ skipped: int = 0
+ errors: int = 0
+ deleted: int = 0
+
+
+def check_artwork(file_path):
+ """Return True if the file contains an embedded artwork (video) stream."""
+ result = subprocess.run(
+ [
+ "ffprobe",
+ "-v",
+ "error",
+ "-select_streams",
+ "v",
+ "-show_entries",
+ "stream=codec_type",
+ "-of",
+ "default=noprint_wrappers=1:nokey=1",
+ str(file_path),
+ ],
+ capture_output=True,
+ text=True,
+ )
+ return "video" in result.stdout
+
+
+def convert_opus_to_aiff(input_path, delete_original=True):
+ """Convert a single Opus file to AIFF.
+
+ Returns (success, status) where status is one of:
+ "artwork_preserved", "no_artwork", "artwork_lost", "skipped".
+ On failure returns (False, "error").
+ """
+ output_path = input_path.with_suffix(".aiff")
+
+ if output_path.exists():
+ print(f"Skipping {input_path} - {output_path} already exists")
+ return True, "skipped"
+
+ print(f"Converting {input_path} to {output_path}")
+
+ try:
+ result = subprocess.run(
+ [
+ "ffmpeg",
+ "-i",
+ str(input_path),
+ "-c:a",
+ "pcm_s16be", # 16-bit PCM (AIFF default audio codec)
+ "-map",
+ "0:a", # Map audio stream
+ "-map",
+ "0:v?", # Map video (cover art) stream if it exists
+ "-map_metadata",
+ "0", # Copy all metadata
+ "-write_id3v2",
+ "1", # Write ID3v2 chunk (required: AIFF muxer defaults to off)
+ "-id3v2_version",
+ "3", # ID3v2.3 for broad compatibility
+ "-f",
+ "aiff",
+ str(output_path),
+ ],
+ check=True,
+ capture_output=True,
+ )
+
+ # Surface any non-fatal ffmpeg warnings (e.g. the optional 0:v? map
+ # producing a "no video stream" notice).
+ if result.stderr:
+ for line in result.stderr.decode(errors="replace").splitlines():
+ if line.strip().lower().startswith("warning"):
+ print(f" ffmpeg warning: {line.strip()}")
+
+ had_artwork = check_artwork(input_path)
+ if had_artwork:
+ if check_artwork(output_path):
+ print(f"Successfully converted {input_path} (artwork preserved)")
+ status = "artwork_preserved"
+ else:
+ # Artwork existed in source but did not make it to the output.
+ # Keep the original so the user can investigate / re-encode.
+ print(
+ f"Warning: Artwork did not transfer for {input_path}; "
+ "keeping original"
+ )
+ return True, "artwork_lost"
+ else:
+ print(f"Successfully converted {input_path}")
+ status = "no_artwork"
+
+ if delete_original:
+ try:
+ input_path.unlink()
+ print(f"Deleted original file: {input_path}")
+ except OSError as e:
+ print(f"Warning: Could not delete original file {input_path}: {str(e)}")
+
+ return True, status
+
+ except subprocess.CalledProcessError as e:
+ print(f"Error converting {input_path}:")
+ print(f"ffmpeg error: {e.stderr.decode(errors='replace')}")
+ return False, "error"
+ except Exception as e:
+ print(f"Unexpected error converting {input_path}: {str(e)}")
+ return False, "error"
+
+
+def iter_opus_files(start_dir):
+ """Yield Opus files under start_dir, case-insensitively.
+
+ pathlib's rglob is case-sensitive on Linux, so *.OPUS would be missed.
+ """
+ lower = list(start_dir.rglob("*.opus"))
+ upper = list(start_dir.rglob("*.OPUS"))
+ # Deduplicate while preserving a stable order.
+ seen = set()
+ for p in lower + upper:
+ rp = p.resolve()
+ if rp not in seen:
+ seen.add(rp)
+ yield p
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Convert Opus files to AIFF format recursively."
+ )
+ parser.add_argument("folder_path", help="Path to the folder containing Opus files")
+ parser.add_argument(
+ "--keep-original",
+ action="store_true",
+ help="Keep original Opus files after conversion (default: delete)",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Show what would be converted without writing or deleting anything",
+ )
+ parser.add_argument(
+ "--jobs",
+ "-j",
+ type=int,
+ default=1,
+ help="Number of files to convert in parallel (default: 1)",
+ )
+ args = parser.parse_args()
+
+ start_dir = Path(args.folder_path).resolve()
+ if not start_dir.exists():
+ print(f"Error: The directory '{start_dir}' does not exist.")
+ return 1
+ if not start_dir.is_dir():
+ print(f"Error: '{start_dir}' is not a directory.")
+ return 1
+
+ files = list(iter_opus_files(start_dir))
+ if not files:
+ print(f"No Opus files found under {start_dir}")
+ return 0
+
+ stats = ConversionStats()
+ delete_original = not args.keep_original
+
+ def handle(file_path):
+ return convert_opus_to_aiff(file_path, delete_original)
+
+ if args.dry_run:
+ for f in files:
+ print(f"[dry-run] Would convert {f} -> {f.with_suffix('.aiff')}")
+ print(f"\n{len(files)} file(s) would be converted.")
+ return 0
+
+ if args.jobs > 1:
+ with ThreadPoolExecutor(max_workers=args.jobs) as pool:
+ futures = {pool.submit(handle, f): f for f in files}
+ for fut in as_completed(futures):
+ f = futures[fut]
+ try:
+ success, status = fut.result()
+ except Exception as e:
+ print(f"Failed to process {f}: {str(e)}")
+ stats.errors += 1
+ continue
+ _tally(stats, success, status, f, delete_original)
+ else:
+ for f in files:
+ try:
+ success, status = handle(f)
+ except Exception as e:
+ print(f"Failed to process {f}: {str(e)}")
+ stats.errors += 1
+ continue
+ _tally(stats, success, status, f, delete_original)
+
+ print("\nConversion Complete!")
+ print(f"Successfully converted: {stats.converted} files")
+ if stats.artwork_preserved:
+ print(f" with artwork preserved: {stats.artwork_preserved}")
+ if stats.no_artwork:
+ print(f" without artwork: {stats.no_artwork}")
+ if stats.skipped:
+ print(f"Skipped (already exist): {stats.skipped} files")
+ if stats.artwork_lost:
+ print(f"Artwork transfer failed (originals kept): {stats.artwork_lost} files")
+ if not args.keep_original:
+ print(f"Original files deleted: {stats.deleted} files")
+ if stats.errors:
+ print(f"Errors encountered: {stats.errors} files")
+
+ return 1 if stats.errors else 0
+
+
+def _tally(stats, success, status, file_path, delete_original):
+ if not success:
+ stats.errors += 1
+ return
+ if status == "skipped":
+ stats.skipped += 1
+ return
+ stats.converted += 1
+ if status == "artwork_preserved":
+ stats.artwork_preserved += 1
+ elif status == "no_artwork":
+ stats.no_artwork += 1
+ elif status == "artwork_lost":
+ stats.artwork_lost += 1
+ if delete_original and not file_path.exists() and status != "artwork_lost":
+ stats.deleted += 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/modules/mixins/dotfiles/config/newsraft/feeds b/modules/mixins/dotfiles/config/newsraft/feeds
@@ -254,3 +254,4 @@ https://www.youtube.com/feeds/videos.xml?channel_id=UC_n6DdR6FClpCbWnNM7Zp6A Spa
https://www.youtube.com/feeds/videos.xml?channel_id=UCFajCKBeNRW16Xb5mJvrCvw Kernotex
https://www.youtube.com/feeds/videos.xml?channel_id=UCN1Dg0gd31dxNfQez6yPB7Q Stephanie Sammann
https://www.youtube.com/feeds/videos.xml?channel_id=UCOT2iLov0V7Re7ku_3UBtcQ Hank Green
+https://www.youtube.com/feeds/videos.xml?channel_id=UCYiey-6Je3t0XsYUeBRpOKw Kolarias