#!/usr/bin/env python3
"""
build_index.py — scan a music library (recursively, unlimited depth) and
generate everything the yzyworks.dc.html album viewer needs, straight from
the song files. Tracks are NOT copied — index.md references them in place.

    python3 build_index.py ../PEEZYYORN
    python3 build_index.py /YZY/PEEZYYORN --out .   # project root (default: cwd)

What it does, all automatically:
  * walks the music dir at UNLIMITED DEPTH (every sub-folder) collecting audio
  * reads each song's TAGS   -> title / album / year / track number
  * reads YZYWORKS_LVP_TAGS  -> dedicated tag field for AI_V, LL, UR, SL, CC
                                 (comma/period separated, e.g. "AI_V, UR, LL";
                                 a repeated field counts as several tags)
                                 On MP3 this is a TXXX frame whose description is
                                 the field name; on FLAC a plain Vorbis comment.
                                 The name is also accepted singular, and as
                                 YZYWORKS_TAGS / LVP_TAGS - see LVP_TAG_KEYS.
                                 Fallback: the old COMMENT / DESCRIPTION field for
                                 "Unreleased" / "Studio leak"
  * groups songs into albums by their ALBUM tag (folder name is a fallback)
  * extracts EMBEDDED COVER ART out of the songs and writes it to
    covers/<album>.png  (no separate cover file needed)
  * also extracts the BACK COVER when a song carries one (picture type 4) and
    writes covers/<album>-back.<ext>, referenced from index.md as `back:`. The
    viewer uses it for the cover flip / 3D vinyl; releases without one get a
    blurred front instead, so it is entirely optional.
  * writes index.md whose track/cover links point AT THE FILES WHERE THEY LIVE,
    as a clean relative path from --out to the source. Point the script at
    ../PEEZYYORN while --out is /YZY/new and links come out as
    ../PEEZYYORN/...  (i.e. /YZY/PEEZYYORN/... once served), never doubled up
    like new/PEEZYYORN/../PEEZYYORN or new/tracks/...

Supported inputs: FLAC (.flac / .flac.bin) and MP3 (.mp3) with no third-party
libraries required — the tag + picture parsers are built in. If `mutagen`
happens to be installed it is used for wider format support, but it's optional.

Album name and year come from the song tags first; the containing folder name
is only a fallback. Cover art is pulled from the first song that carries a
picture, so you never hand-place a cover; the reverse of the sleeve comes from
the first picture tagged as a BACK cover, or from a file named back.jpg (rear /
reverse / bside also work) in the album folder. A file named `lyrics` (any
extension) anywhere in an album's folders marks that album's "word-by-word
lyrics" flag; otherwise the flag is carried forward from an existing index.md.
"""

import os
import re
import sys
import io
import time
import struct
import shutil
import secrets
import argparse
import subprocess
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import quote

# ---------------------------------------------------------------- optional mutagen
try:
    import mutagen  # noqa
    from mutagen import File as MutagenFile
    HAVE_MUTAGEN = True
except Exception:
    HAVE_MUTAGEN = False

# ---------------------------------------------------------------- optional image downscaler
# Homepage grid gets a small thumbnail; album view keeps the full-res art. Pillow
# is used if present, otherwise ffmpeg (found on PATH); with neither, the full
# image is used for the grid too (no thumbnail is written).
try:
    from PIL import Image
    HAVE_PIL = True
except Exception:
    HAVE_PIL = False

FFMPEG = shutil.which('ffmpeg')
THUMB_MAX = 600  # longest-edge pixels for homepage grid thumbnails

# Tag reads are I/O-bound and Pillow's resize/encode releases the GIL, so a
# thread pool scales both phases across cores without pickling multi-MB image
# blobs between processes. Default to every core; override with --workers.
WORKERS = min(32, (os.cpu_count() or 4) * 2)


class Progress:
    """Minimal thread-safe stderr progress bar — no dependencies. Renders a
    single rewritten line: [####----] 40/100  label  (rate, eta)."""

    def __init__(self, total, label='', width=32):
        self.total = max(total, 1)
        self.label = label
        self.width = width
        self.done = 0
        self.start = time.monotonic()
        self.lock = threading.Lock()
        self.tty = sys.stderr.isatty()
        self._draw()

    def step(self, n=1):
        with self.lock:
            self.done += n
            self._draw()

    def _draw(self):
        frac = self.done / self.total
        filled = int(self.width * frac)
        bar = '#' * filled + '-' * (self.width - filled)
        elapsed = time.monotonic() - self.start
        rate = self.done / elapsed if elapsed > 0 else 0
        eta = (self.total - self.done) / rate if rate > 0 else 0
        msg = '\r[%s] %d/%d  %s  (%.0f/s, eta %2ds)' % (
            bar, self.done, self.total, self.label, rate, eta)
        if self.tty:
            sys.stderr.write(msg)
            sys.stderr.flush()

    def close(self):
        with self.lock:
            if self.tty:
                sys.stderr.write('\r\033[K')  # clear the line
                sys.stderr.flush()

AUDIO_EXTS = ('.flac', '.flac.bin', '.mp3')
SLUG_RE = re.compile(r'[^a-z0-9]+')
SKIP_DIRS = {'covers', 'tracks', '.git', '.tmp', 'node_modules'}
UNKNOWN_ALBUM = 'Singles & Unsorted'
IMAGE_EXTS = ('.jpg', '.jpeg', '.png', '.webp', '.gif')
COVER_STEMS = {'cover', 'folder', 'front', 'album', 'artwork', 'art', 'thumb', 'thumbnail'}

# When a song carries no embedded art, look for a cover image sitting in the
# album's folder. These stems (case-insensitive) win; otherwise the first image
# in the folder is used. Referenced in place, never copied (same rule as tracks).
COVER_STEMS = ('cover', 'folder', 'front', 'artwork', 'album', 'albumart', 'thumb', 'thumbnail')
# Same idea for the reverse of the sleeve. Unlike the front there is no
# "first image wins" fallback: a back cover is only ever a file that SAYS it is
# one, because the alternative is silently printing a random photo on the back of
# the record. When nothing here matches, the viewer blurs the front instead.
BACK_STEMS = ('back', 'backcover', 'back cover', 'back-cover', 'back_cover',
              'rear', 'reverse', 'behind', 'bside', 'b-side')
IMAGE_EXTS = ('.jpg', '.jpeg', '.png', '.webp', '.gif')

# ---------------------------------------------------------------- category tags
# The per-track category tags (AI_V / LL / UR / SL / CC) come from a dedicated tag
# field, hand-written in whatever tagger the file passed through. Which SPELLING of
# that field arrives is not stable: Vorbis/FLAC stores the name verbatim
# (YZYWORKS_LVP_TAGS), ID3 wraps it in a TXXX frame whose *description* is the name,
# and the field has been typed both plural and singular. Accepting exactly one
# spelling is how a whole library indexes with zero tags, so take every spelling
# that has been seen. First key that carries a value wins.
LVP_TAG_KEYS = ('yzyworks_lvp_tags', 'yzyworks_lvp_tag',
                'yzyworks_tags', 'yzyworks_tag',
                'lvp_tags', 'lvp_tag')
LVP_KEY_SET = frozenset(LVP_TAG_KEYS)

# ...and the SEPARATORS inside the field NAME are no more stable than the
# spelling. A GUI tagger's "add field" box gets "YZYWORKS LVP TAGS" typed into it
# as readily as the underscored form, and MusicBrainz-flavoured tools like hyphens
# ("YZYWORKS-LVP-TAGS"). Every one of those missed an exact-match lookup and
# indexed the track as carrying no tags at all, so the name is folded the same way
# parse_tag_list() folds the VALUE before it is compared.
_KEY_SEP_RE = re.compile(r'[^a-z0-9]+')


