Skill: hanshojin/cd2flac (version 1.3.1)

--- name: cd2flac description: | Convert CD audio archives (RAR files containing WAV+CUE pairs) into organized FLAC files. Handles RAR extraction, CUE-based track splitting, GBK encoding fix for Chinese filenames, multi-CD album organization, and auto-fetch synced lyrics from Netease Cloud Music (with Kugou fallback). Use whenever someone needs to extract and convert CD-quality audio archives (WAV+CUE in RAR/7z/ZIP) to per-track FLAC files. Also handles direct WAV+CUE pairs without archives. --- # CD to FLAC Converter Tool to extract CD audio from RAR archives and convert WAV+CUE pairs into individual FLAC tracks with correct names and synced lyrics. ## Requirements - **System packages**: `unrar`, `cuetools`, `shntool`, `flac` - Install: `sudo apt-get install -y unrar cuetools shntool flac` ## Python dependencies - `mutagen` (FLAC tag handling) - `pycryptodome` (Netease API encryption) - Install: `pip3 install mutagen pycryptodome` ## Usage ```bash python3 skills/cd2flac/scripts/cd2flac.py <directory> [options] ``` ### Basic — process RAR archives in a directory ```bash python3 skills/cd2flac/scripts/cd2flac.py "/path/to/albums/" ``` ### With synced lyrics (Netease + Kugou) ```bash python3 skills/cd2flac/scripts/cd2flac.py "/path/to/albums/" --lyrics ``` ### Inject lyrics into already converted FLACs (no conversion) ```bash python3 skills/cd2flac/scripts/cd2flac.py "/path/to/albums/" --lyrics-only ``` ### Standalone lyric injector (for any FLAC directory) ```bash python3 skills/cd2flac/scripts/lyric.py "/path/to/album/" python3 skills/cd2flac/scripts/lyric.py "/path/to/track.flac" ``` ### Dry run — preview without changes ```bash python3 skills/cd2flac/scripts/cd2flac.py "/path/to/albums/" --dry-run ``` ### Keep WAV/CUE, delete RAR after extraction ```bash python3 skills/cd2flac/scripts/cd2flac.py "/path/" --keep-wav --delete-rar ``` ### Process subdirectories recursively ```bash python3 skills/cd2flac/scripts/cd2flac.py "/path/" --recursive --lyrics ``` ### Process already-extracted WAV+CUE pairs (no RAR) ```bash python3 skills/cd2flac/scripts/cd2flac.py "/path/to/album_with_wav/" ``` ## What It Does 1. **Extract**: Unpacks RAR archives in the target directory 2. **Parse CUE**: Reads CUE sheets in GBK/GB18030/UTF-8 encoding, extracts track titles 3. **Split**: Uses `shnsplit` to split each WAV into individual FLAC tracks 4. **Fix names**: Renames garbled tracks (CUE files in Chinese encodings produce mojibake filenames) using correct track titles from the CUE sheet 5. **Organize**: Multi-CD albums get `CD1/`, `CD2/`, etc. subdirectories 6. **Lyrics**: Auto-fetch synced lyrics from Netease Cloud Music (primary) and Kugou (fallback), inject as FLAC LYRICS tag with translation support 7. **Clean up**: Deletes original WAV and CUE files (optional: keep them) ## Lyric Injection Lyrics are fetched using the same strategy as ESLyric: - **Primary**: Netease Cloud Music via linuxapi (AES-ECB encryption, no login needed) - **Fallback**: Kugou Music Synced LRC is embedded as Vorbis comment `LYRICS` tag. If Netease provides translation lyrics, they go in `LYRICS_TRANSLATIONS` tag. ## Common Scenarios ### Chinese album with garbled filenames The script auto-detects and fixes GBK→UTF-8 mojibake in track names by comparing the actual filename bytes against the CUE sheet's GBK-encoded titles. ### Multi-CD albums If a directory contains multiple WAV+CUE pairs, they are treated as separate discs and placed into `CD1/`, `CD2/`, etc. subdirectories. ### Already extracted albums If the RAR is already extracted but not yet converted, the script detects the WAV+CUE pairs and processes them directly. ### Add lyrics to an existing FLAC library ```bash find /path/to/music -name "*.flac" -exec dirname {} \; | sort -u | \ xargs -I{} python3 skills/cd2flac/scripts/lyric.py "{}" ```

