nix

configuration files that power my machines
Log | Files | Refs | README | LICENSE

commit af0a367c276bea4f3fdb0a9aa5f25130cb4e793d
parent d275cb255b96560f7271b959d27ae8fde323b098
Author: mtmn <miro@haravara.org>
Date:   Tue,  7 Jul 2026 11:59:42 +0200

add o2aif script

Diffstat:
Amodules/mixins/dotfiles/bin/o2aif | 250+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmodules/mixins/dotfiles/config/newsraft/feeds | 1+
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