def lvp_key(name):
    """The canonical LVP field name for a raw tag key, or None if it is not one."""
    k = _KEY_SEP_RE.sub('_', (name or '').strip().lower()).strip('_')
    return k if k in LVP_KEY_SET else None

# A field that REPEATS means several tags, not "last value wins": three separate
# YZYWORKS_LVP_TAGS lines in a Vorbis comment block, or one TXXX frame holding
# three null-separated strings, is a three-tag track. Only these keys accumulate;
# every other tag keeps last-value-wins, where a repeat is a duplicate not a list.
MULTI_VALUE_KEYS = frozenset(LVP_TAG_KEYS)

# What files actually say, mapped onto the five tags the viewer knows about. The
# right-hand side is the canonical set; 'NR', 'LEAK' and 'STUDIO' are this project's
# own older spellings (the viewer still carries display fallbacks for the last two).
# Anything unlisted passes through as typed, so a tag invented today renders as a
# raw pill instead of disappearing.
TAG_ALIASES = {'AIV': 'AI_V', 'AI_VOCALS': 'AI_V', 'AI_VOCAL': 'AI_V',
               'LOSSLESS': 'LL',
               'UNRELEASED': 'UR', 'NOT_RELEASED': 'UR', 'NR': 'UR',
               'LEAK': 'UR', 'LEAKED': 'UR',
               'STUDIO': 'SL', 'STUDIO_LEAK': 'SL',
               'CONCERT': 'CC', 'LISTENING_PARTY': 'CC', 'LP': 'CC'}

# The five canonical tags plus every alias: "a token this project recognises".
# Used to decide whether a field typed with SPACES between tags ("LL UR AI_V") is
# a list or a single multi-word tag - see parse_tag_list.
KNOWN_TAGS = frozenset(TAG_ALIASES) | {'AI_V', 'LL', 'UR', 'SL', 'CC'}

# Tags are emitted into index.md as "[AI_V]" and read back there by a character
# class, so a tag has to survive that round trip: fold everything else into
# underscores. This is also what turns a field typed "studio leak" into STUDIO_LEAK,
# which TAG_ALIASES then resolves to SL.
_TAG_CHARS_RE = re.compile(r'[^A-Z0-9_]+')


def parse_tag_list(raw):
    """Split a tag field into canonical tags, in the order written, no repeats.

    Separators are comma, period, semicolon, slash, pipe and any newline/tab -
    the period is in there because the field gets typed "AI_V.UR.LL" about as
    often as with commas. A plain SPACE is deliberately not a separator, so
    "studio leak" stays one tag (-> SL) instead of splitting into two unknowns.

    A space-separated run is split only when folding it WHOLE does not name a tag
    this project knows, and every word in it does: "LL UR AI_V" is unambiguously
    three tags, and folding it whole gave the single unknown pill LL_UR_AI_V.
    "studio leak" keeps its meaning because STUDIO_LEAK is itself a known alias
    (-> SL), which is checked first and wins - same for "AI vocals" -> AI_V."""
    out = []
    for part in re.split(r'[,.;/|\r\n\t]+', raw or ''):
        for tok in _split_spaced(part):
            tok = TAG_ALIASES.get(tok, tok)
            if tok not in out:
                out.append(tok)
    return out


def _split_spaced(part):
    """One comma-delimited chunk as canonical token(s). Returns a list because a
    chunk of all-known words is several tags; anything else is exactly one."""
    whole = _TAG_CHARS_RE.sub('_', part.strip().upper()).strip('_')
    if not whole:
        return []
    if whole in KNOWN_TAGS:
        return [whole]
    words = [w for w in (_TAG_CHARS_RE.sub('_', x.upper()).strip('_')
                         for x in part.split()) if w]
    if len(words) > 1 and all(w in KNOWN_TAGS for w in words):
        return words
    return [whole]


# Where the pre-field spelling lived: a free-text comment reading "Unreleased" or
# "Studio leak". Both key names are needed because the containers disagree about
# which one a "comment" is - Vorbis/FLAC has DESCRIPTION as well as COMMENT, and
# ffmpeg writes `-metadata comment=` into DESCRIPTION, so reading only 'comment'
# missed the fallback on every ffmpeg-tagged FLAC in the library.
COMMENT_KEYS = ('comment', 'description')


def lvp_tags_of(tags):
    """A track's category tags, read from whichever spelling of the dedicated
    field the file carries. Falls back to the free-text Comment field, which is
    where 'Unreleased' / 'Studio leak' lived before the field existed."""
    for k in LVP_TAG_KEYS:
        raw = (tags.get(k) or '').strip()
        if raw:
            return parse_tag_list(raw)
    # 'studio leak' is checked first and wins outright: it is the more specific
    # claim, and a leak description very often says "unreleased" in the same
    # breath ("unreleased, studio leak"), which would otherwise report UR.
    comment = ' '.join((tags.get(k) or '') for k in COMMENT_KEYS).lower()
    if 'studio leak' in comment:
        return ['SL']
    if 'unreleased' in comment:
        return ['UR']
    return []


def slugify(s):
    return SLUG_RE.sub('-', s.lower()).strip('-') or 'album'


def is_audio(name):
    n = name.lower()
    return any(n.endswith(e) for e in AUDIO_EXTS)


def read_indexer_rules(out_dir):
    """Header tag lines (name, library-last-update, ...) from LVP_IndexerRules in
    the output folder, copied verbatim above the first '---' of index.md. That is
    machine-facing metadata for the indexer only: the viewer's parser skips every
    line before the first '---', so these tags never reach the LVP website. A
    missing file (the other forks ship none) just means an empty list."""
    try:
        with open(os.path.join(out_dir, 'LVP_IndexerRules'), encoding='utf-8') as f:
            return [l.strip() for l in f if l.strip()]
    except OSError:
        return []


def read_existing_lyrics(index_path):
    """Map album name -> lyrics bool from an existing index.md so the
    'word-by-word lyrics' option survives a reindex (audio files don't
    carry it)."""
    out, name = {}, None
    try:
        with open(index_path, encoding='utf-8') as f:
            for raw in f:
                line = raw.strip()
                if line.startswith('> ') and not line.startswith('> !['):
                    name = line[2:].strip()
                elif name and line.lower().startswith('lyrics:'):
                    out[name] = line.split(':', 1)[1].strip().lower() in ('yes', 'true', '1', 'on')
    except OSError:
        pass
    return out


# ================================================================ FLAC parsing
def _flac_start(data):
    """Offset of the 'fLaC' marker, or -1. Plenty of taggers PREPEND an ID3v2 tag
    to a FLAC file (foobar2000 and ffmpeg both can), which is legal enough that
    players cope - but it moves the marker off byte 0, so an `data[:4] != b'fLaC'`
    check declared the file unparseable and every Vorbis comment in it, category
    tags included, was silently dropped."""
    if data[:4] == b'fLaC':
        return 0
    if data[:3] == b'ID3' and len(data) >= 10:
        size = ((data[6] & 0x7F) << 21) | ((data[7] & 0x7F) << 14) | \
               ((data[8] & 0x7F) << 7) | (data[9] & 0x7F)
        off = 10 + size
        if data[off:off + 4] == b'fLaC':
            return off
    # last resort: the marker within the first few KB (a tag whose declared size
    # disagrees with its real length, which happens with footers).
    off = data.find(b'fLaC', 0, 65536)
    return off if off >= 0 else -1


def _flac_blocks(data):
    """Yield (block_type, payload) for each metadata block in a FLAC file."""
    off = _flac_start(data)
    if off < 0:
        return
    off += 4
    while off + 4 <= len(data):
        header = data[off]
        last = header & 0x80
        btype = header & 0x7F
        length = int.from_bytes(data[off + 1:off + 4], 'big')
        payload = data[off + 4:off + 4 + length]
        yield btype, payload
        off += 4 + length
        if last:
            break


