nix

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

o2aif (9519B)


      1 #!/usr/bin/env python3
      2 
      3 import argparse
      4 import subprocess
      5 import sys
      6 from concurrent.futures import ThreadPoolExecutor, as_completed
      7 from dataclasses import dataclass
      8 from pathlib import Path
      9 
     10 
     11 @dataclass
     12 class ConversionStats:
     13     converted: int = 0
     14     artwork_preserved: int = 0
     15     no_artwork: int = 0
     16     artwork_lost: int = 0
     17     skipped: int = 0
     18     errors: int = 0
     19     deleted: int = 0
     20 
     21 
     22 def check_artwork(file_path):
     23     """Return True if the file contains a video/artwork stream."""
     24     result = subprocess.run(
     25         [
     26             "ffprobe",
     27             "-v",
     28             "error",
     29             "-select_streams",
     30             "v",
     31             "-show_entries",
     32             "stream=codec_type,stream_disposition=attached_pic",
     33             "-of",
     34             "default=noprint_wrappers=1:nokey=1",
     35             str(file_path),
     36         ],
     37         capture_output=True,
     38         text=True,
     39     )
     40     output = result.stdout
     41     return "video" in output or "attached_pic=1" in output
     42 
     43 
     44 COVER_NAMES = ("cover", "folder", "front", "albumart", "album")
     45 COVER_EXTS = (".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp")
     46 
     47 
     48 def find_external_cover(input_path):
     49     """Return the path to a cover image next to the input, or None."""
     50     parent = input_path.parent
     51     for name in COVER_NAMES:
     52         for ext in COVER_EXTS:
     53             for variant in (name + ext, name.title() + ext, name.upper() + ext):
     54                 candidate = parent / variant
     55                 if candidate.is_file():
     56                     return candidate
     57     return None
     58 
     59 
     60 def convert_opus_to_aiff(input_path, delete_original=True):
     61     """Convert a single Opus file to AIFF.
     62 
     63     Returns (success, status) where status is one of:
     64     "artwork_preserved", "no_artwork", "artwork_lost", "skipped".
     65     On failure returns (False, "error").
     66     """
     67     output_path = input_path.with_suffix(".aiff")
     68 
     69     if output_path.exists():
     70         print(f"Skipping {input_path} - {output_path} already exists")
     71         return True, "skipped"
     72 
     73     print(f"Converting {input_path} to {output_path}")
     74 
     75     had_embedded_artwork = check_artwork(input_path)
     76     external_cover = None if had_embedded_artwork else find_external_cover(input_path)
     77 
     78     try:
     79         ffmpeg_cmd = [
     80             "ffmpeg",
     81             "-i",
     82             str(input_path),
     83         ]
     84 
     85         if external_cover:
     86             ffmpeg_cmd.extend(["-i", str(external_cover)])
     87 
     88         ffmpeg_cmd.extend(
     89             [
     90                 "-c:a",
     91                 "pcm_s16be",  # 16-bit PCM (AIFF default audio codec)
     92                 "-map",
     93                 "0:a",  # Map audio stream
     94                 "-map_metadata",
     95                 "0:s:0",  # Copy Vorbis-comment tags to global AIFF ID3 metadata
     96                 "-write_id3v2",
     97                 "1",  # Write ID3v2 chunk (required: AIFF muxer defaults to off)
     98                 "-id3v2_version",
     99                 "3",  # ID3v2.3 for broad compatibility
    100             ]
    101         )
    102 
    103         if had_embedded_artwork:
    104             ffmpeg_cmd.extend(["-map", "0:v?", "-c:v", "copy"])
    105         elif external_cover:
    106             ffmpeg_cmd.extend(["-map", "1:v", "-c:v", "copy"])
    107 
    108         ffmpeg_cmd.extend(["-f", "aiff", str(output_path)])
    109 
    110         result = subprocess.run(ffmpeg_cmd, check=True, capture_output=True)
    111 
    112         # Surface any non-fatal ffmpeg warnings.
    113         if result.stderr:
    114             for line in result.stderr.decode(errors="replace").splitlines():
    115                 if line.strip().lower().startswith("warning"):
    116                     print(f"  ffmpeg warning: {line.strip()}")
    117 
    118         has_output_artwork = check_artwork(output_path)
    119         if has_output_artwork:
    120             if had_embedded_artwork:
    121                 print(
    122                     f"Successfully converted {input_path} (embedded artwork preserved)"
    123                 )
    124                 status = "artwork_preserved"
    125             elif external_cover:
    126                 print(
    127                     f"Successfully converted {input_path} "
    128                     f"(external cover {external_cover.name} embedded)"
    129                 )
    130                 status = "artwork_preserved"
    131             else:
    132                 print(f"Successfully converted {input_path} (artwork preserved)")
    133                 status = "artwork_preserved"
    134         else:
    135             if had_embedded_artwork or external_cover:
    136                 # Artwork existed in source or as external file but did not
    137                 # make it to the output. Keep the original so the user can
    138                 # investigate / re-encode.
    139                 print(
    140                     f"Warning: Artwork did not transfer for {input_path}; "
    141                     "keeping original"
    142                 )
    143                 return True, "artwork_lost"
    144             print(f"Successfully converted {input_path}")
    145             status = "no_artwork"
    146 
    147         if delete_original:
    148             try:
    149                 input_path.unlink()
    150                 print(f"Deleted original file: {input_path}")
    151             except OSError as e:
    152                 print(f"Warning: Could not delete original file {input_path}: {str(e)}")
    153 
    154         return True, status
    155 
    156     except subprocess.CalledProcessError as e:
    157         print(f"Error converting {input_path}:")
    158         print(f"ffmpeg error: {e.stderr.decode(errors='replace')}")
    159         return False, "error"
    160     except Exception as e:
    161         print(f"Unexpected error converting {input_path}: {str(e)}")
    162         return False, "error"
    163 
    164 
    165 def iter_opus_files(start_dir):
    166     """Yield Opus files under start_dir, case-insensitively.
    167 
    168     pathlib's rglob is case-sensitive on Linux, so *.OPUS would be missed.
    169     """
    170     lower = list(start_dir.rglob("*.opus"))
    171     upper = list(start_dir.rglob("*.OPUS"))
    172     # Deduplicate while preserving a stable order.
    173     seen = set()
    174     for p in lower + upper:
    175         rp = p.resolve()
    176         if rp not in seen:
    177             seen.add(rp)
    178             yield p
    179 
    180 
    181 def main():
    182     parser = argparse.ArgumentParser(
    183         description="Convert Opus files to AIFF format recursively."
    184     )
    185     parser.add_argument("folder_path", help="Path to the folder containing Opus files")
    186     parser.add_argument(
    187         "--keep-original",
    188         action="store_true",
    189         help="Keep original Opus files after conversion (default: delete)",
    190     )
    191     parser.add_argument(
    192         "--dry-run",
    193         action="store_true",
    194         help="Show what would be converted without writing or deleting anything",
    195     )
    196     parser.add_argument(
    197         "--jobs",
    198         "-j",
    199         type=int,
    200         default=1,
    201         help="Number of files to convert in parallel (default: 1)",
    202     )
    203     args = parser.parse_args()
    204 
    205     start_dir = Path(args.folder_path).resolve()
    206     if not start_dir.exists():
    207         print(f"Error: The directory '{start_dir}' does not exist.")
    208         return 1
    209     if not start_dir.is_dir():
    210         print(f"Error: '{start_dir}' is not a directory.")
    211         return 1
    212 
    213     files = list(iter_opus_files(start_dir))
    214     if not files:
    215         print(f"No Opus files found under {start_dir}")
    216         return 0
    217 
    218     stats = ConversionStats()
    219     delete_original = not args.keep_original
    220 
    221     def handle(file_path):
    222         return convert_opus_to_aiff(file_path, delete_original)
    223 
    224     if args.dry_run:
    225         for f in files:
    226             print(f"[dry-run] Would convert {f} -> {f.with_suffix('.aiff')}")
    227         print(f"\n{len(files)} file(s) would be converted.")
    228         return 0
    229 
    230     if args.jobs > 1:
    231         with ThreadPoolExecutor(max_workers=args.jobs) as pool:
    232             futures = {pool.submit(handle, f): f for f in files}
    233             for fut in as_completed(futures):
    234                 f = futures[fut]
    235                 try:
    236                     success, status = fut.result()
    237                 except Exception as e:
    238                     print(f"Failed to process {f}: {str(e)}")
    239                     stats.errors += 1
    240                     continue
    241                 _tally(stats, success, status, f, delete_original)
    242     else:
    243         for f in files:
    244             try:
    245                 success, status = handle(f)
    246             except Exception as e:
    247                 print(f"Failed to process {f}: {str(e)}")
    248                 stats.errors += 1
    249                 continue
    250             _tally(stats, success, status, f, delete_original)
    251 
    252     print("\nConversion Complete!")
    253     print(f"Successfully converted: {stats.converted} files")
    254     if stats.artwork_preserved:
    255         print(f"  with artwork preserved: {stats.artwork_preserved}")
    256     if stats.no_artwork:
    257         print(f"  without artwork:        {stats.no_artwork}")
    258     if stats.skipped:
    259         print(f"Skipped (already exist): {stats.skipped} files")
    260     if stats.artwork_lost:
    261         print(f"Artwork transfer failed (originals kept): {stats.artwork_lost} files")
    262     if not args.keep_original:
    263         print(f"Original files deleted: {stats.deleted} files")
    264     if stats.errors:
    265         print(f"Errors encountered: {stats.errors} files")
    266 
    267     return 1 if stats.errors else 0
    268 
    269 
    270 def _tally(stats, success, status, file_path, delete_original):
    271     if not success:
    272         stats.errors += 1
    273         return
    274     if status == "skipped":
    275         stats.skipped += 1
    276         return
    277     stats.converted += 1
    278     if status == "artwork_preserved":
    279         stats.artwork_preserved += 1
    280     elif status == "no_artwork":
    281         stats.no_artwork += 1
    282     elif status == "artwork_lost":
    283         stats.artwork_lost += 1
    284     if delete_original and not file_path.exists() and status != "artwork_lost":
    285         stats.deleted += 1
    286 
    287 
    288 if __name__ == "__main__":
    289     sys.exit(main())