path: _meta.json
content: ownerId: kn7621a0m45rgjx0e1xjfy9th985ratg
slug: cd2flac
version: 1.3.0
publishedAt: 1777436971600
changelog: DSF/SACD 支持: 修复文件头写入损坏问题，支持 DSF 格式歌词注入 (TXXX:LYRICS)，按 DSF 规范在文件末尾附加 ID3 标签

path: scripts/cd2flac.py
content: #!/usr/bin/env python3
"""
cd2flac - Convert CD audio archives (RAR/WAV+CUE) into organized FLAC files.

Usage:
    python3 cd2flac.py <directory> [options]

Features:
    - Extract RAR archives containing WAV+CUE pairs
    - Split WAV files into individual FLAC tracks using CUE sheets
    - Handle GBK-encoded CUE files (common for Chinese releases)
    - Organize multi-CD albums into CD1/, CD2/ subdirectories
    - Preserve cover art and metadata files
    - Clean up WAV and CUE after successful conversion

Options:
    --dry-run     Preview what would be done without making changes
    --keep-wav    Keep original WAV files after conversion
    --keep-cue    Keep CUE files after conversion
    --keep-rar    Keep RAR archives after extraction (default: keep)
    --threads N   Number of parallel conversions (default: 1)
"""

import os
import subprocess
import sys
import shutil
import re
import argparse
import tempfile
from pathlib import Path

# Optional: lyric injection
_lyric_module = None
def _get_lyric_module():
    global _lyric_module
    if _lyric_module is None:
        try:
            from scripts import lyric as _lyric_module
        except ImportError:
            try:
                import lyric as _lyric_module
            except ImportError:
                _lyric_module = False
    return _lyric_module if _lyric_module else None


