Guest User

process.py

a guest
Apr 28th, 2026
279
0
Never
5
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 26.33 KB | Source Code | 0 0
  1. """
  2. Music Library Transcoder
  3. Scans, caches, and processes a music library from IN to OUT with metadata normalization.
  4. Written largely by GLM preview
  5. """
  6.  
  7. import json
  8. import logging
  9. import os
  10. import re
  11. import shutil
  12. import subprocess
  13. import sys
  14. import unicodedata
  15. from pathlib import Path
  16. from typing import Optional
  17.  
  18. import mutagen
  19. from mutagen.flac import FLAC
  20. from mutagen.mp3 import MP3
  21. from mutagen.easymp4 import EasyMP4
  22. from mutagen.oggopus import OggOpus
  23. from mutagen.id3 import ID3
  24.  
  25. # =============================================================================
  26. # USER-CONFIGURABLE CONSTANTS
  27. # =============================================================================
  28. BASEP = Path("/mnt/Vault/Media/Music/Library")
  29. IN_PATH = BASEP / "Library"
  30. OUT_PATH = BASEP / "Library-squashed"
  31.  
  32. VALID_MUSIC_EXTENSIONS = {"opus", "mp3", "m4a", "flac"}
  33. VALID_ART_EXTENSIONS = {"jpg", "png"}
  34.  
  35. # Transcoding settings
  36. OPUS_BITRATE = "192"  # kbps VBR
  37.  
  38. # =============================================================================
  39. # INTERNAL CONSTANTS
  40. # =============================================================================
  41.  
  42. SCRIPT_NAME = Path(__file__).stem
  43. CACHE_FILE = Path(__file__).parent / f"{SCRIPT_NAME}.cache.json"
  44. DEBUG_LOG_FILE = Path(__file__).parent / f"{SCRIPT_NAME}.debug.log"
  45.  
  46. FORBIDDEN_FILENAME_CHARS = r'<>:"/\\|?*'
  47.  
  48. # Artist image filenames (case-insensitive match)
  49. ARTIST_IMAGE_NAMES = {"artist"}
  50.  
  51. # Album cover filenames (case-insensitive match)
  52. ALBUM_COVER_NAMES = {"cover", "folder", "front"}
  53.  
  54.  
  55. # =============================================================================
  56. # LOGGING SETUP
  57. # =============================================================================
  58.  
  59.  
  60. def setup_logging() -> logging.Logger:
  61.     """Configure dual logging: INFO to console, DEBUG to file."""
  62.     logger = logging.getLogger(SCRIPT_NAME)
  63.     logger.setLevel(logging.DEBUG)
  64.  
  65.     # Console handler (INFO and above)
  66.     console_handler = logging.StreamHandler(sys.stdout)
  67.     console_handler.setLevel(logging.INFO)
  68.     console_format = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S")
  69.     console_handler.setFormatter(console_format)
  70.  
  71.     # File handler (DEBUG and above)
  72.     file_handler = logging.FileHandler(DEBUG_LOG_FILE, mode="w", encoding="utf-8")
  73.     file_handler.setLevel(logging.DEBUG)
  74.     file_format = logging.Formatter("%(asctime)s [%(levelname)s] %(funcName)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
  75.     file_handler.setFormatter(file_format)
  76.  
  77.     logger.addHandler(console_handler)
  78.     logger.addHandler(file_handler)
  79.  
  80.     return logger
  81.  
  82.  
  83. log = setup_logging()
  84.  
  85.  
  86. # =============================================================================
  87. # UTILITY FUNCTIONS
  88. # =============================================================================
  89.  
  90.  
  91. def normalize_for_out(name: str) -> str:
  92.     """Normalize unicode to NFC and remove forbidden characters for OUT filenames only."""
  93.     normalized = unicodedata.normalize("NFC", name)
  94.  
  95.     original = normalized
  96.     for char in FORBIDDEN_FILENAME_CHARS:
  97.         if char in normalized:
  98.             log.warning(f"Removing forbidden character '{char}' from OUT filename: {original}")
  99.             normalized = normalized.replace(char, "")
  100.  
  101.     return normalized
  102.  
  103.  
  104. def get_file_mtime(path: Path) -> float:
  105.     """Get file modification time as timestamp."""
  106.     return os.path.getmtime(path)
  107.  
  108.  
  109. def extract_year_from_folder(folder_name: str) -> tuple[Optional[int], str]:
  110.     """
  111.    Extract year from folder name if present in parentheses.
  112.    Returns (year, album_name) tuple.
  113.    Album name is for metadata tags only - folder name preserved as-is.
  114.    """
  115.     match = re.match(r"^\((\d{4})\)\s*(.+)$", folder_name)
  116.     if match:
  117.         year = int(match.group(1))
  118.         album_name = match.group(2).strip()
  119.         log.debug(f"Extracted year {year} from folder '{folder_name}', album name: '{album_name}'")
  120.         return year, album_name
  121.  
  122.     log.debug(f"No year prefix in folder '{folder_name}'")
  123.     return None, folder_name
  124.  
  125.  
  126. def extract_track_from_filename(filename: str) -> tuple[Optional[int], str]:
  127.     """
  128.    Extract track number from filename if present.
  129.    Returns (track_number, title) tuple.
  130.    """
  131.     name = Path(filename).stem
  132.  
  133.     match = re.match(r"^(\d+)[^\w]*(.+)$", name)
  134.     if match:
  135.         track = int(match.group(1))
  136.         title = match.group(2).strip()
  137.         log.debug(f"Extracted track {track} from filename '{filename}', title: '{title}'")
  138.         return track, title
  139.  
  140.     log.debug(f"No track prefix in filename '{filename}'")
  141.     return None, name
  142.  
  143.  
  144. def get_extension(path: Path) -> str:
  145.     """Get lowercase file extension without dot."""
  146.     return path.suffix.lower().lstrip(".")
  147.  
  148.  
  149. # =============================================================================
  150. # AUDIO METADATA FUNCTIONS
  151. # =============================================================================
  152.  
  153.  
  154. def read_audio_metadata(file_path: Path) -> dict:
  155.     """Read metadata from audio file using mutagen."""
  156.     metadata = {}
  157.     ext = get_extension(file_path)
  158.  
  159.     try:
  160.         if ext == "flac":
  161.             audio = FLAC(file_path)
  162.         elif ext == "mp3":
  163.             audio = MP3(file_path, ID3=ID3)
  164.         elif ext == "m4a":
  165.             audio = EasyMP4(file_path)
  166.         elif ext == "opus":
  167.             audio = OggOpus(file_path)
  168.         else:
  169.             audio = mutagen.File(file_path, easy=True)
  170.  
  171.         if audio is not None:
  172.             for tag in ["artist", "album", "title", "tracknumber", "date", "genre"]:
  173.                 if tag in audio:
  174.                     value = audio[tag]
  175.                     if isinstance(value, list):
  176.                         value = value[0] if value else None
  177.                     metadata[tag] = str(value) if value else None
  178.  
  179.             if ext == "mp3" and "date" not in metadata:
  180.                 try:
  181.                     audio_full = MP3(file_path)
  182.                     if "TDRC" in audio_full:
  183.                         metadata["date"] = str(audio_full["TDRC"])
  184.                     elif "TYER" in audio_full:
  185.                         metadata["date"] = str(audio_full["TYER"])
  186.                 except Exception:
  187.                     pass
  188.  
  189.             log.debug(f"Read metadata from {file_path.name}: {metadata}")
  190.  
  191.     except Exception as e:
  192.         log.debug(f"Failed to read metadata from {file_path}: {e}")
  193.  
  194.     return metadata
  195.  
  196.  
  197. def strip_all_tags(file_path: Path) -> None:
  198.     """Remove all tags/metadata from audio file."""
  199.     ext = get_extension(file_path)
  200.  
  201.     try:
  202.         if ext == "flac":
  203.             audio = FLAC(file_path)
  204.             audio.delete()
  205.         elif ext == "mp3":
  206.             audio = MP3(file_path, ID3=ID3)
  207.             audio.delete()
  208.         elif ext == "m4a":
  209.             audio = EasyMP4(file_path)
  210.             audio.delete()
  211.         elif ext == "opus":
  212.             audio = OggOpus(file_path)
  213.             audio.delete()
  214.  
  215.         log.debug(f"Stripped tags from {file_path.name}")
  216.  
  217.     except Exception as e:
  218.         log.debug(f"Error stripping tags from {file_path}: {e}")
  219.  
  220.  
  221. def write_tags(file_path: Path, metadata: dict) -> None:
  222.     """Write normalized metadata to audio file."""
  223.     ext = get_extension(file_path)
  224.  
  225.     try:
  226.         if ext == "opus":
  227.             audio = OggOpus(file_path)
  228.         elif ext == "mp3":
  229.             from mutagen.easyid3 import EasyID3
  230.  
  231.             audio = EasyID3(file_path)
  232.         elif ext == "m4a":
  233.             audio = EasyMP4(file_path)
  234.         else:
  235.             log.warning(f"Cannot write tags to {ext} file: {file_path}")
  236.             return
  237.  
  238.         audio.delete()
  239.  
  240.         if metadata.get("artist"):
  241.             audio["artist"] = metadata["artist"]
  242.         if metadata.get("album"):
  243.             audio["album"] = metadata["album"]
  244.         if metadata.get("title"):
  245.             audio["title"] = metadata["title"]
  246.         if metadata.get("tracknumber"):
  247.             audio["tracknumber"] = str(metadata["tracknumber"])
  248.         if metadata.get("date"):
  249.             audio["date"] = str(metadata["date"])
  250.         if metadata.get("genre"):
  251.             audio["genre"] = metadata["genre"]
  252.  
  253.         audio.save()
  254.         log.debug(f"Wrote tags to {file_path.name}")
  255.  
  256.     except Exception as e:
  257.         log.error(f"Failed to write tags to {file_path}: {e}")
  258.  
  259.  
  260. # =============================================================================
  261. # CACHE MANAGEMENT
  262. # =============================================================================
  263.  
  264.  
  265. class Cache:
  266.     """Manages cached metadata and file modification times."""
  267.  
  268.     def __init__(self, cache_path: Path):
  269.         self.cache_path = cache_path
  270.         self.data: dict = {}
  271.         self.load()
  272.  
  273.     def load(self) -> None:
  274.         """Load cache from disk."""
  275.         if self.cache_path.exists():
  276.             try:
  277.                 with open(self.cache_path, "r", encoding="utf-8") as f:
  278.                     self.data = json.load(f)
  279.                 log.info(f"Loaded cache with {len(self.data)} entries")
  280.             except Exception as e:
  281.                 log.warning(f"Failed to load cache: {e}")
  282.                 self.data = {}
  283.         else:
  284.             self.data = {}
  285.  
  286.     def save(self) -> None:
  287.         """Save cache to disk."""
  288.         try:
  289.             with open(self.cache_path, "w", encoding="utf-8") as f:
  290.                 json.dump(self.data, f, indent=2, ensure_ascii=False)
  291.             log.debug(f"Saved cache to {self.cache_path}")
  292.         except Exception as e:
  293.             log.error(f"Failed to save cache: {e}")
  294.  
  295.     def get_entry(self, file_path: Path) -> Optional[dict]:
  296.         """Get cached entry if file hasn't been modified."""
  297.         key = str(file_path)
  298.         if key not in self.data:
  299.             return None
  300.  
  301.         cached = self.data[key]
  302.         current_mtime = get_file_mtime(file_path)
  303.  
  304.         if cached.get("mtime") == current_mtime:
  305.             log.debug(f"Cache hit for {file_path.name}")
  306.             return cached
  307.         else:
  308.             log.debug(f"Cache miss (modified) for {file_path.name}")
  309.             return None
  310.  
  311.     def set_entry(self, file_path: Path, metadata: dict) -> None:
  312.         """Store metadata in cache with current modification time."""
  313.         key = str(file_path)
  314.         self.data[key] = {
  315.             "mtime": get_file_mtime(file_path),
  316.             **metadata,
  317.         }
  318.  
  319.  
  320. # =============================================================================
  321. # LIBRARY SCANNING
  322. # =============================================================================
  323.  
  324.  
  325. def scan_library(in_path: Path, cache: Cache) -> dict:
  326.     """
  327.    Scan the input library and return structured data.
  328.    IN paths and names are preserved exactly as-is.
  329.    """
  330.     library = {"artists": {}}
  331.  
  332.     log.info(f"Scanning library at {in_path}")
  333.  
  334.     for artist_folder in sorted(in_path.iterdir()):
  335.         if not artist_folder.is_dir():
  336.             continue
  337.  
  338.         # Preserve exact IN artist folder name
  339.         artist_folder_name = artist_folder.name
  340.         log.info(f"Scanning artist: {artist_folder_name}")
  341.  
  342.         artist_data = {
  343.             "path": str(artist_folder),
  344.             "folder_name": artist_folder_name,  # Exact IN name
  345.             "albums": {},
  346.             "artist_image": None,
  347.         }
  348.  
  349.         # Check for artist image
  350.         for file in artist_folder.iterdir():
  351.             if file.is_file() and get_extension(file) in VALID_ART_EXTENSIONS:
  352.                 if file.stem.lower() in ARTIST_IMAGE_NAMES:
  353.                     artist_data["artist_image"] = str(file)
  354.                     log.debug(f"Found artist image: {file.name}")
  355.                     break
  356.  
  357.         for album_folder in sorted(artist_folder.iterdir()):
  358.             if not album_folder.is_dir():
  359.                 continue
  360.  
  361.             # Preserve exact IN album folder name
  362.             album_folder_name = album_folder.name
  363.             year, album_name = extract_year_from_folder(album_folder_name)
  364.  
  365.             log.debug(f"Scanning album: {album_folder_name} (year={year}, name='{album_name}')")
  366.  
  367.             album_data = {
  368.                 "path": str(album_folder),
  369.                 "folder_name": album_folder_name,  # Exact IN name
  370.                 "album_name": album_name,  # For metadata tags (year stripped)
  371.                 "year": year,
  372.                 "tracks": {},
  373.                 "cover_image": None,
  374.             }
  375.  
  376.             # Check for album cover
  377.             for file in album_folder.iterdir():
  378.                 if file.is_file() and get_extension(file) in VALID_ART_EXTENSIONS:
  379.                     if file.stem.lower() in ALBUM_COVER_NAMES:
  380.                         album_data["cover_image"] = str(file)
  381.                         log.debug(f"Found album cover: {file.name}")
  382.                         break
  383.  
  384.             # Find all audio tracks
  385.             for audio_file in sorted(album_folder.iterdir()):
  386.                 if not audio_file.is_file():
  387.                     continue
  388.  
  389.                 ext = get_extension(audio_file)
  390.                 if ext not in VALID_MUSIC_EXTENSIONS:
  391.                     continue
  392.  
  393.                 # Check cache first
  394.                 cached = cache.get_entry(audio_file)
  395.                 if cached:
  396.                     track_data = {
  397.                         "path": str(audio_file),
  398.                         "filename": audio_file.name,  # Exact IN filename
  399.                         "title": cached.get("title"),
  400.                         "tracknumber": cached.get("tracknumber"),
  401.                         "year": cached.get("year"),
  402.                         "genre": cached.get("genre"),
  403.                         "artist": cached.get("artist"),
  404.                         "album": cached.get("album"),
  405.                         "extension": ext,
  406.                     }
  407.                 else:
  408.                     # Extract metadata from file and path
  409.                     file_meta = read_audio_metadata(audio_file)
  410.                     track_from_name, title_from_name = extract_track_from_filename(audio_file.name)
  411.  
  412.                     # Get raw track number from metadata or filename
  413.                     raw_track = file_meta.get("tracknumber") or track_from_name
  414.  
  415.                     # Sanitize track number: handle "01/12", "1-1/10", etc. else None
  416.                     if raw_track:
  417.                         track_str = str(raw_track).strip()
  418.                         track_str = track_str.split('-')[-1]
  419.                         track_str = track_str.split('/')[0]
  420.                         digits = re.search(r'\d+', track_str)
  421.                         sanitized_track = digits.group(0) if digits else None
  422.                     else:
  423.                         sanitized_track = None
  424.  
  425.                     track_data = {
  426.                         "path": str(audio_file),
  427.                         "filename": audio_file.name,  # Exact IN filename
  428.                         "title": file_meta.get("title") or title_from_name,
  429.                         "tracknumber": sanitized_track or track_from_name,
  430.                         "year": file_meta.get("date"),
  431.                         "genre": file_meta.get("genre"),
  432.                         "artist": file_meta.get("artist"),
  433.                         "album": file_meta.get("album"),
  434.                         "extension": ext,
  435.                     }
  436.  
  437.                     cache.set_entry(
  438.                         audio_file,
  439.                         {
  440.                             "title": track_data["title"],
  441.                             "tracknumber": track_data["tracknumber"],
  442.                             "year": track_data["year"],
  443.                             "genre": track_data["genre"],
  444.                             "artist": track_data["artist"],
  445.                             "album": track_data["album"],
  446.                         },
  447.                     )
  448.  
  449.                 album_data["tracks"][audio_file.name] = track_data
  450.                 log.debug(f"Found track: {audio_file.name}")
  451.  
  452.             artist_data["albums"][album_folder_name] = album_data
  453.  
  454.         library["artists"][artist_folder_name] = artist_data
  455.  
  456.     log.info(f"Scan complete: {len(library['artists'])} artists found")
  457.     return library
  458.  
  459.  
  460. # =============================================================================
  461. # FILE PROCESSING
  462. # =============================================================================
  463.  
  464.  
  465. def should_process(in_file: Path, out_file: Path) -> bool:
  466.     """
  467.    Determine if a file needs to be processed.
  468.    Returns True if OUT doesn't exist or is older than IN.
  469.    """
  470.     if not out_file.exists():
  471.         log.debug(f"OUT file doesn't exist: {out_file.name}")
  472.         return True
  473.  
  474.     in_mtime = get_file_mtime(in_file)
  475.     out_mtime = get_file_mtime(out_file)
  476.  
  477.     if in_mtime > out_mtime:
  478.         log.debug(f"IN file is newer: {in_file.name}")
  479.         return True
  480.  
  481.     return False
  482.  
  483.  
  484. def transcode_flac_to_opus(in_file: Path, out_file: Path) -> bool:
  485.     """Transcode FLAC to Opus using ffmpeg."""
  486.     try:
  487.         cmd = [
  488.             "ffmpeg",
  489.             "-i", str(in_file),
  490.             "-c:a", "libopus",
  491.             "-b:a", f"{OPUS_BITRATE}k",
  492.             "-vn",
  493.             "-y",
  494.             str(out_file),
  495.         ]
  496.  
  497.         log.debug(f"Running: {' '.join(cmd)}")
  498.  
  499.         result = subprocess.run(
  500.             cmd,
  501.             capture_output=True,
  502.             text=True,
  503.         )
  504.  
  505.         if result.returncode != 0:
  506.             log.error(f"ffmpeg failed: {result.stderr}")
  507.             return False
  508.  
  509.         log.info(f"Transcoded: {in_file.name} -> {out_file.name}")
  510.         return True
  511.  
  512.     except Exception as e:
  513.         log.error(f"Transcoding failed: {e}")
  514.         return False
  515.  
  516.  
  517. def copy_audio(in_file: Path, out_file: Path) -> bool:
  518.     """Copy audio file as-is (for non-FLAC lossy formats)."""
  519.     try:
  520.         shutil.copy2(in_file, out_file)
  521.         log.info(f"Copied: {in_file.name}")
  522.         return True
  523.     except Exception as e:
  524.         log.error(f"Copy failed: {e}")
  525.         return False
  526.  
  527.  
  528. def copy_art(in_file: Path, out_file: Path) -> bool:
  529.     """Copy art file as-is."""
  530.     try:
  531.         shutil.copy2(in_file, out_file)
  532.         log.info(f"Copied art: {in_file.name}")
  533.         return True
  534.     except Exception as e:
  535.         log.error(f"Art copy failed: {e}")
  536.         return False
  537.  
  538.  
  539. def process_track(
  540.     track_data: dict,
  541.     artist_folder_name: str,
  542.     album_data: dict,
  543.     out_artist_path: Path,
  544.     out_album_path: Path,
  545. ) -> bool:
  546.     """Process a single track: copy/transcode and write tags."""
  547.     in_file = Path(track_data["path"])
  548.     ext = track_data["extension"]
  549.  
  550.     # Build output filename (normalized for OUT only)
  551.     track_num = track_data.get("tracknumber")
  552.     title = track_data.get("title") or "Unknown"
  553.  
  554.     if track_num is not None:
  555.         out_filename = f"{int(track_num):02d} - {title}"
  556.     else:
  557.         out_filename = title
  558.  
  559.     # Determine output extension
  560.     out_ext = "opus" if ext == "flac" else ext
  561.  
  562.     # Normalize ONLY for OUT filename
  563.     out_filename = normalize_for_out(f"{out_filename}.{out_ext}")
  564.     out_file = out_album_path / out_filename
  565.  
  566.     if not should_process(in_file, out_file):
  567.         log.debug(f"Skipping (up-to-date): {out_filename}")
  568.         return True
  569.  
  570.     out_album_path.mkdir(parents=True, exist_ok=True)
  571.  
  572.     success = False
  573.  
  574.     if ext == "flac":
  575.         success = transcode_flac_to_opus(in_file, out_file)
  576.     else:
  577.         success = copy_audio(in_file, out_file)
  578.         if success:
  579.             strip_all_tags(out_file)
  580.  
  581.     if not success:
  582.         return False
  583.  
  584.     # Build final metadata - normalize for tags
  585.     final_metadata = {
  586.         "artist": normalize_for_out(artist_folder_name),
  587.         "album": normalize_for_out(album_data["album_name"]),
  588.         "title": normalize_for_out(title),
  589.         "tracknumber": track_num,
  590.         "date": track_data.get("year") or album_data.get("year"),
  591.         "genre": track_data.get("genre"),
  592.     }
  593.  
  594.     write_tags(out_file, final_metadata)
  595.  
  596.     return True
  597.  
  598.  
  599. # =============================================================================
  600. # ORPHAN CLEANUP
  601. # =============================================================================
  602.  
  603.  
  604. def find_orphaned_files(library: dict, out_path: Path) -> list[Path]:
  605.     """Find files in OUT that don't exist in IN."""
  606.     orphaned = []
  607.  
  608.     if not out_path.exists():
  609.         return orphaned
  610.  
  611.     expected_out_paths = set()
  612.  
  613.     for artist_folder_name, artist_data in library["artists"].items():
  614.         # OUT folder names match IN folder names exactly
  615.         out_artist_path = out_path / artist_folder_name
  616.         expected_out_paths.add(out_artist_path)
  617.  
  618.         if artist_data.get("artist_image"):
  619.             art_file = Path(artist_data["artist_image"])
  620.             out_art = out_artist_path / art_file.name  # Preserve exact filename
  621.             expected_out_paths.add(out_art)
  622.  
  623.         for album_folder_name, album_data in artist_data["albums"].items():
  624.             out_album_path = out_artist_path / album_folder_name  # Preserve exact folder name
  625.             expected_out_paths.add(out_album_path)
  626.  
  627.             if album_data.get("cover_image"):
  628.                 cover_file = Path(album_data["cover_image"])
  629.                 out_cover = out_album_path / cover_file.name  # Preserve exact filename
  630.                 expected_out_paths.add(out_cover)
  631.  
  632.             for track_name, track_data in album_data["tracks"].items():
  633.                 ext = track_data["extension"]
  634.                 track_num = track_data.get("tracknumber")
  635.                 title = track_data.get("title") or "Unknown"
  636.  
  637.                 if track_num is not None:
  638.                     out_filename = f"{int(track_num):02d} - {title}"
  639.                 else:
  640.                     out_filename = title
  641.  
  642.                 out_ext = "opus" if ext == "flac" else ext
  643.                 out_filename = normalize_for_out(f"{out_filename}.{out_ext}")
  644.  
  645.                 out_track = out_album_path / out_filename
  646.                 expected_out_paths.add(out_track)
  647.  
  648.     # Find actual OUT files
  649.     for root, dirs, files in os.walk(out_path):
  650.         root_path = Path(root)
  651.  
  652.         for d in dirs:
  653.             dir_path = root_path / d
  654.             if dir_path not in expected_out_paths:
  655.                 orphaned.append(dir_path)
  656.  
  657.         for f in files:
  658.             file_path = root_path / f
  659.             if file_path not in expected_out_paths:
  660.                 orphaned.append(file_path)
  661.  
  662.     orphaned.sort(key=lambda p: len(str(p)), reverse=True)
  663.  
  664.     return orphaned
  665.  
  666.  
  667. def confirm_and_delete_orphans(orphaned: list[Path]) -> bool:
  668.     """Ask for confirmation once, then delete all orphaned files."""
  669.     if not orphaned:
  670.         log.info("No orphaned files to clean up")
  671.         return True
  672.  
  673.     print("\n" + "=" * 60)
  674.     print("ORPHANED FILES DETECTED (not in IN library):")
  675.     print("=" * 60)
  676.  
  677.     for p in orphaned:
  678.         print(f"  {p}")
  679.  
  680.     print("=" * 60)
  681.     print(f"Total: {len(orphaned)} items")
  682.  
  683.     response = input("\nDelete these orphaned files? [y/N]: ").strip().lower()
  684.  
  685.     if response == "y":
  686.         for p in orphaned:
  687.             try:
  688.                 if p.is_dir():
  689.                     shutil.rmtree(p)
  690.                 else:
  691.                     p.unlink()
  692.                 log.info(f"Deleted orphaned: {p}")
  693.             except Exception as e:
  694.                 log.error(f"Failed to delete {p}: {e}")
  695.         return True
  696.     else:
  697.         log.info("Orphan deletion cancelled by user")
  698.         return False
  699.  
  700.  
  701. # =============================================================================
  702. # MAIN PROCESSING
  703. # =============================================================================
  704.  
  705.  
  706. def process_library(library: dict, out_path: Path) -> None:
  707.     """Process entire library: copy art and process tracks."""
  708.     log.info(f"Processing library to {out_path}")
  709.  
  710.     for artist_folder_name, artist_data in library["artists"].items():
  711.         # OUT folder structure mirrors IN exactly
  712.         out_artist_path = out_path / artist_folder_name
  713.  
  714.         log.info(f"Processing artist: {artist_folder_name}")
  715.  
  716.         # Copy artist image if exists
  717.         if artist_data.get("artist_image"):
  718.             in_art = Path(artist_data["artist_image"])
  719.             out_art = out_artist_path / in_art.name  # Preserve exact filename
  720.  
  721.             if should_process(in_art, out_art):
  722.                 out_artist_path.mkdir(parents=True, exist_ok=True)
  723.                 copy_art(in_art, out_art)
  724.  
  725.         for album_folder_name, album_data in artist_data["albums"].items():
  726.             out_album_path = out_artist_path / album_folder_name  # Preserve exact folder name
  727.  
  728.             log.info(f"  Processing album: {album_folder_name}")
  729.  
  730.             # Copy album cover if exists
  731.             if album_data.get("cover_image"):
  732.                 in_cover = Path(album_data["cover_image"])
  733.                 out_cover = out_album_path / in_cover.name  # Preserve exact filename
  734.  
  735.                 if should_process(in_cover, out_cover):
  736.                     out_album_path.mkdir(parents=True, exist_ok=True)
  737.                     copy_art(in_cover, out_cover)
  738.  
  739.             for track_name, track_data in album_data["tracks"].items():
  740.                 process_track(
  741.                     track_data,
  742.                     artist_folder_name,
  743.                     album_data,
  744.                     out_artist_path,
  745.                     out_album_path,
  746.                 )
  747.  
  748.  
  749. # =============================================================================
  750. # MAIN ENTRY POINT
  751. # =============================================================================
  752.  
  753.  
  754. def main() -> None:
  755.     """Main entry point."""
  756.     log.info("=" * 60)
  757.     log.info("Music Library Transcoder")
  758.     log.info("=" * 60)
  759.     log.info(f"IN:  {IN_PATH}")
  760.     log.info(f"OUT: {OUT_PATH}")
  761.     log.info("=" * 60)
  762.  
  763.     if not IN_PATH.exists():
  764.         log.error(f"IN path does not exist: {IN_PATH}")
  765.         sys.exit(1)
  766.  
  767.     cache = Cache(CACHE_FILE)
  768.     library = scan_library(IN_PATH, cache)
  769.     cache.save()
  770.  
  771.     orphaned = find_orphaned_files(library, OUT_PATH)
  772.     if orphaned:
  773.         if not confirm_and_delete_orphans(orphaned):
  774.             log.warning("Proceeding without deleting orphans (may cause inconsistencies)")
  775.  
  776.     process_library(library, OUT_PATH)
  777.  
  778.     log.info("=" * 60)
  779.     log.info("Processing complete!")
  780.     log.info("=" * 60)
  781.  
  782.  
  783. if __name__ == "__main__":
  784.     main()
  785.  
Advertisement
Comments
  • User was banned
  • User was banned
  • User was banned
  • User was banned
  • User was banned
Add Comment
Please, Sign In to add comment