def _parse_vorbis_comment(payload):
    """Return a dict of lowercased Vorbis comment fields (last value wins, except
    for MULTI_VALUE_KEYS, whose repeats are joined - a tag field written as three
    separate YZYWORKS_LVP_TAGS entries is three tags, and keeping only the last
    one silently indexed a track as carrying a single tag)."""
    tags = {}
    p = 0
    vlen = struct.unpack('<I', payload[p:p + 4])[0]; p += 4 + vlen
    count = struct.unpack('<I', payload[p:p + 4])[0]; p += 4
    for _ in range(count):
        if p + 4 > len(payload):
            break
        clen = struct.unpack('<I', payload[p:p + 4])[0]; p += 4
        entry = payload[p:p + clen].decode('utf-8', 'replace'); p += clen
        if '=' in entry:
            k, v = entry.split('=', 1)
            k = k.strip().lower()
            # a field name typed "YZYWORKS LVP TAGS" is the same field; fold it
            # so the rest of the pipeline sees one spelling (see lvp_key).
            k = lvp_key(k) or k
            if k in MULTI_VALUE_KEYS and tags.get(k):
                tags[k] = tags[k] + ', ' + v
            else:
                tags[k] = v
    return tags


def _parse_flac_picture(payload):
    """Return (picture_type, mime, image_bytes) from a FLAC PICTURE block."""
    p = 0
    ptype = struct.unpack('>I', payload[p:p + 4])[0]; p += 4
    mlen = struct.unpack('>I', payload[p:p + 4])[0]; p += 4
    mime = payload[p:p + mlen].decode('ascii', 'replace'); p += mlen
    dlen = struct.unpack('>I', payload[p:p + 4])[0]; p += 4
    p += dlen  # description
    p += 16    # width, height, depth, colors
    ilen = struct.unpack('>I', payload[p:p + 4])[0]; p += 4
    return ptype, mime, payload[p:p + ilen]


# Embedded art carries a PICTURE TYPE — FLAC's PICTURE block and ID3's APIC frame
# share one enumeration, of which only two values matter here: 3 = front cover,
# 4 = back cover. A release with both gets a real two-sided sleeve in the viewer
# (the 3D vinyl / cover flip); with only a front, the page fakes the reverse by
# blurring the front, so a missing back is a soft failure and never a hard one.
PIC_FRONT, PIC_BACK = 3, 4


class ArtPicker:
    """Collects every embedded picture in one file, hands back (front, back).

    Ranking is the whole point, because plenty of files carry three or four
    pictures. A picture declared type-3 beats an untyped one for the front slot,
    while the back slot accepts ONLY a declared type-4: inferring "the second
    picture must be the reverse" would happily put a leaflet scan, a disc label
    or an artist photo on the back of the sleeve. The front slot still falls back
    to whatever picture came first, which is exactly the old
    first-PICTURE-block-wins behaviour, so nothing that used to find art stops."""

    def __init__(self):
        self.front = None        # (mime, bytes) or None
        self.back = None         # (mime, bytes) or None
        self._front_typed = False

    def add(self, ptype, mime, img):
        if not img:
            return
        try:
            ptype = int(ptype)
        except (TypeError, ValueError):
            ptype = 0
        if ptype == PIC_BACK:
            if self.back is None:
                self.back = (mime, img)
        elif self.front is None or (ptype == PIC_FRONT and not self._front_typed):
            self.front = (mime, img)
            self._front_typed = ptype == PIC_FRONT

    def result(self):
        return self.front, self.back


def read_flac(path):
    with open(path, 'rb') as f:
        data = f.read()
    tags, duration = {}, 0.0
    art = ArtPicker()
    for btype, payload in _flac_blocks(data):
        if btype == 0 and len(payload) >= 18:  # STREAMINFO — sample_rate + total_samples
            val = int.from_bytes(payload[10:18], 'big')
            sample_rate = (val >> 44) & 0xFFFFF
            total_samples = val & 0xFFFFFFFFF
            if sample_rate:
                duration = total_samples / sample_rate
        elif btype == 4:
            tags = _parse_vorbis_comment(payload)
        elif btype == 6:
            # Every PICTURE block is now read, not just the first: the back cover
            # is by definition a later one. A malformed block is skipped rather
            # than aborting the file — losing one picture beats losing the tags.
            try:
                art.add(*_parse_flac_picture(payload))
            except (struct.error, IndexError):
                pass
    if duration:
        tags.setdefault('duration', duration)
    front, back = art.result()
    return tags, front, back


# ================================================================ MP3 (ID3v2) parsing
# ID3 text frames start with an encoding byte. Decoding everything as UTF-8
# turns any UTF-16 frame (very common on Windows-tagged files) into mojibake,
# which matters doubly now that lyrics — the longest text we read — come from
# here. Encoding 1 carries a BOM; 2 is big-endian with none.
_ID3_ENCODINGS = {0: 'latin-1', 1: 'utf-16', 2: 'utf-16-be', 3: 'utf-8'}


def _id3_text(enc, raw):
    return raw.decode(_ID3_ENCODINGS.get(enc, 'utf-8'), 'replace')


def _id3_split_terminated(enc, raw):
    """Split raw into (first_string, rest) at the null terminator for this
    encoding. UTF-16 terminates on a 2-byte null that must sit on an even
    boundary, otherwise a character like 'a\\u0000' style byte pair can be
    mistaken for the end of the string."""
    if enc in (1, 2):
        i = 0
        while i + 1 < len(raw):
            if raw[i] == 0 and raw[i + 1] == 0:
                return _id3_text(enc, raw[:i]), raw[i + 2:]
            i += 2
        return _id3_text(enc, raw), b''
    i = raw.find(b'\x00')
    if i < 0:
        return _id3_text(enc, raw), b''
    return _id3_text(enc, raw[:i]), raw[i + 1:]


def _id3_multi(enc, raw):
    """Every string in a text frame's value. ID3v2.4 separates multiple values
    with the encoding's null terminator, and taggers write them that way on 2.3
    too, so a field holding three tags arrives as ONE frame with three strings.
    UTF-16 values each carry their own BOM, which decodes to U+FEFF once the
    buffer is split, hence stripping it here."""
    junk = '﻿ \t\r\n'
    return [s.strip(junk) for s in _id3_text(enc, raw).split('\x00') if s.strip(junk)]