def run_cmd(cmd, cwd=None, timeout=7200, capture=True):
    """Run a command, return (returncode, stdout, stderr)."""
    kwargs = dict(cwd=cwd, timeout=timeout)
    if capture:
        kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    else:
        kwargs.update(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    try:
        result = subprocess.run(cmd, **kwargs)
    except subprocess.TimeoutExpired:
        return -1, "", "TIMEOUT"
    if capture:
        out = result.stdout.decode('utf-8', 'replace').strip()
        err = result.stderr.decode('utf-8', 'replace').strip()
    else:
        out, err = "", ""
    return result.returncode, out, err


def has_tool(name):
    """Check if a system tool is available."""
    return shutil.which(name) is not None


def check_dependencies():
    """Verify all required tools are installed."""
    required = {'unrar': 'unrar', 'shnsplit': 'shntool',
                'cuebreakpoints': 'cuetools', 'flac': 'flac'}
    missing = []
    for cmd, pkg in required.items():
        if not has_tool(cmd):
            missing.append(f"{cmd} (install: sudo apt install {pkg})")
    if missing:
        print("❌ Missing dependencies:")
        for m in missing:
            print(f"   {m}")
        print("\nInstall them with:")
        print("   sudo apt-get install -y unrar cuetools shntool flac")
        return False
    return True


def safe_filename(s):
    """Clean a string for use as a filename."""
    replacements = {
        '/': '／', '\\': '＼', ':': '：', '"': "'",
        '?': '？', '*': '＊', '<': '＜', '>': '＞',
        '|': '｜', '\n': ' ', '\r': '',
    }
    for old, new in replacements.items():
        s = s.replace(old, new)
    return s.strip()


def get_rar_files(directory):
    """Get sorted list of RAR files in directory."""
    return sorted([f for f in os.listdir(directory)
                   if f.endswith('.rar') and os.path.isfile(os.path.join(directory, f))])


def find_wav_cue_pairs(directory):
    """Find all WAV+CUE pairs in directory tree. Returns [(wav_path, cue_path)]. """
    pairs = []
    for root, dirs, files in os.walk(directory):
        # Skip [原始文件] and CD subdirectories - they don't have WAV
        dirs[:] = [d for d in dirs if d not in ('[原始文件]', 'CD1', 'CD2', 'CD3')]
        wavs = {}
        cues = {}
        for f in files:
            if f.lower().endswith('.wav'):
                base = os.path.splitext(f)[0]
                wavs[base] = os.path.join(root, f)
            elif f.lower().endswith('.cue'):
                base = os.path.splitext(f)[0]
                cues[base] = os.path.join(root, f)
        for base in wavs:
            if base in cues:
                pairs.append((wavs[base], cues[base]))
            else:
                print(f"  ⚠️  WAV without matching CUE: {os.path.basename(wavs[base])}")
    return pairs


def read_cue_text(cue_path):
    """Read CUE file, trying multiple encodings. Returns text."""
    with open(cue_path, 'rb') as f:
        raw = f.read()
    for enc in ('gbk', 'gb18030', 'utf-8', 'latin-1'):
        try:
            return raw.decode(enc)
        except (UnicodeDecodeError, UnicodeError):
            continue
    return raw.decode('utf-8', 'replace')


def parse_cue_tracks(cue_text):
    """Parse CUE text, return list of (track_number, title) in order."""
    tracks = []
    cur_track = None
    for line in cue_text.split('\n'):
        m = re.search(r'TRACK\s+(\d+)\s+AUDIO', line, re.IGNORECASE)
        if m:
            cur_track = int(m.group(1))
            continue
        m = re.search(r'TITLE\s+"(.+)"', line)
        if m and cur_track is not None:
            tracks.append((cur_track, m.group(1)))
    return tracks


def count_cds(tracks_list):
    """Determine number of CDs from track list (detect repeated track 01)."""
    count = 0
    for tn, title in tracks_list:
        if tn == 1:
            count += 1
    return max(count, 1)


def get_multi_cd_groups(tracks_list, num_cds):
    """Split track list into per-CD groups."""
    groups = []
    current = []
    for tn, title in tracks_list:
        if tn == 1 and current:
            groups.append(current)
            current = []
        current.append((tn, title))
    if current:
        groups.append(current)
    # Pad to expected count
    while len(groups) < num_cds:
        groups.append([])
    return groups


def extract_rar(directory, rar_name, dry_run=False):
    """Extract a RAR file into the directory. Returns True on success."""
    rar_path = os.path.join(directory, rar_name)
    dir_name = os.path.splitext(rar_name)[0]
    target = os.path.join(directory, dir_name)

    if os.path.isdir(target):
        existing_flacs = len([f for r, d, files in os.walk(target)
                              for f in files if f.endswith('.flac')])
        if existing_flacs > 0:
            print(f"  ℹ️  Already has {existing_flacs} FLACs, skipping extraction")
            return True

    print(f"  🔓 Extracting {rar_name}...")
    if dry_run:
        return True
    rc, out, err = run_cmd(["unrar", "x", "-y", rar_path], cwd=directory, capture=False)
    if rc not in (0, 1):
        print(f"  ❌ Extraction failed (rc={rc})")
        return False
    print(f"  ✅ Extracted")
    return True


def extract_rar_cue_contents(directory, rar_name):
    """Get all CUE file contents from a RAR archive, decoded as GBK."""
    rar_path = os.path.join(directory, rar_name)
    rc, out, err = run_cmd(["unrar", "p", rar_path, "*.cue"], cwd=directory, timeout=60)
    if rc not in (0, 1):
        return None
    for enc in ('gbk', 'gb18030'):
        try:
            return out.encode('latin-1').decode(enc) if isinstance(out, str) else out
        except:
            pass
    return out


def process_single_cd(wav_path, cue_path, output_dir, dry_run=False):
    """Process one CD: split WAV by CUE into FLAC tracks."""
    wav_name = os.path.basename(wav_path)
    print(f"  🎵 {wav_name}", flush=True)

    # Read CUE and get correct track names
    cue_text = read_cue_text(cue_path)
    correct_titles = dict(parse_cue_tracks(cue_text))

    if not correct_titles:
        print(f"  ⚠️  No tracks found in CUE file")
        return False

    print(f"     {len(correct_titles)} tracks in CUE", flush=True)

    if dry_run:
        return True

    # Split WAV into FLAC tracks in output directory
    rc, out, err = run_cmd(
        ["shnsplit", "-f", cue_path, "-o", "flac", "-t", "%n - %t", wav_path],
        cwd=output_dir, capture=False
    )

    if rc not in (0, 1):
        print(f"  ❌ Split failed (rc={rc})")
        return False

    # Move any FLACs that ended up in parent directory
    parent = os.path.dirname(output_dir)
    for f in os.listdir(parent):
        if f.endswith('.flac') and os.path.isfile(os.path.join(parent, f)):
            shutil.move(os.path.join(parent, f), os.path.join(output_dir, f))

    # Rename garbled FLACs using correct titles
    flacs = sorted([f for f in os.listdir(output_dir) if f.endswith('.flac')])
    renamed = 0
    for f in flacs:
        m = re.match(r'(\d+)\s*-\s*(.+)\.flac', f)
        if not m:
            continue
        tn = int(m.group(1))
        if tn in correct_titles:
            correct = correct_titles[tn]
            new_name = f"{tn:02d} - {safe_filename(correct)}.flac"
            if new_name != f:
                if os.path.exists(os.path.join(output_dir, new_name)):
                    os.remove(os.path.join(output_dir, f))
                else:
                    os.rename(os.path.join(output_dir, f),
                              os.path.join(output_dir, new_name))
                    renamed += 1

    final = len([f for f in os.listdir(output_dir) if f.endswith('.flac')])
    if renamed:
        print(f"     Fixed {renamed} garbled filenames, {final} total", flush=True)
    else:
        print(f"     ✅ {final} FLACs", flush=True)
    return True


def process_album(directory, rar_name, args):
    """Process one RAR archive (one album)."""
    rar_path = os.path.join(directory, rar_name)
    album_name = os.path.splitext(rar_name)[0]
    album_dir = os.path.join(directory, album_name)

    print(f"\n{'='*60}")
    print(f"📦 {rar_name}")

    # Step 1: Extract RAR
    if not extract_rar(directory, rar_name, args.dry_run):
        return False

    # Step 2: Find WAV+CUE pairs (from both fresh extraction and remaining)
    pairs = find_wav_cue_pairs(album_dir)
    if not pairs:
        # Check if already has FLACs
        flacs = [f for r, d, files in os.walk(album_dir)
                 for f in files if f.endswith('.flac')]
        if flacs:
            print(f"  ✅ Already processed: {len(flacs)} FLACs")
        else:
            print(f"  ℹ️  No WAV+CUE pairs found")
        return True

    print(f"  Found {len(pairs)} WAV+CUE pair(s)")

    # Step 3: Determine if multi-CD
    multi_cd = len(pairs) > 1

    if multi_cd:
        # Process each CD into its own subdirectory
        for i, (wav, cue) in enumerate(pairs, 1):
            cd_dir = os.path.join(album_dir, f"CD{i}")
            os.makedirs(cd_dir, exist_ok=True)
            ok = process_single_cd(wav, cue, cd_dir, args.dry_run)
            if ok and not args.dry_run:
                # Clean up this CD's WAV+CUE
                if not args.keep_wav and os.path.isfile(wav):
                    os.remove(wav)
                if not args.keep_cue and os.path.isfile(cue):
                    os.remove(cue)
    else:
        # Single CD - process in album root
        wav, cue = pairs[0]
        ok = process_single_cd(wav, cue, album_dir, args.dry_run)
        if ok and not args.dry_run:
            if not args.keep_wav and os.path.isfile(wav):
                os.remove(wav)
            if not args.keep_cue and os.path.isfile(cue):
                os.remove(cue)

    # Step 4: Clean up RAR if requested
    if args.delete_rar and not args.dry_run and os.path.isfile(rar_path):
        os.remove(rar_path)
        print(f"  🗑️  Deleted RAR: {rar_name}")

    # Step 5: Inject lyrics if requested
    if args.lyrics and not args.dry_run:
        lyric_mod = _get_lyric_module()
        if lyric_mod:
            print(f"\n  🎤 Fetching lyrics...")
            for root, dirs, files in os.walk(album_dir):
                for f in sorted(files):
                    if f.endswith('.flac'):
                        fp = os.path.join(root, f)
                        try:
                            from mutagen.flac import FLAC
                            audio = FLAC(fp)
                            title = audio.get('title', [''])[0]
                            artist = audio.get('artist', [''])[0]
                            if not title:
                                m = re.match(r'\d+\s*-\s*(.+)\.flac', f)
                                if m:
                                    title = m.group(1).strip()
                            lrc, src, tlrc = lyric_mod.get_lyrics(title, artist, flac_path=fp)
                            if lrc:
                                lyric_mod.inject_to_flac(fp, lrc, tlrc)
                                lc = len([l for l in lrc.split('\n') if l.strip()])
                                print(f"    ✅ [{src}] {f} - {lc}行")
                            else:
                                print(f"    ❌ {f} - 未找到歌词")
                        except Exception as e:
                            print(f"    ⚠️ {f} - {e}")

    # Summary
    total_flacs = len([f for r, d, files in os.walk(album_dir)
                       for f in files if f.endswith('.flac')])
    print(f"  📊 Result: {total_flacs} FLACs")
    return True


def main():
    parser = argparse.ArgumentParser(
        description='Convert CD audio archives (RAR/WAV+CUE) into FLAC files.',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  %(prog)s /path/to/album/dir
  %(prog)s /path/to/album/dir --dry-run
  %(prog)s /path/to/album/dir --keep-wav --delete-rar
  %(prog)s /path/to/multi/album/root
        """)
    parser.add_argument('directory', help='Directory containing .rar or .wav/.cue files')
    parser.add_argument('--dry-run', action='store_true',
                        help='Preview what would be done without making changes')
    parser.add_argument('--keep-wav', action='store_true',
                        help='Keep original WAV files after conversion')
    parser.add_argument('--keep-cue', action='store_true',
                        help='Keep CUE files after conversion')
    parser.add_argument('--delete-rar', action='store_true',
                        help='Delete RAR archives after successful extraction')
    parser.add_argument('--recursive', '-r', action='store_true',
                        help='Process subdirectories as separate albums')
    parser.add_argument('--lyrics', action='store_true',
                        help='Auto-fetch synced lyrics from Netease/Kugou and inject into FLAC')
    parser.add_argument('--lyrics-only', action='store_true',
                        help='Only inject lyrics into existing FLACs, skip conversion')

    args = parser.parse_args()

    # Check dependencies
    if not check_dependencies():
        sys.exit(1)

    directory = os.path.abspath(args.directory)
    if not os.path.isdir(directory):
        print(f"❌ Directory not found: {directory}")
        sys.exit(1)

    if args.dry_run:
        print("🔍 DRY RUN - no changes will be made\n")

    # ── Lyrics-only mode: inject into existing FLACs ──
    if args.lyrics_only:
        print("🎵 Lyrics-only mode: injecting lyrics into existing FLACs...\n")
        lyric_mod = _get_lyric_module()
        if lyric_mod:
            lyric_mod.process_directory(directory, dry_run=args.dry_run)
        else:
            print("❌ Lyric module not available (needs pycryptodome + mutagen)")
        return

    if args.recursive:
        # Process each subdirectory
        albums = sorted([d for d in os.listdir(directory)
                         if os.path.isdir(os.path.join(directory, d))])
        for album in albums:
            album_path = os.path.join(directory, album)
            process_album(album_path, None, args)
    else:
        # Process RAR files and leftover WAV+CUE in this directory
        rars = get_rar_files(directory)
        if rars:
            print(f"📀 Found {len(rars)} album archive(s) in {directory}")
            success = 0
            for rar in rars:
                if process_album(directory, rar, args):
                    success += 1
            print(f"\n{'='*60}")
            print(f"✅ Done: {success}/{len(rars)} albums processed successfully")
        else:
            # No RARs - process any existing WAV+CUE pairs directly
            pairs = find_wav_cue_pairs(directory)
            if pairs:
                print(f"🎵 Found {len(pairs)} WAV+CUE pair(s)")
                # Determine if multi-CD
                if len(pairs) > 1:
                    for i, (wav, cue) in enumerate(pairs, 1):
                        cd_dir = os.path.join(directory, f"CD{i}")
                        os.makedirs(cd_dir, exist_ok=True)
                        process_single_cd(wav, cue, cd_dir, args.dry_run)
                        if not args.dry_run:
                            if not args.keep_wav:
                                os.remove(wav)
                            if not args.keep_cue:
                                os.remove(cue)
                else:
                    wav, cue = pairs[0]
                    process_single_cd(wav, cue, directory, args.dry_run)
                    if not args.dry_run:
                        if not args.keep_wav:
                            os.remove(wav)
                        if not args.keep_cue:
                            os.remove(cue)
                print(f"\n✅ Processing complete")
            else:
                print(f"ℹ️  No RAR archives or WAV+CUE pairs found in {directory}")


if __name__ == "__main__":
    main()


path: scripts/lyric.py
content: #!/usr/bin/env python3
"""
Lyric provider for FLAC files using Netease Cloud Music (primary) and Kugou (fallback).

Usage:
    python3 lyric.py "/path/to/album"           # Inject lyrics into all FLACs
    python3 lyric.py "/path/to/song.flac"       # Inject lyrics into one FLAC
    python3 lyric.py "/path/to/album" --dry-run # Preview only
"""

import os, sys, re, json, hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import urllib.request, urllib.parse

# ── Netease Cloud Music (linuxapi) ──────────────────────────────────

LINUXAPI_KEY = b'rFgB&h#%2?^eDg:Q'
ANON_TOKEN = ("bf8bfeabb1aa84f9c8c3906c04a04fb864322804c83f5d607e91a04eae463c9436"
              "bd1a17ec353cf780b396507a3f7464e8a60f4bbc019437993166e004087dd32d14"
              "90298caf655c2353e58daa0bc13cc7d5c198250968580b12c1b8817e3f5c807e65"
              "0dd04abd3fb8130b7ae43fcc5b")

def _linuxapi_encrypt(params):
    text = json.dumps(params, separators=(',', ':')).encode('utf-8')
    cipher = AES.new(LINUXAPI_KEY, AES.MODE_ECB)
    return cipher.encrypt(pad(text, AES.block_size)).hex().upper()

def _linuxapi_req(params):
    data = urllib.parse.urlencode({"eparams": _linuxapi_encrypt(params)}).encode()
    req = urllib.request.Request(
        "https://music.163.com/api/linux/forward", data=data,
        headers={
            "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
            "Cookie": f"MUSIC_A={ANON_TOKEN}",
            "Referer": "https://music.163.com",
            "Content-Type": "application/x-www-form-urlencoded",
        })
    return json.loads(urllib.request.urlopen(req, timeout=15).read().decode('utf-8'))

def netease_search(title, artist):
    """Search Netease, return list of {id, name, artist, album}"""
    result = _linuxapi_req({
        "method": "POST",
        "url": "http://music.163.com/api/search/get",
        "params": {"s": f"{title} {artist}", "type": 1, "limit": 10, "offset": 0}
    })
    if result.get('code') != 200:
        return []
    return [{
        'id': s['id'],
        'name': s['name'],
        'artist': s.get('artists', [{}])[0].get('name', '') if s.get('artists') else '',
        'album': s.get('album', {}).get('name', ''),
    } for s in result.get('result', {}).get('songs', [])]

def netease_lyric(song_id):
    """Get LRC from Netease by song ID. Returns (lrc_text, translation_text)"""
    result = _linuxapi_req({
        "method": "POST",
        "url": "http://music.163.com/api/song/lyric",
        "params": {"id": song_id, "lv": -1, "kv": -1, "tv": -1}
    })
    lrc = result.get('lrc', {}).get('lyric', '')
    tlrc = result.get('tlyric', {}).get('lyric', '')
    return lrc, tlrc

# ── Kugou (fallback) ───────────────────────────────────────────────

KUGOU_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15"

def kugou_search(title, artist):
    """Search Kugou, return list of {hash, name, artist}"""
    encoded = urllib.parse.quote(f"{artist} {title}")
    req = urllib.request.Request(
        f"http://mobilecdn.kugou.com/api/v3/search/song?format=json&keyword={encoded}&page=1&pagesize=5",
        headers={"User-Agent": KUGOU_UA})
    resp = urllib.request.urlopen(req, timeout=15)
    data = json.loads(resp.read())
    songs = []
    if data.get('status') == 1 and data.get('data', {}).get('info'):
        for s in data['data']['info']:
            songs.append({
                'hash': s['hash'],
                'name': s['songname'],
                'artist': s.get('singername', ''),
            })
    return songs

def kugou_lyric(song_hash):
    """Get LRC from Kugou"""
    req = urllib.request.Request(
        f"http://m.kugou.com/app/i/krc.php?cmd=100&hash={song_hash}&timelength=999999",
        headers={"User-Agent": KUGOU_UA})
    resp = urllib.request.urlopen(req, timeout=15)
    raw = resp.read().decode('utf-8', errors='replace')
    lines = [l for l in raw.split('\n') if re.match(r'^\[\d{2}:\d{2}\.\d{2}', l)]
    return '\n'.join(lines)

# ── Lyric matching ─────────────────────────────────────────────────

def _normalize(s):
    """Normalize for matching: lowercase, remove punctuation, extras"""
    if not s:
        return ''
    s = s.lower()
    s = re.sub(r'[\(\)\[\]\{\}（）【】「」,.《》「」『』、：；！？…·\-\s]', '', s)
    return s.strip()

def _pick_best_match(songs, target_title, target_artist):
    """Pick the best matching song from a list"""
    t_title = _normalize(target_title)
    t_artist = _normalize(target_artist) if target_artist else ''
    
    if not songs:
        return None
    
    # Pass 1: exact title + artist match
    for s in songs:
        if _normalize(s['name']) == t_title:
            if t_artist and _normalize(s['artist']) and _normalize(s['artist']) in t_artist:
                return s
    
    # Pass 2: title contains target or vice versa
    for s in songs:
        s_name = _normalize(s['name'])
        if s_name == t_title:
            return s
    
    # Pass 3: any match
    for s in songs:
        if t_title in _normalize(s['name']) or _normalize(s['name']) in t_title:
            return s
    
    return songs[0]  # last resort

# ── Main API ───────────────────────────────────────────────────────

def clean_lrc(lrc_text):
    """Extract only timestamped lines from LRC text"""
    lines = []
    for l in lrc_text.strip().split('\n'):
        l = l.strip().rstrip('\r')
        if re.match(r'^\[\d{2}:\d{2}\.\d{2}', l):
            lines.append(l)
    return '\n'.join(lines)

def _infer_artist_from_dir(filepath):
    """Try to infer artist name from directory path"""
    dirname = os.path.basename(os.path.dirname(os.path.abspath(filepath)))
    # Common patterns: "Artist - Album", "Artist/Album"
    # Check if dirname has Chinese characters that look like an artist name
    parts = dirname.split(' - ')
    if len(parts) >= 2:
        return parts[0].strip()
    # Extract known artists from common patterns (艺名 in brackets before year)
    m = re.match(r'^\d{4}-(.+?)\[', dirname)
    if m:
        candidate = m.group(1).strip()
        # If it's not too long, likely an artist name
        if len(candidate) < 20:
            return candidate
    return dirname

def _try_search(title, artist):
    """Try Netease then Kugou with given title+artist"""
    if not title:
        return None, None, None
    
    # Netease
    songs = netease_search(title, artist)
    best = _pick_best_match(songs, title, artist)
    if best:
        lrc, tlrc = netease_lyric(best['id'])
        cleaned = clean_lrc(lrc)
        if cleaned.strip():
            return cleaned, "netease", clean_lrc(tlrc)
    
    # Kugou fallback
    songs = kugou_search(title, artist)
    best = _pick_best_match(songs, title, artist)
    if best:
        lrc = kugou_lyric(best['hash'])
        if lrc.strip():
            return lrc, "kugou", ""
    
    return None, None, None

def get_lyrics(title, artist, album=None, flac_path=None):
    """
    Get lyrics for a song with multi-strategy search.
    Returns (lrc_text, source_name, translation_text) or (None, None, None).
    """
    # Normalize empty artist
    artist = (artist or '').strip()
    if artist in ('', '?', 'unknown', 'Unknown'):
        artist = ''
    
    # Strategy 1: title + artist (most precise)
    if artist:
        result = _try_search(title, artist)
        if result[0]:
            return result
    
    # Strategy 2: title + inferred artist from directory
    if not artist and flac_path:
        inferred = _infer_artist_from_dir(flac_path)
        if inferred:
            result = _try_search(title, inferred)
            if result[0]:
                return result
    
    # Strategy 3: title only (broad search, works for popular songs)
    result = _try_search(title, '')
    if result[0]:
        return result
    
    return None, None, None

def inject_to_flac(flac_path, lrc_text, translation_text=''):
    """
    Inject lyrics into a FLAC/DSF file.
    FLAC: Vorbis comment LYRICS tag.
    DSF: ID3 USLT frame.
    """
    from mutagen.flac import FLAC
    from mutagen.dsf import DSF
    from mutagen.id3 import USLT, ID3
    if flac_path.endswith('.dsf'):
        # DSF 只生成 .lrc 文件，不修改文件本体（避免损坏 DSF 头）
        lrc_path = os.path.splitext(flac_path)[0] + '.lrc'
        with open(lrc_path, 'w', encoding='utf-8') as f:
            f.write(lrc_text)
        # 验证歌词文件
        print(f"  写入 .lrc: {os.path.basename(lrc_path)} ({len(lrc_text)}字符)")
    else:
        audio = FLAC(flac_path)
        audio['LYRICS'] = lrc_text
        if translation_text and translation_text.strip():
            audio['LYRICS_TRANSLATIONS'] = translation_text
        audio.save()

# ── CLI entry point ────────────────────────────────────────────────

def process_file(flac_path, dry_run=False):
    """Process one FLAC/DSF file: read tags, fetch lyrics, inject"""
    from mutagen.flac import FLAC
    from mutagen.dsf import DSF
    audio = DSF(flac_path) if flac_path.endswith('.dsf') else FLAC(flac_path)
    title = audio.get('title', [''])[0]
    artist = audio.get('artist', [''])[0]
    album = audio.get('album', [''])[0]
    
    if not title:
        title = os.path.splitext(os.path.basename(flac_path))[0]
        m = re.match(r'\d+\s*-\s*(.*)', title)
        if m:
            title = m.group(1).strip()
    
    print(f"🎵 {os.path.basename(flac_path)}")
    print(f"   标签: '{artist}' - '{title}' [{album}]")
    
    lrc, src, tlrc = get_lyrics(title, artist, album, flac_path)
    if not lrc:
        print("   ❌ 未找到歌词")
        return False
    
    line_count = len([l for l in lrc.split('\n') if l.strip()])
    has_tlrc = f" +翻译({len(tlrc.split())}行)" if tlrc.strip() else ''
    print(f"   ✅ [{src}] {line_count}行歌词{has_tlrc}")
    
    if not dry_run:
        inject_to_flac(flac_path, lrc, tlrc)
    
    return True

def process_directory(directory, dry_run=False):
    """Process all FLAC files in a directory"""
    audio_files = sorted([f for f in os.listdir(directory) if f.endswith(('.flac','.dsf'))])
    if not audio_files:
        print(f"ℹ️  没有音频文件在: {directory}")
        return
    
    if dry_run:
        print("🔍 DRY RUN - 仅预览\n")
    
    success = 0
    for f in audio_files:
        if process_file(os.path.join(directory, f), dry_run):
            success += 1
        print()
    
    print(f"{'='*50}")
    print(f"✅ {success}/{len(audio_files)} 增加歌词成功")

def main():
    if len(sys.argv) < 2:
        print(__doc__)
        sys.exit(1)
    
    path = os.path.abspath(sys.argv[1])
    dry_run = '--dry-run' in sys.argv
    
    if not os.path.exists(path):
        print(f"❌ 路径不存在: {path}")
        sys.exit(1)
    
    if os.path.isfile(path) and path.endswith('.flac'):
        lang = process_file(path, dry_run)
    elif os.path.isdir(path):
        process_directory(path, dry_run)
    else:
        print(f"❌ 不支持的文件类型")
        sys.exit(1)

if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)