def _parse_sylt(body):
    """SYLT carries synchronised text as repeated <text>\\0<timestamp> chunks.
    Rendered as standard [mm:ss.xx] LRC lines, which is exactly what the player
    parses, so synced MP3 lyrics survive the trip to .lrc."""
    if len(body) < 6:
        return ''
    enc = body[0]
    fmt = body[4]  # 1 = absolute ms, 2 = MPEG frames (we can only trust ms)
    rest = body[6:]
    lines = []
    while rest:
        text, rest = _id3_split_terminated(enc, rest)
        if len(rest) < 4:
            break
        stamp = int.from_bytes(rest[:4], 'big')
        rest = rest[4:]
        if fmt != 1:
            continue
        total = stamp / 1000.0
        text = text.replace('\n', ' ').replace('\r', ' ').strip()
        if not text:
            continue
        lines.append('[%02d:%05.2f]%s' % (int(total // 60), total % 60, text))
    return '\n'.join(lines)


def _id3_deunsync(raw):
    """Undo ID3 unsynchronisation: every 0xFF 0x00 pair is a literal 0xFF.

    A tagger that sets the unsynchronisation flag rewrites the tag so no byte run
    can be mistaken for an MPEG frame sync. Walking those bytes as-is shifts every
    offset after the first 0xFF, so the frame walk desyncs and the rest of the tag
    - which is where a TXXX field usually sits, after the big text frames - is
    lost. Silent: the file parses, it just reports no category tags."""
    out = bytearray()
    i, n = 0, len(raw)
    while i < n:
        out.append(raw[i])
        if raw[i] == 0xFF and i + 1 < n and raw[i + 1] == 0x00:
            i += 2
        else:
            i += 1
    return bytes(out)


def _id3_ext_header_len(version, body):
    """Bytes of extended header at the front of the tag body, 0 when there is none.

    The two layouts disagree about whether the size counts itself: v2.3 writes a
    plain 4-byte size EXCLUDING the size field, v2.4 a synchsafe size INCLUDING
    it. Skipping the wrong number of bytes starts the frame walk mid-header, where
    the first four bytes are not a frame id, so the walk stops immediately and the
    whole tag reads as empty."""
    if len(body) < 4:
        return 0
    if version >= 4:
        size = ((body[0] & 0x7F) << 21) | ((body[1] & 0x7F) << 14) | \
               ((body[2] & 0x7F) << 7) | (body[3] & 0x7F)
        return size if 6 <= size <= len(body) else 0
    size = int.from_bytes(body[:4], 'big')
    return 4 + size if 0 < size <= len(body) - 4 else 0


# A frame id is 4 (or, on ID3v2.2, 3) upper-case letters or digits. Used to sanity
# check the NEXT frame's header, which is how a buggy v2.4 tag - one whose frame
# sizes are plain big-endian instead of synchsafe, written by more taggers than
# anyone would like - is detected and re-read the other way round.
_ID3_FID_RE = re.compile(rb'^[A-Z0-9]+$')


def _id3_frames(data):
    """Yield (frame_id, body) for every frame in an ID3v2 tag, or nothing when
    `data` does not start with one. Handles v2.2 (3-byte ids and sizes), v2.3 and
    v2.4, the optional extended header, tag- and frame-level unsynchronisation,
    the v2.4 data-length indicator, and the group byte."""
    if data[:3] != b'ID3' or len(data) < 10:
        return
    version, flags = data[3], data[5]
    size = ((data[6] & 0x7F) << 21) | ((data[7] & 0x7F) << 14) | \
           ((data[8] & 0x7F) << 7) | (data[9] & 0x7F)
    body = data[10:10 + size]
    # On v2.2/v2.3 the flag unsynchronises the WHOLE tag; v2.4 moved it per frame.
    if flags & 0x80 and version < 4:
        body = _id3_deunsync(body)
    if flags & 0x40:
        body = body[_id3_ext_header_len(version, body):]

    idlen, szlen, flaglen = (3, 3, 0) if version == 2 else (4, 4, 2)
    hdr = idlen + szlen + flaglen
    p, end = 0, len(body)
    while p + hdr <= end:
        fid = body[p:p + idlen]
        if fid[:1] == b'\x00':
            # Padding. The spec puts it at the END of the tag, and the old walk
            # stopped here for that reason - but a tagger that rewrites a tag in
            # place can leave a zero gap in FRONT of the frames it wrote, and
            # stopping at the first zero byte then read the entire tag as empty.
            # Skip the run and carry on if real frames follow; a zero run that
            # reaches the end of the tag is ordinary trailing padding.
            while p < end and body[p] == 0:
                p += 1
            if p + hdr > end or not _ID3_FID_RE.match(body[p:p + idlen]):
                break
            continue
        if not _ID3_FID_RE.match(fid):
            break                      # a walk that has lost its place
        raw_size = body[p + idlen:p + idlen + szlen]
        if version >= 4:
            fsize = ((raw_size[0] & 0x7F) << 21) | ((raw_size[1] & 0x7F) << 14) | \
                    ((raw_size[2] & 0x7F) << 7) | (raw_size[3] & 0x7F)
            # A v2.4 tag whose sizes are really big-endian: trust whichever
            # reading lands on something that looks like the next frame header.
            plain = int.from_bytes(raw_size, 'big')
            if plain != fsize and 0 < plain <= end - p - hdr:
                nxt = body[p + hdr + fsize:p + hdr + fsize + idlen]
                if not _ID3_FID_RE.match(nxt) and nxt[:1] != b'\x00':
                    fsize = plain
        else:
            fsize = int.from_bytes(raw_size, 'big')
        if fsize <= 0 or p + hdr + fsize > end:
            break
        fflags = body[p + idlen + szlen:p + hdr] if flaglen else b'\x00\x00'
        fbody = body[p + hdr:p + hdr + fsize]
        p += hdr + fsize

        if version >= 4:
            # A compressed or encrypted frame's bytes are not text; skipping it
            # beats handing mojibake to the tag readers.
            if fflags[1] & 0x0C:
                continue
            if fflags[1] & 0x40:                       # group identity byte
                fbody = fbody[1:]
            if fflags[1] & 0x01:                       # data length indicator
                fbody = fbody[4:]
            if fflags[1] & 0x02:                       # per-frame unsynchronisation
                fbody = _id3_deunsync(fbody)
        elif version == 3:
            if fflags[1] & 0xC0:                       # compressed / encrypted
                continue
            if fflags[1] & 0x20:                       # group identity byte
                fbody = fbody[1:]
        if fbody:
            # v2.2 uses 3-letter ids for the same frames; map the ones read below
            # so one branch handles every version.
            yield _ID3V22_IDS.get(fid, fid), fbody


# ID3v2.2 spells the same frames with three letters. Files this old are rare but
# real (early iTunes rips), and an unmapped id means the frame is simply dropped.
_ID3V22_IDS = {b'TT2': b'TIT2', b'TAL': b'TALB', b'TYE': b'TDRC', b'TRK': b'TRCK',
               b'TP1': b'TPE1', b'TP2': b'TPE2', b'TPB': b'TPUB', b'TCR': b'TCOP',
               b'TXX': b'TXXX', b'COM': b'COMM', b'PIC': b'APIC', b'ULT': b'USLT',
               b'SLT': b'SYLT'}


def read_mp3(path):
    with open(path, 'rb') as f:
        data = f.read()
    if data[:3] != b'ID3':
        return {}, None, None
    tags = {}
    art = ArtPicker()
    for fid, body in _id3_frames(data):
        if fid == b'APIC':
            # encoding, mime\0, pic type, desc\0, image. Every APIC frame is read
            # (not just the first) so a type-4 back cover further down the tag is
            # still found; ArtPicker decides which slot each one lands in.
            try:
                enc = body[0]
                i = body.index(b'\x00', 1)
                mime = body[1:i].decode('ascii', 'replace')
                ptype = body[i + 1]
                _desc, img = _id3_split_terminated(enc, body[i + 2:])
                art.add(ptype, mime, img)
            except (ValueError, IndexError):
                pass
        elif fid == b'USLT' and not tags.get('unsyncedlyrics'):
            # encoding, 3-byte language, description\0, lyrics
            if len(body) > 4:
                _desc, rest = _id3_split_terminated(body[0], body[4:])
                text = _id3_text(body[0], rest).strip()
                if text:
                    tags['unsyncedlyrics'] = text
        elif fid == b'SYLT' and not tags.get('syncedlyrics'):
            text = _parse_sylt(body)
            if text:
                tags['syncedlyrics'] = text
        elif fid == b'TXXX' and len(body) > 1:
            # A USER-DEFINED text frame: encoding, description\0, value(s). The
            # description IS the field name, which is how a custom field like
            # YZYWORKS_LVP_TAGS reaches an MP3 at all - so this frame has to be
            # split before the generic T* branch below can see it. It used to fall
            # through to that branch, find no entry for b'TXXX' in the key map and
            # get dropped, which is why not one MP3 in the library ever indexed a
            # category tag while the same field read fine on FLAC.
            desc, rest = _id3_split_terminated(body[0], body[1:])
            key = desc.strip().lower()
            key = lvp_key(key) or key   # "YZYWORKS LVP TAGS" is the same field
            val = ', '.join(_id3_multi(body[0], rest))
            if key and val:
                if key in MULTI_VALUE_KEYS and tags.get(key):
                    tags[key] = tags[key] + ', ' + val   # repeated frame = more tags
                else:
                    tags[key] = val
        elif fid.startswith(b'T'):
            text = _id3_split_terminated(body[0], body[1:])[0].strip()
            key = {b'TIT2': 'title', b'TALB': 'album', b'TDRC': 'date',
                   b'TYER': 'date', b'TRCK': 'tracknumber',
                   b'TPE1': 'artist', b'TPE2': 'albumartist',
                   b'TPUB': 'organization', b'TCOP': 'copyright'}.get(fid)
            if key:
                tags[key] = text
        elif fid == b'COMM' and len(body) > 4:
            # encoding, 3-byte language, description\0, text. The text is NOT
            # null-terminated, but plenty of taggers write a trailing NUL anyway,
            # and str.strip() does not remove it - so it survived into the comment
            # and any exact-match read of it saw 'Studio leak\x00'.
            _desc, rest = _id3_split_terminated(body[0], body[4:])
            tags['comment'] = _id3_text(body[0], rest).strip('\x00 \t\r\n')
    front, back = art.result()
    return tags, front, back


# ================================================================ mutagen fallback
def _mutagen_one(v):
    """One mutagen value as text. MP4 freeform atoms ('----:com.apple.iTunes:X')
    hand back MP4FreeForm, a BYTES subclass, so str() on it yields the repr
    "b'UR'" - which folded to the tag B_UR instead of UR. Decode bytes rather
    than repr them; every other container already hands back str."""
    if isinstance(v, (bytes, bytearray)):
        return bytes(v).decode('utf-8', 'replace')
    return str(v)


def _mutagen_text(val):
    """Flatten whatever mutagen hands back for one key into a comma-joined
    string: a list of values, an ID3 frame carrying a .text list, or a bare
    value. Joining rather than taking [0] is the point for a multi-value tag
    field - three values there mean three tags."""
    if isinstance(val, (bytes, bytearray)):
        return _mutagen_one(val)          # bytes are iterable; never treat as a list
    if isinstance(val, (list, tuple)):
        return ', '.join(_mutagen_one(v) for v in val if _mutagen_one(v).strip())
    text = getattr(val, 'text', None)
    if isinstance(text, (list, tuple)):
        return ', '.join(_mutagen_one(v) for v in text if _mutagen_one(v).strip())
    return _mutagen_one(text if text is not None else val)


def read_mutagen(path):
    try:
        mf = MutagenFile(path)
    except Exception:
        return {}, None, None
    if mf is None:
        return {}, None, None
    tags = {}
    for k in ('title', 'album', 'date', 'tracknumber', 'comment', 'description',
              'artist', 'albumartist',
              'organization', 'label', 'publisher', 'copyright',
              'lyrics', 'syncedlyrics', 'unsyncedlyrics', 'unsyncedlyric'):
        val = mf.tags.get(k) if mf.tags else None
        if val:
            tags[k] = str(val[0] if isinstance(val, list) else val)
    # The category-tag field cannot be named in the whitelist above, because the
    # key it arrives under depends on the container: Vorbis/FLAC uses the field
    # name itself, ID3 uses 'TXXX:YZYWORKS_LVP_TAGS', MP4 buries it under an
    # '----:com.apple.iTunes:' prefix. Match on the LAST colon-separated segment
    # so all three land. Still worth doing now that read_mp3 parses TXXX itself:
    # mutagen is the only reader for containers the built-in parsers don't cover,
    # and on an ID3 file the whitelist loop above finds nothing at all (mutagen
    # keys ID3 by FRAME ID, so 'title' never matches TIT2).
    try:
        items = list(mf.tags.items()) if mf.tags else []
    except Exception:
        items = []
    for key, val in items:
        k = lvp_key(str(key).strip().lower().rsplit(':', 1)[-1])
        if not k:
            continue
        text = _mutagen_text(val).strip()
        if text:
            # a repeated key is more tags, not a replacement - same rule the
            # Vorbis and ID3 readers follow for MULTI_VALUE_KEYS.
            tags[k] = (tags[k] + ', ' + text) if tags.get(k) else text
    art = ArtPicker()
    pics = getattr(mf, 'pictures', None)
    if pics:
        for pic in pics:
            art.add(getattr(pic, 'type', 0), getattr(pic, 'mime', ''), getattr(pic, 'data', b''))
    elif mf.tags:
        # ID3 keys APIC frames as 'APIC:<description>', so the old bare 'APIC:'
        # lookup only ever matched a picture with an EMPTY description — a file
        # whose art was tagged 'APIC:Cover (front)' read as having none. Walk the
        # keys instead, which also picks up the back cover in its own frame.
        try:
            frames = list(mf.tags.items())
        except Exception:
            frames = []
        for key, frame in frames:
            if not str(key).startswith('APIC'):
                continue
            art.add(getattr(frame, 'type', 0), getattr(frame, 'mime', ''),
                    getattr(frame, 'data', b''))
    front, back = art.result()
    return tags, front, back


def read_apev2(path):
    """The category-tag field from an APEv2 tag, if the file carries one.

    APEv2 is a second, independent tag block that some taggers append to an MP3
    (and to a FLAC) alongside ID3. mutagen's File() picks ONE tag type per file and
    on an MP3 that is always ID3, so a field written only into the APEv2 block is
    invisible to every other reader here - the file parses, the field is simply not
    there. Returns {} when there is no APEv2 block, which is the common case."""
    if not HAVE_MUTAGEN:
        return {}
    try:
        from mutagen.apev2 import APEv2
    except ImportError:
        return {}
    try:
        ape = APEv2(path)
    except Exception:
        return {}                       # APENoHeaderError and friends: no block
    out = {}
    try:
        items = list(ape.items())
    except Exception:
        return {}
    for key, val in items:
        k = lvp_key(key)
        if not k:
            continue
        text = _mutagen_text(val).strip()
        if text:
            out[k] = (out[k] + ', ' + text) if out.get(k) else text
    return out


def read_song(path):
    name = os.path.basename(path).lower()
    if name.endswith('.flac') or name.endswith('.flac.bin'):
        tags, pic, back = read_flac(path)
    elif name.endswith('.mp3'):
        tags, pic, back = read_mp3(path)
    else:
        tags, pic, back = {}, None, None
    # A missing BACK cover is now also a reason to consult mutagen: the built-in
    # parsers only understand FLAC and MP3, so on any other container (or a file
    # whose art sits somewhere the hand-rolled walk does not reach) mutagen is the
    # only thing that can find the reverse.
    #
    # So is a missing CATEGORY-TAG field, and that one is worth spelling out: the
    # old condition asked only "did we get *any* tags?", so a file that yielded
    # album+artist but whose tag field sat in a shape the hand-rolled walk could
    # not reach (an extended header, an unsynchronised tag, a v2.4 data-length
    # indicator) was accepted as fully read and never re-checked. The result was a
    # track that indexed perfectly except for the one field this whole path exists
    # to carry. Asking mutagen costs a second parse of a file already in memory,
    # and only for files that appear to have no tags at all.
    if (not tags or pic is None or back is None or not lvp_tags_of(tags)) and HAVE_MUTAGEN:
        mt, mp, mb = read_mutagen(path)
        tags = {**mt, **tags}
        pic = pic or mp
        back = back or mb
    # Last resort for the tag field only: an APEv2 block, which no reader above can
    # see (mutagen's File() picks one tag type per file, and on an MP3 that is ID3).
    # Deliberately last and deliberately narrow - it is a second open() of the file,
    # so it only runs for a track that still has no category tags at all.
    if HAVE_MUTAGEN and not lvp_tags_of(tags):
        for k, v in read_apev2(path).items():
            tags.setdefault(k, v)
    return tags, pic, back


# ================================================================ helpers
def sniff_image_ext(data, mime):
    """Determine the real image extension from magic bytes, falling back to the
    declared MIME. Catches art that is e.g. WebP but tagged image/png — a strict
    server or browser will refuse to render a file whose extension lies about
    its content."""
    if data[:3] == b'\xff\xd8\xff':
        return '.jpg'
    if data[:8] == b'\x89PNG\r\n\x1a\n':
        return '.png'
    if data[:4] == b'RIFF' and data[8:12] == b'WEBP':
        return '.webp'
    if data[:6] in (b'GIF87a', b'GIF89a'):
        return '.gif'
    return {'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg',
            'image/webp': '.webp', 'image/gif': '.gif'}.get((mime or '').lower(), '.jpg')


def make_thumbnail(src_bytes, out_path, max_edge=THUMB_MAX):
    """Write a downscaled thumbnail of src_bytes to out_path. Returns True on
    success. Uses Pillow if available, else ffmpeg; if the source is already
    smaller than max_edge it's left as-is (caller reuses the full image)."""
    if HAVE_PIL:
        try:
            im = Image.open(io.BytesIO(src_bytes))
            if max(im.size) <= max_edge:
                return False
            im = im.convert('RGB') if im.mode not in ('RGB', 'L') else im
            im.thumbnail((max_edge, max_edge), Image.LANCZOS)
            im.save(out_path, 'JPEG', quality=82, optimize=True)
            return True
        except Exception:
            return False
    if FFMPEG:
        try:
            # scale longest edge to max_edge, preserve aspect, never upscale
            vf = ("scale='if(gt(iw,ih),min(%d,iw),-2)':"
                  "'if(gt(iw,ih),-2,min(%d,ih))'" % (max_edge, max_edge))
            r = subprocess.run(
                [FFMPEG, '-hide_banner', '-loglevel', 'error', '-y',
                 '-i', 'pipe:0', '-vf', vf, '-frames:v', '1', out_path],
                input=src_bytes, stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL)
            return r.returncode == 0 and os.path.getsize(out_path) > 0
        except Exception:
            return False
    return False


_ARTIST_SPLIT = re.compile(
    r'\s*(?:,|&|/|;|\bfeat\.?\b|\bft\.?\b|\bwith\b|\bvs\.?\b|\bx\b)\s*', re.I)


def split_artists(value):
    """Break a credit string into individual artist names.

    Tags carry whole credits per song ("Kanye West & Kid Cudi"), so treating a
    credit as one atomic name means the same person reappears in every variant
    they feature on. Splitting on the usual joiners first is what makes the
    dedupe below actually dedupe people rather than strings.
    """
    out = []
    for part in _ARTIST_SPLIT.split(value or ''):
        name = ' '.join(part.split())
        if name:
            out.append(name)
    return out


def album_author(songs):
    """Album credit: every distinct artist once, in first-seen order.

    Prefers albumartist tags when present (that is the curated answer), but
    still splits and dedupes them, since compilations often stamp a joined
    credit onto every track.
    """
    sources = [s['album_artist'] for s in songs if s.get('album_artist')]
    if not sources:
        sources = [s['artist'] for s in songs if s.get('artist')]
    seen = set()
    names = []
    for value in sources:
        for name in split_artists(value):
            key = name.casefold()
            if key in seen:
                continue
            seen.add(key)
            names.append(name)
    return ', '.join(names) if names else 'Unknown'


def clean_title(tags, filename):
    if tags.get('title'):
        return tags['title']
    base = re.sub(r'\.flac\.bin$', '', filename, flags=re.I)
    base = re.sub(r'\.[^.]+$', '', base)
    return re.sub(r'^\s*\d+\s*[-.]?\s*', '', base).strip() or filename


def track_no(tags, fallback):
    tn = str(tags.get('tracknumber', '')).split('/')[0]
    return int(tn) if tn.isdigit() else fallback


def year_of(tags):
    m = re.search(r'\d{4}', str(tags.get('date', '')))
    return m.group(0) if m else ''


# the label a release came out under lives under a different tag name depending on
# who tagged the file: Vorbis/FLAC conventionally uses ORGANIZATION, but plenty of
# taggers write LABEL or PUBLISHER instead, and some only leave a COPYRIGHT line.
# check them in descending order of how specific they are — COPYRIGHT is last
# because it is often a rights notice ("2020 Def Jam") rather than a bare label.
LABEL_KEYS = ('organization', 'label', 'publisher', 'copyright')


def label_of(tags):
    for k in LABEL_KEYS:
        v = (tags.get(k) or '').strip()
        if v:
            return v
    return ''


# duration normally comes from the FLAC STREAMINFO block as a float, but a file
# can also carry a literal DURATION tag written by some other tool, which arrives
# as a string. coerce here so the per-album sum can never hit str + float.
def duration_of(tags):
    try:
        return float(tags.get('duration', 0.0) or 0.0)
    except (TypeError, ValueError):
        return 0.0


# ================================================================ rename & LRC helpers
_ILLEGAL_CHARS_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')

def sanitize_filename_part(s):
    """Replace illegal filename characters with underscore, strip trailing dots
    and spaces (Windows), collapse repeated underscores."""
    s = _ILLEGAL_CHARS_RE.sub('_', s).strip()
    s = re.sub(r'_+', '_', s)
    return s.rstrip('. ')


def random_hex():
    return secrets.token_hex(8)


def split_audio_ext(name):
    """Return (stem, ext). Handles double extensions (.flac.bin) and preserves
    case. ext always starts with a dot if present."""
    lower = name.lower()
    for double in ('.flac.bin',):
        if lower.endswith(double):
            return name[:-len(double)], name[-len(double):]
    dot = name.rfind('.')
    return (name[:dot], name[dot:]) if dot > 0 else (name, '')


def extract_lyrics(tags):
    """Return the best lyrics text from tags, preferring synced over unsynced.
    Keys vary: FLAC uses syncedlyrics/unsyncedlyrics, MP3 uses our USLT/SYLT
    parsing. First synced hit wins, else first unsynced."""
    for k in ('syncedlyrics', 'lyrics'):
        v = tags.get(k, '').strip()
        if v:
            return v
    for k in ('unsyncedlyrics', 'unsyncedlyric'):
        v = tags.get(k, '').strip()
        if v:
            return v
    return ''


def lrc_path_for(audio_path):
    """Derive the .lrc sibling by stripping only the last extension, matching
    what the player does: x.flac.bin -> x.flac.lrc."""
    stem, ext = split_audio_ext(os.path.basename(audio_path))
    return os.path.join(os.path.dirname(audio_path), stem + '.lrc')


def write_lrc_file(audio_path, tags):
    """Write a .lrc file next to audio_path if tags carry lyrics. Returns True
    if a file was written. Always writes, even for plain untimestamped lyrics
    (user choice: saves ranged reads at the cost of displaying nothing for plain
    tracks, same as today)."""
    text = extract_lyrics(tags)
    if not text:
        return False
    lrc = lrc_path_for(audio_path)
    try:
        with open(lrc, 'w', encoding='utf-8') as f:
            f.write(text.replace('\r\n', '\n').replace('\r', '\n') + '\n')
        return True
    except OSError:
        return False


# Thread pool runs many _read calls concurrently; without a lock, two threads
# can both see "target free", try to rename to it, and one clobbers the other.
_rename_lock = threading.Lock()


def compute_rename_target(old_path, tags):
    """Return the new absolute path this audio file should have, or None if no
    rename is needed. Handles missing title/artist by generating random hex,
    reusing existing hex from the old filename when tags haven't changed, and
    appending hex suffixes to break collisions."""
    dname = os.path.dirname(old_path)
    old_base = os.path.basename(old_path)
    stem, ext = split_audio_ext(old_base)

    # extract what's already in the filename if it matches "A - B" or just "A"
    old_title, old_artist = None, None
    if ' - ' in stem:
        parts = stem.split(' - ', 1)
        old_title, old_artist = parts[0].strip(), parts[1].strip()
    else:
        old_title = stem.strip()

    # decide what the new title and artist should be
    title_tag = (tags.get('title') or '').strip()
    artist_tag = (tags.get('artist') or tags.get('albumartist') or '').strip()

    # if tag is missing, reuse the old hex if it looks like hex, else generate fresh
    def is_hex(s):
        return bool(s) and len(s) == 16 and all(c in '0123456789abcdef' for c in s.lower())

    if not title_tag:
        title_tag = old_title if is_hex(old_title) else random_hex()
    if not artist_tag:
        artist_tag = old_artist if is_hex(old_artist) else random_hex()

    title_clean = sanitize_filename_part(title_tag)
    artist_clean = sanitize_filename_part(artist_tag)
    if not title_clean:
        title_clean = random_hex()
    if not artist_clean:
        artist_clean = random_hex()

    new_base = '%s - %s%s' % (title_clean, artist_clean, ext)
    new_path = os.path.join(dname, new_base)

    # already correct?
    if os.path.samefile(old_path, new_path) if os.path.exists(new_path) else (old_path == new_path):
        return None

    # collision? append hex suffix to the stem (before the extension)
    if os.path.exists(new_path):
        attempt = 0
        while attempt < 100:
            suffix = random_hex()[:8]
            new_base = '%s - %s %s%s' % (title_clean, artist_clean, suffix, ext)
            new_path = os.path.join(dname, new_base)
            if not os.path.exists(new_path):
                break
            attempt += 1
        else:
            return None  # give up after 100 collisions

    return new_path


def rename_audio_with_lrc(old_path, new_path):
    """Rename the audio file and its sibling .lrc if one exists. Thread-safe."""
    with _rename_lock:
        if os.path.exists(new_path):
            return False  # another thread won the race
        os.rename(old_path, new_path)
        old_lrc = lrc_path_for(old_path)
        new_lrc = lrc_path_for(new_path)
        if os.path.isfile(old_lrc):
            try:
                os.rename(old_lrc, new_lrc)
            except OSError:
                pass
        return True


def rel_url(src_abs, out_dir):
    """Clean relative link from out_dir to a file that lives at src_abs, URL-encoded.
    e.g. out=/YZY/new, src=/YZY/PEEZYYORN/a/01.flac -> ../PEEZYYORN/a/01.flac"""
    rel = os.path.relpath(src_abs, out_dir)
    return quote(rel.replace(os.sep, '/'))


def find_cover_file(dirs):
    """Look through an album's folders for a cover image file and return its
    absolute path, or None. A file whose stem is in COVER_STEMS wins; otherwise
    the first image found is used. Used when no song carries embedded art."""
    first = None
    for d in sorted(dirs):
        try:
            entries = sorted(os.listdir(d))
        except OSError:
            continue
        for f in entries:
            stem, ext = os.path.splitext(f)
            if ext.lower() not in IMAGE_EXTS:
                continue
            full = os.path.join(d, f)
            if not os.path.isfile(full):
                continue
            if stem.lower() in COVER_STEMS:
                return full
            # back.jpg must never become the FRONT by being alphabetically first,
            # which it usually is in a folder holding both scans.
            if first is None and stem.lower() not in BACK_STEMS:
                first = full
    return first


def find_back_cover_file(dirs):
    """Absolute path of an album folder's back-cover scan, or None. Only an
    explicit BACK_STEMS name counts — see the comment on BACK_STEMS for why there
    is deliberately no positional fallback here."""
    for d in sorted(dirs):
        try:
            entries = sorted(os.listdir(d))
        except OSError:
            continue
        for f in entries:
            stem, ext = os.path.splitext(f)
            if ext.lower() not in IMAGE_EXTS:
                continue
            full = os.path.join(d, f)
            if os.path.isfile(full) and stem.lower() in BACK_STEMS:
                return full
    return None


# ================================================================ scanning
def walk_audio(music_dir):
    """Yield absolute paths to every audio file under music_dir, unlimited depth.
    Hidden dirs and the generated covers/tracks folders are skipped."""
    for dirpath, dirnames, filenames in os.walk(music_dir, followlinks=True):
        dirnames[:] = sorted(d for d in dirnames
                             if d.lower() not in SKIP_DIRS and not d.startswith('.'))
        for name in sorted(filenames):
            if name.startswith('.') or not is_audio(name):
                continue
            yield os.path.join(dirpath, name)


def build_albums(music_dir, out_dir, lyrics_map, workers=WORKERS, enable_rename=False, enable_lrc=True):
    """Scan every audio file under music_dir and group into albums by ALBUM tag
    (folder name is the fallback). Returns a list of album dicts ready for
    index.md. Track links point at the source files in place, never copied.

    Two parallel phases, each with its own progress bar: tag reading (I/O-bound)
    and cover/thumbnail generation (Pillow's resize/encode releases the GIL), so
    a thread pool scales both across cores without pickling image blobs.

    If enable_rename is True, audio files are renamed to "{title} - {artist}{ext}"
    with missing fields filled by random hex, and collisions broken with hex
    suffixes. If enable_lrc is True, embedded lyrics are extracted into sibling
    .lrc files (always, even for plain untimestamped lyrics per user choice)."""
    paths = list(walk_audio(music_dir))

    def _read(idx_path):
        i, path = idx_path
        try:
            tags, pic, back_pic = read_song(path)
        except Exception as e:
            print('\nskipping unreadable file: %s (%s)' % (path, e), file=sys.stderr)
            tags, pic, back_pic = {}, None, None

        renamed, lrc_written = False, False
        final_path = path

        if enable_rename:
            target = compute_rename_target(path, tags)
            if target:
                if rename_audio_with_lrc(path, target):
                    renamed = True
                    final_path = target
                    # re-read tags from the new path to be safe
                    try:
                        tags, pic, back_pic = read_song(final_path)
                    except Exception:
                        pass

        if enable_lrc:
            lrc_written = write_lrc_file(final_path, tags)

        name = tags.get('album') or os.path.basename(os.path.dirname(final_path)) or UNKNOWN_ALBUM

        # Category tags (AI_V / LL / UR / SL / CC). Every spelling of the field,
        # every separator and the old Comment-field fallback are handled in
        # lvp_tags_of(); see LVP_TAG_KEYS for why "one field name" was not enough.
        tag_list = lvp_tags_of(tags)

        song = {'no': track_no(tags, i),
                'title': clean_title(tags, os.path.basename(final_path)),
                'src': final_path,
                'tags': tag_list,  # List of tags: ['AI_V', 'UR', 'LL'], etc.
                'artist': tags.get('artist'),
                'album_artist': tags.get('albumartist'),
                'duration': duration_of(tags),
                'label': label_of(tags)}
        return name, os.path.dirname(final_path), year_of(tags), pic, back_pic, song, renamed, lrc_written

    bar = Progress(len(paths), 'reading tags')
    groups = {}   # album name -> {'year','cover','back','songs','dirs'}
    rename_count, lrc_count = 0, 0
    with ThreadPoolExecutor(max_workers=workers) as pool:
        for name, songdir, yr, pic, back_pic, song, renamed, lrc_written in pool.map(_read, enumerate(paths, 1)):
            g = groups.setdefault(name, {'year': '', 'cover': None, 'back': None,
                                         'songs': [], 'dirs': set()})
            g['year'] = g['year'] or yr
            g['cover'] = g['cover'] or pic
            # the reverse is collected independently of the front: it is common for
            # only ONE track on a rip to carry the back scan, and it need not be the
            # same track that carried the front.
            g['back'] = g['back'] or back_pic
            g['dirs'].add(songdir)
            g['songs'].append(song)
            if renamed:
                rename_count += 1
            if lrc_written:
                lrc_count += 1
            bar.step()
    bar.close()
    print('read %d tracks across %d albums' % (len(paths), len(groups)), file=sys.stderr)
    if enable_rename:
        print('renamed %d audio files' % rename_count, file=sys.stderr)
    if enable_lrc:
        print('wrote %d .lrc files' % lrc_count, file=sys.stderr)

    def _finish(item):
        name, g = item
        songs = sorted(g['songs'], key=lambda s: s['no'])
        slug = slugify(name)

        # lyrics: a `lyrics` marker file in any of the album's folders forces
        # true; otherwise carry the setting forward from a previous index.md.
        has_marker = False
        for d in g['dirs']:
            try:
                if any(re.match(r'lyrics(\.|$)', f, re.I) for f in os.listdir(d)):
                    has_marker = True
                    break
            except OSError:
                pass
        lyrics = has_marker or lyrics_map.get(name, False)

        # cover: embedded art wins and is written into out_dir/covers/; if no
        # song carried art, fall back to a cover image file already sitting in
        # the album folder, referenced in place (same no-copy rule as tracks).
        # A downscaled thumbnail (covers/<slug>-thumb.jpg) is written for the
        # homepage grid; the album view keeps the full-res cover. If no
        # downscaler is available (or the art is already small), thumb == cover.
        cover_rel, thumb_rel, src_bytes = '', '', None
        if g['cover']:
            mime, img = g['cover']
            os.makedirs(os.path.join(out_dir, 'covers'), exist_ok=True)
            cover_rel = 'covers/%s%s' % (slug, sniff_image_ext(img, mime))
            with open(os.path.join(out_dir, cover_rel), 'wb') as f:
                f.write(img)
            src_bytes = img
        else:
            found = find_cover_file(g['dirs'])
            if found:
                cover_rel = rel_url(found, out_dir)
                try:
                    with open(found, 'rb') as f:
                        src_bytes = f.read()
                except OSError:
                    src_bytes = None

        if cover_rel and src_bytes is not None:
            os.makedirs(os.path.join(out_dir, 'covers'), exist_ok=True)
            thumb_name = 'covers/%s-thumb.jpg' % slug
            if make_thumbnail(src_bytes, os.path.join(out_dir, thumb_name)):
                thumb_rel = thumb_name
        thumb_rel = thumb_rel or cover_rel  # reuse full art when no thumb made

        # back cover: same two sources as the front, in the same order — a type-4
        # embedded picture first, then a back*.jpg sitting in the album folder.
        # No thumbnail is written for it: the reverse is never shown in the grid,
        # only full-size inside the cover flip / 3D vinyl, and only once the
        # visitor actually clicks. Empty when the release has no back art at all,
        # which the viewer handles by blurring the front — so this is optional in
        # index.md and always has been safe to omit.
        back_rel = ''
        if g.get('back'):
            mime, img = g['back']
            os.makedirs(os.path.join(out_dir, 'covers'), exist_ok=True)
            back_rel = 'covers/%s-back%s' % (slug, sniff_image_ext(img, mime))
            with open(os.path.join(out_dir, back_rel), 'wb') as f:
                f.write(img)
        else:
            found_back = find_back_cover_file(g['dirs'])
            if found_back:
                back_rel = rel_url(found_back, out_dir)

        # album runtime is the sum of every track, in hours (index.md carries hours
        # so the page can print it without knowing the unit). the label is taken
        # from the first track that names one — per the rule "pick one song and
        # read it off that song's metadata" — since a release shares one label.
        total_secs = sum(duration_of(s) for s in songs)
        label = next((s['label'] for s in songs if s.get('label')), '')
        return {'name': name, 'year': g['year'], 'slug': slug,
                'cover': cover_rel, 'thumb': thumb_rel, 'back': back_rel,
                'lyrics': lyrics, 'songs': songs,
                'author': album_author(songs),
                'duration': total_secs / 3600 if total_secs > 0 else 0.0,
                'label': label}

    bar = Progress(len(groups), 'covers & thumbs')
    albums = []
    with ThreadPoolExecutor(max_workers=workers) as pool:
        for album in pool.map(_finish, list(groups.items())):
            albums.append(album)
            bar.step()
    bar.close()

    # Newest first; albums with no year sort to the very end (year '' -> bucket 1).
    albums.sort(key=lambda a: (0, -int(a['year']), a['name'].lower())
                if a['year'].isdigit() else (1, 0, a['name'].lower()))
    return albums


# ================================================================ main
def main():
    ap = argparse.ArgumentParser(
        description='Index a music library (recursive) into index.md + covers, '
                    'referencing tracks in place (no copying).')
    ap.add_argument('music_dir', help='folder to scan recursively for audio')
    ap.add_argument('--out', default='.', help='project root to write into (default: current dir)')
    ap.add_argument('--workers', type=int, default=WORKERS,
                    help='parallel worker threads (default: %d on this machine)' % WORKERS)
    ap.add_argument('--rename', action='store_true',
                    help='rename audio files to "{title} - {artist}{ext}", with missing fields '
                         'filled by random hex and collision suffixes appended as needed')
    ap.add_argument('--no-lrc', dest='lrc', action='store_false', default=True,
                    help='skip writing .lrc files from embedded lyrics (default: enabled)')
    args = ap.parse_args()

    music_dir = os.path.abspath(os.path.expanduser(args.music_dir))
    out_dir = os.path.abspath(os.path.expanduser(args.out))
    os.makedirs(out_dir, exist_ok=True)
    if not os.path.isdir(music_dir):
        sys.exit('music dir not found: %s' % music_dir)

    lyrics_map = read_existing_lyrics(os.path.join(out_dir, 'index.md'))
    albums = build_albums(music_dir, out_dir, lyrics_map, workers=max(1, args.workers),
                          enable_rename=args.rename, enable_lrc=args.lrc)
    if not albums:
        sys.exit('no audio found under %s' % music_dir)

    # No tagline: nothing human reads index.md. The header carries the indexer
    # tags from LVP_IndexerRules instead (name, library-last-update) — invisible
    # to the viewer, which ignores everything above the first '---'.
    rules = read_indexer_rules(out_dir)
    lines = ['# yzyworks.com'] + ([''] + rules if rules else [])
    for a in albums:
        lines.append('')
        lines.append('---')
        if a['cover']:
            lines.append('> ![](%s)' % a['cover'])
        if a.get('thumb') and a['thumb'] != a['cover']:
            lines.append('thumb: %s' % a['thumb'])
        if a.get('back') and a['back'] != a['cover']:
            lines.append('back: %s' % a['back'])
        lines.append('> %s' % a['name'])
        if a['year']:
            lines.append('year: %s' % a['year'])
        if a['author']:
            lines.append('author: %s' % a['author'])
        lines.append('lyrics: %s' % ('yes' if a['lyrics'] else 'no'))
        if a.get('duration'):
            lines.append('duration: %.4f' % a['duration'])
        if a.get('label'):
            lines.append('label: %s' % a['label'])
        for i, s in enumerate(a['songs'], 1):
            # Format tags as space-separated: [AI_V] [UR] [LL]
            tag_str = ''.join(' [%s]' % t for t in s.get('tags', []))
            lines.append('%d. %s — %s%s' % (i, s['title'], rel_url(s['src'], out_dir), tag_str))

    with open(os.path.join(out_dir, 'index.md'), 'w', encoding='utf-8') as f:
        f.write('\n'.join(lines) + '\n')

    tracks = sum(len(a['songs']) for a in albums)
    covers = sum(1 for a in albums if a['cover'])
    backs = sum(1 for a in albums if a.get('back'))
    print('Wrote index.md — %d albums, %d tracks, %d covers extracted (%d with a back cover)%s.' %
          (len(albums), tracks, covers, backs,
           '' if HAVE_MUTAGEN else ' (built-in parser; install mutagen for more formats)'))


if __name__ == '__main__':
    main()
