From 0b04663e417fa86b7aa8700684737effa367fcfe Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Sun, 27 Sep 2026 15:31:06 -0700 Subject: feat: initial 2.0 - expergo - support fetching album art - full multiproc GUI - supporting in-place tag editing via GUI --- README.md | 54 ++- covers.py | 125 ++++++ expergo.py | 495 +++++++++++++++++++++ gui.py | 1323 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ main.py | 244 ----------- models.py | 155 +++++++ pyproject.toml | 2 + uv.lock | 52 +++ 8 files changed, 2198 insertions(+), 252 deletions(-) create mode 100644 covers.py create mode 100644 expergo.py create mode 100644 gui.py delete mode 100644 main.py create mode 100644 models.py diff --git a/README.md b/README.md index 5736f26..c3fd11f 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,18 @@ -# FIIO Snowsky Echo FLAC Media Tool -This is a small script to help owners who already maintain a FLAC library make their music work/show up nicer on the Snowsky Echo/Echo Mini +# expergo +An opinionated music library tool to help you manage your FLACs, compatible with the Snowsky Echo/Mini. -The script does the following: +> [!IMPORTANT] +> expergo works entirely in the directory you've pointed it to. It can work off of removable storage such as the Snowsky Echo Mini, but be aware that large jobs can take some time (limited by read-write speeds). + +# Echo Rules +These are the rules that the CLI applies (can be OPTIONALLY applied from the GUI): - Recursively searches through the provided directory -- Re-samples audio higher than 192Khz 24bit via ffmpeg +- Re-samples audio higher than 192Khz 24bit via ffmpeg down to 192Khz - Re-encodes files with block size higher than 4096 via `flac` CLI - Rename FLAC file to `TRACK_NAME - ARTIST.flac` -- Resize album art to 500x500px +- Resize embedded album art to 500x500px (good enough for small screen players, without taking up much space) - Normalize all audio to -14 LUFS -- Download LRC file +- Download LRC files (places them in same folder with same name, according to Snowsky Echo rules) - A delay of 0.3 sec is hardcoded to avoid rate limit, this is a hard limit across all threads (if running multithreaded) # External Dependencies @@ -20,12 +24,46 @@ https://xiph.org/flac/download.html - Linux: `sudo pacman -S flac` (follow your package manager) - macOS: `brew install flac` (idk tho i don't own a mac) +All other dependencies can be installed via `uv` ```bash uv sync -uv run main.py [--nolrc] [-n num_threads] ``` -Example: `uv run main.py ` +# CLI + +**Apply Echo Rules:** +```bash +uv run expergo.py [--nolrc] [-n workers] +``` + +**Subcommands:** +```bash +uv run expergo.py process [--nolrc] [-n workers] +uv run expergo.py auto-lrc [--force] [-n workers] +uv run expergo.py auto-cover [--force] [--tries N] [-n workers] +uv run expergo.py reorg [--artist-only] [--no-cleanup] [-n workers] +``` + +What each command does: +- `process`: full Echo rules pipeline (rename, resample/re-encode, normalize, resize art, optional LRC) +- `auto-lrc`: only fetch LRC files for FLACs (skips existing by default, `--force` overwrites) +- `auto-cover`: only fetch album art using MusicBrainz + Cover Art Archive, grouped per album (uses album artist when present) +- `reorg`: reorganize library into `Artist/Album/` folders, moving matching `.lrc` sidecars too +- +# GUI +```bash +uv run gui.py [base_dir] +``` + +- **Open Folder** scans recursively and lists every FLAC: filename, title, artist, album, album artist, genre and LRC status +- Double-click a cell to edit it. Edited cells are highlighted until you **Save Changes** (Ctrl+S). To edit many rows at once, right-click and choose "Set … for N rows" +- **Apply Echo Rules** runs the full script pipeline above on the selected rows, or on all visible rows if nothing is selected +- The LRC column shows `Missing` (no `.lrc` with the same name) or `Empty` (a lookup found nothing). Tick **Only missing LRC** to filter to those rows, then use **Fetch Missing LRC** to fill them in +- The **Album Art** panel beside the log shows the embedded cover of the highlighted row. **Edit Art** (Ctrl+E) searches MusicBrainz and shows covers from the Cover Art Archive to choose from. You can also pick a local image. The chosen cover is resized to 500x500 and embedded in every selected row, so select a whole album at once +- The **Art** column flags tracks without embedded album art. Tick **Missing art** to filter to them. **Fetch Missing Art** (Ctrl+Shift+E) looks up each album once on MusicBrainz and embeds the best-ranked cover it finds in every track of that album. Tracks without an Album tag are skipped, so use **Edit Art** for those +- The toolbar holds the common actions. The **File / Tracks / Options** menus contain everything, including the "Skip LRC" option and the parallel worker count, which are also in the arrow menu next to **Apply Echo Rules** +- **Add Songs** opens a window where you drop in new FLACs, fix their tags, then copy them into the library (optionally into `Artist/Album/` folders). The rules are applied to the copies unless you untick **Apply Echo rules**, in which case the files are copied as-is with only your tag edits (and LRC unless skipped). The original files are left alone +- **Reorganize** (Ctrl+Shift+O) moves every FLAC in the open folder (with its `.lrc`) into `Artist/Album/` folders. It uses Album Artist when set, so compilations stay together. It shows a preview of every move first. Filenames are kept, clashing files are skipped rather than overwritten, and empty folders can be removed afterwards # Other Tools `apply_album_art.sh` - Recursively traverse all folders, automatically apply album art and resize to all FLACs that have a JPG/PNG in the same directory diff --git a/covers.py b/covers.py new file mode 100644 index 0000000..647f9bf --- /dev/null +++ b/covers.py @@ -0,0 +1,125 @@ +import threading +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from io import BytesIO +from pathlib import Path + +import requests +from mutagen.flac import FLAC, Picture +from PIL import Image + +HEADERS = {"User-Agent": "SnowskyEchoFlacTool/0.1 ( https://github.com/pinapelz/fiio-snowsky-echo-flac-tool )"} +MB_SEARCH_URL = "https://musicbrainz.org/ws/2/release/" +CAA_URL = "https://coverartarchive.org/release/{mbid}/front{size}" +COVER_SIZE = 500 + +# MusicBrainz allows ~1 request/second per client +_mb_lock = threading.Lock() +_mb_last = 0.0 + + +def _quote(text: str) -> str: + return '"' + text.replace("\\", " ").replace('"', " ").strip() + '"' + + +def search_releases(album: str, artist: str = "", limit: int = 25) -> list[dict]: + global _mb_last + query = f"release:{_quote(album)}" + if artist.strip(): + query += f" AND artist:{_quote(artist)}" + with _mb_lock: + time.sleep(max(0.0, _mb_last + 1.1 - time.monotonic())) + try: + resp = requests.get( + MB_SEARCH_URL, + params={"query": query, "fmt": "json", "limit": limit}, + headers=HEADERS, + timeout=15, + ) + finally: + _mb_last = time.monotonic() + resp.raise_for_status() + + releases = [] + for rel in resp.json().get("releases", []): + credit = "".join(c.get("name", "") + c.get("joinphrase", "") for c in rel.get("artist-credit", [])) + formats = ", ".join(sorted({m["format"] for m in rel.get("media", []) if m.get("format")})) + details = " · ".join(x for x in (rel.get("date", "")[:4], rel.get("country", ""), formats) if x) + releases.append({"id": rel["id"], "title": rel.get("title", ""), "artist": credit, "details": details}) + return releases + + +def fetch_cover(mbid: str, size: int | None = 250) -> bytes | None: + url = CAA_URL.format(mbid=mbid, size=f"-{size}" if size else "") + resp = requests.get(url, headers=HEADERS, timeout=30) + if resp.status_code == 404: + return None + resp.raise_for_status() + return resp.content + + +def search_with_thumbnails(album: str, artist: str): + releases = search_releases(album, artist) + yield "releases", releases + + def thumb(mbid): + try: + return fetch_cover(mbid, 250) + except requests.RequestException: + return None + + executor = ThreadPoolExecutor(max_workers=6) + try: + futures = {executor.submit(thumb, rel["id"]): i for i, rel in enumerate(releases)} + for future in as_completed(futures): + yield "thumb", futures[future], future.result() + finally: + executor.shutdown(wait=False, cancel_futures=True) + + +def find_best_cover(album: str, artist: str, tries: int = 5) -> tuple[bytes, dict] | None: + """Front cover of the best-ranked matching release that has one, as (image bytes, release).""" + for rel in search_releases(album, artist, limit=tries): + data = fetch_cover(rel["id"], 1200) or fetch_cover(rel["id"], None) + if data: + return data, rel + return None + + +def load_source(source: tuple[str, str]) -> bytes: + kind, value = source + if kind == "file": + return Path(value).read_bytes() + for size in (1200, 500, None): + data = fetch_cover(value, size) + if data: + return data + raise LookupError(f"No cover art found for release {value}") + + +def prepare_cover(data: bytes, size: int = COVER_SIZE) -> bytes: + with Image.open(BytesIO(data)) as img: + resized = img.convert("RGB").resize((size, size), Image.Resampling.LANCZOS) + out = BytesIO() + resized.save(out, format="JPEG", quality=92) + return out.getvalue() + + +def read_cover(path: Path) -> Picture | None: + pictures = FLAC(str(path)).pictures + return next((p for p in pictures if p.type == 3), pictures[0] if pictures else None) + + +def write_cover(path: Path, jpeg: bytes | None) -> None: + audio = FLAC(str(path)) + audio.clear_pictures() + if jpeg: + with Image.open(BytesIO(jpeg)) as img: + width, height = img.size + pic = Picture() + pic.type = 3 + pic.mime = "image/jpeg" + pic.width, pic.height, pic.depth = width, height, 24 + pic.data = jpeg + audio.add_picture(pic) + audio.save() diff --git a/expergo.py b/expergo.py new file mode 100644 index 0000000..23cd5d2 --- /dev/null +++ b/expergo.py @@ -0,0 +1,495 @@ +import argparse +import os +import shutil +import subprocess +import threading +import time +from collections.abc import Callable, Iterator +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from pathlib import Path + +import covers +import syncedlyrics +from mutagen.flac import FLAC +from tqdm import tqdm + +_lyrics_semaphore = threading.Semaphore(3) + +_claimed_names: set[Path] = set() +_claimed_names_lock = threading.Lock() + + +def iter_files(base: Path) -> Iterator[Path]: + for p in base.rglob("*"): + if p.is_file(): + yield p.resolve() + + +def find_flacs(base_dir: Path) -> list[Path]: + return sorted(p for p in iter_files(base_dir) if p.suffix.lower() == ".flac" and not p.name.endswith(".tmp.flac")) + + +def sanitize_filename(name: str) -> str: + illegal = r'\\/:*?"<>|' + return "".join(c for c in name if c not in illegal).strip() + + +def rename_file(filepath: Path, new_name: str) -> Path: + target = filepath.with_name(new_name) + filepath.rename(target) + lrc, new_lrc = filepath.with_suffix(".lrc"), target.with_suffix(".lrc") + if lrc.exists() and not new_lrc.exists(): + lrc.rename(new_lrc) + return target + + +def get_tags(path: Path) -> dict[str, str]: + audio = FLAC(str(path)) + return { + "title": audio.get("TITLE", [""])[0], + "artist": audio.get("ARTIST", [""])[0], + "album": audio.get("ALBUM", [""])[0], + "albumartist": audio.get("ALBUMARTIST", [""])[0], + } + + +def has_cover(path: Path) -> bool: + try: + return bool(FLAC(str(path)).pictures) + except Exception: + return False + + +def download_lrc(lrc_path: Path, title: str, artist: str) -> bool: + with _lyrics_semaphore: + lrc = syncedlyrics.search(f"{title} {artist}", providers=["Lrclib", "Megalobiz", "NetEase"]) + time.sleep(0.3) + + with open(lrc_path, "w", encoding="utf-8") as f: + f.write(lrc if lrc else "") + return bool(lrc) + + +def get_audio_issues(path: Path) -> dict: + audio = FLAC(str(path)) + info = audio.info + sample_rate = getattr(info, "sample_rate", 0) + bits_per_sample = getattr(info, "bits_per_sample", 24) + max_blocksize = getattr(info, "max_blocksize", 4096) + + return { + "needs_sample_rate_fix": sample_rate > 192000, + "needs_bitdepth_fix": bits_per_sample > 24, + "sample_rate": sample_rate, + "bits_per_sample": bits_per_sample, + "max_blocksize": max_blocksize, + } + + +def fix_with_ffmpeg(path: Path, fix_sample_rate: bool, fix_bitdepth: bool) -> Path: + args = [ + "ffmpeg", "-y", + "-i", str(path), + "-map", "0:a", + "-map", "0:v?", + "-c:v", "copy", + "-acodec", "flac", + ] + if fix_bitdepth: + args += ["-sample_fmt", "s24"] + if fix_sample_rate: + args += ["-ar", "192000"] + + temp_path = path.with_suffix(".tmp.flac") + args.append(str(temp_path)) + subprocess.run(args, capture_output=True, text=True, check=True) + path.unlink() + temp_path.rename(path) + return path + + +def fix_blocksize(path: Path, blocksize: int = 4096) -> Path: + temp_path = path.with_suffix(".tmp.flac") + command = ["flac", "--force", f"--blocksize={blocksize}", str(path), "-o", str(temp_path)] + try: + subprocess.run(command, capture_output=True, text=True, check=True) + path.unlink() + temp_path.rename(path) + except subprocess.CalledProcessError as e: + temp_path.unlink(missing_ok=True) + print(f" Error fixing blocksize for {path.name}:") + print(e.stderr) + return path + + +def resize_album_art(path: Path) -> None: + audio = FLAC(str(path)) + if not audio.pictures: + return + from io import BytesIO + from PIL import Image + + for pic in audio.pictures: + with Image.open(BytesIO(pic.data)) as img: + resized = img.resize((500, 500), Image.Resampling.LANCZOS) + out = BytesIO() + fmt = img.format if img.format else "PNG" + resized.save(out, format=fmt) + pic.data = out.getvalue() + audio.save() + + +def normalize_loudness(path: Path, target_lufs: float = -14.0, target_tp: float = -1.0) -> Path: + temp_path = path.with_suffix(".tmp.flac") + subprocess.run( + [ + "ffmpeg-normalize", str(path), + "-o", str(temp_path), + "-c:a", "flac", + "-t", str(target_lufs), + "--true-peak", str(target_tp), + "-f", + "-e", "-map 0:v? -c:v copy", + ], + capture_output=True, + text=True, + check=True, + ) + path.unlink() + temp_path.rename(path) + return path + + +def process_file(fp: Path, nolrc: bool) -> str: + lines = [] + log = lines.append + + log(f"\nProcessing: {fp.name}") + tags = get_tags(fp) + title, artist, album = tags["title"], tags["artist"], tags["album"] + + if not title: + log(" Warning: TITLE tag is empty, using filename as title") + title = fp.stem + artist = "UNKNOWN ARTIST" + + new_stem = sanitize_filename(f"{title} - {artist}") + with _claimed_names_lock: + target = fp.with_name(new_stem + ".flac") + conflict = (target.resolve() in _claimed_names) or ( + target.exists() and target.resolve() != fp.resolve() + ) + if conflict: + if album: + log(" Conflict detected, adding album name as differentiator") + new_stem = sanitize_filename(f"{title} - {artist} ({album})") + else: + log(" Warning: filename conflict but no album tag, keeping original name") + new_stem = fp.stem + new_file_name = new_stem + ".flac" + _claimed_names.add(fp.with_name(new_file_name).resolve()) + + if new_file_name != fp.name: + fp = rename_file(fp, new_file_name) + + issues = get_audio_issues(fp) + log(f" Stats: {issues['sample_rate']}Hz, {issues['bits_per_sample']}-bit, blocksize={issues['max_blocksize']}") + + if issues["needs_sample_rate_fix"] or issues["needs_bitdepth_fix"]: + reasons = [] + if issues["needs_sample_rate_fix"]: + reasons.append(f"sample rate {issues['sample_rate']}Hz -> 192000Hz") + if issues["needs_bitdepth_fix"]: + reasons.append(f"bit depth {issues['bits_per_sample']}-bit -> 24-bit") + log(f" Fixing via ffmpeg: {', '.join(reasons)}") + fp = fix_with_ffmpeg(fp, issues["needs_sample_rate_fix"], issues["needs_bitdepth_fix"]) + + log(" Normalizing loudness to -14 LUFS") + fp = normalize_loudness(fp) + + post_blocksize = getattr(FLAC(str(fp)).info, "max_blocksize", 4096) + if post_blocksize > 4096: + log(" Fixing blocksize -> 4096 via flac CLI") + fp = fix_blocksize(fp) + + log(" Resizing album art to 500x500") + resize_album_art(fp) + + if nolrc: + return "\n".join(lines) + + if not title or not artist: + log(f" Skipping LRC for {fp.name} (missing title or artist tag)") + return "\n".join(lines) + + lrc_path = fp.with_suffix(".lrc") + if lrc_path.exists(): + log(f" Skipping LRC for {fp.name} (already exists)") + return "\n".join(lines) + + log(f" Fetching LRC for: {title} - {artist}") + download_lrc(lrc_path, title, artist) + return "\n".join(lines) + + +def auto_lrc_file(fp: Path, force: bool = False) -> str: + tags = get_tags(fp) + title, artist = tags["title"].strip(), tags["artist"].strip() + if not title or not artist: + return f"Skip {fp.name}: missing TITLE or ARTIST" + + lrc_path = fp.with_suffix(".lrc") + if lrc_path.exists() and not force: + return f"Skip {fp.name}: LRC already exists" + + found = download_lrc(lrc_path, title, artist) + return f"{'Fetched' if found else 'No'} LRC: {title} - {artist}" + + +@dataclass +class AlbumGroup: + key: tuple[str, str] + tracks: list[Path] + + @property + def name(self) -> str: + artist, album = self.key + return f"{album} — {artist}" if artist else album + + +def auto_cover_group(group: AlbumGroup, tries: int = 5) -> str: + artist, album = group.key + found = covers.find_best_cover(album, artist, tries=tries) + if not found: + return f"No cover found: {group.name}" + + data, rel = found + jpeg = covers.prepare_cover(data) + for fp in group.tracks: + covers.write_cover(fp, jpeg) + + info = " · ".join(x for x in (rel["title"], rel["artist"], rel["details"]) if x) + return f"Cover applied to {len(group.tracks)} track(s) of {group.name} (from {info})" + + +@dataclass +class MoveItem: + src: Path + dst: Path + conflict: bool + + @property + def name(self) -> str: + return self.src.name + + +def safe_dirname(name: str, fallback: str) -> str: + return sanitize_filename(name).rstrip(". ") or fallback + + +class FolderLayout: + def __init__(self, root: Path, prefer_album_artist: bool = True): + self.root = root + self.prefer_album_artist = prefer_album_artist + self._names: dict[Path, dict[str, str]] = {} + + def _canonical(self, parent: Path, name: str) -> str: + if parent not in self._names: + existing = {} + if parent.is_dir(): + existing = {c.name.lower(): c.name for c in parent.iterdir() if c.is_dir()} + self._names[parent] = existing + return self._names[parent].setdefault(name.lower(), name) + + def folder_for(self, tags: dict[str, str]) -> Path: + artist = tags["albumartist"] if self.prefer_album_artist else "" + artist = (artist or tags["artist"] or tags["albumartist"]).strip() + artist_dir = self._canonical(self.root, safe_dirname(artist, "Unknown Artist")) + album_dir = self._canonical(self.root / artist_dir, safe_dirname(tags["album"].strip(), "Unknown Album")) + return self.root / artist_dir / album_dir + + +def plan_reorg(files: list[Path], root: Path, prefer_album_artist: bool) -> list[MoveItem]: + layout = FolderLayout(root, prefer_album_artist) + claimed: set[str] = set() + plan = [] + for fp in files: + dst = layout.folder_for(get_tags(fp)) / fp.name + if dst == fp: + continue + key = str(dst).lower() + conflict = key in claimed or dst.exists() + claimed.add(key) + plan.append(MoveItem(src=fp, dst=dst, conflict=conflict)) + return plan + + +def move_item(item: MoveItem) -> str: + if item.dst.exists(): + raise FileExistsError(f"{item.dst} already exists") + item.dst.parent.mkdir(parents=True, exist_ok=True) + shutil.move(item.src, item.dst) + lrc, new_lrc = item.src.with_suffix(".lrc"), item.dst.with_suffix(".lrc") + if lrc.exists() and not new_lrc.exists(): + shutil.move(lrc, new_lrc) + return "" + + +def remove_empty_dirs(root: Path) -> int: + removed = 0 + for dirpath, _, _ in os.walk(root, topdown=False): + path = Path(dirpath) + try: + if path != root and not any(path.iterdir()): + path.rmdir() + removed += 1 + except OSError: + pass + return removed + + +def run_jobs(items: list, worker: Callable, workers: int, desc: str) -> tuple[int, int]: + ok = errors = 0 + with ThreadPoolExecutor(max_workers=max(1, workers)) as executor: + futures = {executor.submit(worker, item): item for item in items} + with tqdm(total=len(items), desc=desc, unit="item") as pbar: + for future in as_completed(futures): + item = futures[future] + try: + result = future.result() + if isinstance(result, str) and result.strip(): + tqdm.write(result) + ok += 1 + except Exception as e: + name = getattr(item, "name", Path(item).name if isinstance(item, Path) else str(item)) + tqdm.write(f"ERROR {name}: {e}") + errors += 1 + finally: + pbar.update(1) + return ok, errors + + +def command_process(base_dir: Path, workers: int, nolrc: bool) -> int: + files = find_flacs(base_dir) + _claimed_names.clear() + ok, errors = run_jobs(files, lambda fp: process_file(fp, nolrc), workers, "Applying Echo rules") + tqdm.write(f"Done: {ok} processed, {errors} errors") + return 0 if errors == 0 else 1 + + +def command_auto_lrc(base_dir: Path, workers: int, force: bool) -> int: + files = find_flacs(base_dir) + ok, errors = run_jobs(files, lambda fp: auto_lrc_file(fp, force=force), workers, "Fetching LRC") + tqdm.write(f"Done: {ok} checked, {errors} errors") + return 0 if errors == 0 else 1 + + +def command_auto_cover(base_dir: Path, workers: int, force: bool, tries: int) -> int: + files = find_flacs(base_dir) + groups: dict[tuple[str, str], list[Path]] = {} + skipped = 0 + + for fp in files: + if not force and has_cover(fp): + continue + tags = get_tags(fp) + album = tags["album"].strip() + artist = (tags["albumartist"] or tags["artist"]).strip() + if not album: + skipped += 1 + continue + groups.setdefault((artist, album), []).append(fp) + + items = [AlbumGroup(key=key, tracks=tracks) for key, tracks in groups.items()] + if skipped: + tqdm.write(f"Skipped {skipped} track(s) without an ALBUM tag") + if not items: + tqdm.write("Nothing to do (no albums matched)") + return 0 + + ok, errors = run_jobs(items, lambda g: auto_cover_group(g, tries=tries), workers, "Fetching album art") + tqdm.write(f"Done: {ok} albums checked, {errors} errors") + return 0 if errors == 0 else 1 + + +def command_reorg(base_dir: Path, workers: int, prefer_album_artist: bool, cleanup: bool) -> int: + files = find_flacs(base_dir) + plan = plan_reorg(files, base_dir, prefer_album_artist) + moves = [m for m in plan if not m.conflict] + conflicts = len(plan) - len(moves) + + tqdm.write(f"Plan: {len(moves)} move(s), {len(files) - len(plan)} already in place, {conflicts} conflict(s)") + if not moves: + return 0 + + ok, errors = run_jobs(moves, move_item, workers, "Reorganizing") + if cleanup: + removed = remove_empty_dirs(base_dir) + tqdm.write(f"Removed {removed} empty folder(s)") + tqdm.write(f"Done: {ok} moved, {errors} errors") + return 0 if errors == 0 else 1 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="expergo CLI") + sub = parser.add_subparsers(dest="command") + + p_process = sub.add_parser("process", help="Apply full Echo rules") + p_process.add_argument("base_dir", type=Path) + p_process.add_argument("--nolrc", action="store_true", dest="nolrc") + p_process.add_argument("-n", "--workers", type=int, default=1) + + p_lrc = sub.add_parser("auto-lrc", help="Fetch missing LRC files") + p_lrc.add_argument("base_dir", type=Path) + p_lrc.add_argument("-n", "--workers", type=int, default=1) + p_lrc.add_argument("--force", action="store_true", help="Overwrite existing .lrc files") + + p_cover = sub.add_parser("auto-cover", help="Fetch album art from MusicBrainz/Cover Art Archive") + p_cover.add_argument("base_dir", type=Path) + p_cover.add_argument("-n", "--workers", type=int, default=1) + p_cover.add_argument("--force", action="store_true", help="Also replace existing embedded cover art") + p_cover.add_argument("--tries", type=int, default=5, help="Release search candidates per album (default: 5)") + + p_reorg = sub.add_parser("reorg", help="Move tracks into Artist/Album folders") + p_reorg.add_argument("base_dir", type=Path) + p_reorg.add_argument("-n", "--workers", type=int, default=1) + p_reorg.add_argument("--artist-only", action="store_true", help="Use ARTIST only (ignore ALBUMARTIST)") + p_reorg.add_argument("--no-cleanup", action="store_true", help="Do not remove empty folders after moving") + + return parser + + +def normalize_legacy_argv(argv: list[str]) -> list[str]: + commands = {"process", "auto-lrc", "auto-cover", "reorg", "-h", "--help"} + if len(argv) > 1 and argv[1] not in commands and not argv[1].startswith("-"): + return [argv[0], "process", *argv[1:]] + return argv + + +def main() -> int: + import sys + + argv = normalize_legacy_argv(list(sys.argv)) + args = build_parser().parse_args(argv[1:]) + + base_dir = getattr(args, "base_dir", None) + if base_dir and not base_dir.is_dir(): + raise SystemExit(f"Not a directory: {base_dir}") + + match args.command: + case "process": + return command_process(args.base_dir, args.workers, args.nolrc) + case "auto-lrc": + return command_auto_lrc(args.base_dir, args.workers, args.force) + case "auto-cover": + return command_auto_cover(args.base_dir, args.workers, args.force, max(1, args.tries)) + case "reorg": + return command_reorg(args.base_dir, args.workers, not args.artist_only, not args.no_cleanup) + case _: + build_parser().print_help() + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..66f0a12 --- /dev/null +++ b/gui.py @@ -0,0 +1,1323 @@ +import os +import shutil +import subprocess +import sys +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path +from types import SimpleNamespace +from PyQt6.QtCore import ( + QAbstractTableModel, + QElapsedTimer, + QModelIndex, + QSize, + QSortFilterProxyModel, + Qt, + QThread, + QTimer, + pyqtSignal, +) +from PyQt6.QtGui import QAction, QActionGroup, QBrush, QColor, QIcon, QKeySequence, QPixmap +from PyQt6.QtWidgets import ( + QAbstractItemView, + QApplication, + QCheckBox, + QDialog, + QFileDialog, + QFrame, + QHBoxLayout, + QHeaderView, + QInputDialog, + QLabel, + QLineEdit, + QListView, + QListWidget, + QListWidgetItem, + QMainWindow, + QMenu, + QMessageBox, + QPlainTextEdit, + QProgressBar, + QPushButton, + QSizePolicy, + QSpinBox, + QSplitter, + QStyle, + QTableView, + QTableWidget, + QTableWidgetItem, + QToolBar, + QToolButton, + QVBoxLayout, + QWidget, +) + +import covers +import expergo as core +from models import ( + ART_COL, + FIELDS, + HEADERS, + LRC_COL, + FolderLayout, + Track, + apply_cover, + auto_cover, + fetch_lrc, + find_flacs, + flac_name, + move_track, + plan_reorganize, + remove_empty_dirs, + save_track, + write_tags, +) +DIRTY_BRUSH = QBrush(QColor(255, 190, 0, 70)) +MISSING_BRUSH = QBrush(QColor(220, 60, 60)) +EDIT_TRIGGERS = ( + QAbstractItemView.EditTrigger.DoubleClicked + | QAbstractItemView.EditTrigger.EditKeyPressed + | QAbstractItemView.EditTrigger.AnyKeyPressed +) + +os.environ["PATH"] = str(Path(sys.executable).parent) + os.pathsep + os.environ.get("PATH", "") +REQUIRED_TOOLS = ("ffmpeg", "flac", "ffmpeg-normalize") + + +def tools_ok(parent) -> bool: + missing = [tool for tool in REQUIRED_TOOLS if not shutil.which(tool)] + if missing: + QMessageBox.critical(parent, "Missing tools", f"Not found on PATH: {', '.join(missing)}\nSee README.md.") + return not missing + + +def std_icon(theme: str, fallback: QStyle.StandardPixmap) -> QIcon: + return QIcon.fromTheme(theme, QApplication.style().standardIcon(fallback)) + + +class Cancelled(Exception): + pass + + +class Worker(QThread): + """Runs fn(item) for every item on a thread pool, reporting log lines and progress.""" + + log = pyqtSignal(str) + progress = pyqtSignal(int, int) + current = pyqtSignal(str) + + def __init__(self, items, fn, workers: int = 1, parent=None): + super().__init__(parent) + self.items, self.fn, self.workers = list(items), fn, max(1, workers) + self.results = [] + self._stop = False + + def stop(self): + self._stop = True + + def _run_one(self, item): + if self._stop: + raise Cancelled() + name = getattr(item, "name", None) + if name: + self.current.emit(name) + return self.fn(item) + + def run(self): + total, done = len(self.items), 0 + self.progress.emit(0, total) + with ThreadPoolExecutor(max_workers=self.workers) as executor: + futures = {executor.submit(self._run_one, item): item for item in self.items} + for future in as_completed(futures): + item = futures[future] + try: + result = future.result() + self.results.append((item, result)) + if isinstance(result, str) and result.strip(): + self.log.emit(result.strip()) + except Cancelled: + pass + except subprocess.CalledProcessError as e: + self.log.emit(f"ERROR {item.name}: {e}\n{(e.stderr or '')[-800:]}") + except Exception as e: + self.log.emit(f"ERROR {getattr(item, 'name', item)}: {e}") + done += 1 + self.progress.emit(done, total) + if self._stop: + self.log.emit("Stopped.") + + +# Background threads not parented to a widget, kept alive until they finish so closing a dialog can't +# destroy a QThread that is still blocked on the network. +_live_threads: set[QThread] = set() + + +class Task(QThread): + progress = pyqtSignal(object) + done = pyqtSignal(object) + failed = pyqtSignal(str) + + def __init__(self, fn): + super().__init__() + self.fn = fn + + def run(self): + try: + self.done.emit(self.fn(self)) + except Exception as e: + self.failed.emit(str(e)) + + def start(self): + _live_threads.add(self) + self.finished.connect(lambda: _live_threads.discard(self)) + super().start() + + +def format_duration(seconds: float) -> str: + seconds = int(seconds) + return f"{seconds // 3600}:{seconds // 60 % 60:02d}:{seconds % 60:02d}" if seconds >= 3600 else f"{seconds // 60}:{seconds % 60:02d}" + + +class ProgressStrip(QFrame): + def __init__(self, stop_action: QAction | None = None): + super().__init__() + self.setFrameShape(QFrame.Shape.StyledPanel) + self.title = QLabel() + self.title.setStyleSheet("font-weight: bold;") + self.bar = QProgressBar() + self.bar.setMinimumWidth(200) + self.detail = QLabel() + self.detail.setStyleSheet("color: palette(mid);") + # Ignored width so long filenames get clipped instead of stretching the window + self.detail.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + self.clock = QLabel() + self._elapsed = QElapsedTimer() + self._timer = QTimer(self) + self._timer.setInterval(1000) + self._timer.timeout.connect(self._tick) + self._done = self._total = 0 + + row = QHBoxLayout(self) + row.setContentsMargins(8, 4, 8, 4) + row.addWidget(self.title) + row.addWidget(self.bar, 2) + row.addWidget(self.detail, 3) + row.addWidget(self.clock) + if stop_action: + stop = QToolButton() + stop.setDefaultAction(stop_action) + stop.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + row.addWidget(stop) + self.hide() + + def start(self, title: str, total: int = 0): + self.title.setText(title) + self.detail.clear() + self._elapsed.start() + self._timer.start() + self.set_progress(0, total) + self.show() + + def set_title(self, title: str): + self.title.setText(title) + + def set_progress(self, done: int, total: int): + self._done, self._total = done, total + if total == 0 or (total == 1 and done == 0): + self.bar.setRange(0, 0) # unknown / single long step: show a busy animation instead of 0% + else: + self.bar.setRange(0, total) + self.bar.setValue(done) + self.bar.setFormat(f"{done} / {total} (%p%)") + self._tick() + + def set_detail(self, text: str): + self.detail.setText(text) + self.detail.setToolTip(text) + + def _tick(self): + seconds = self._elapsed.elapsed() / 1000 if self._elapsed.isValid() else 0 + text = format_duration(seconds) + if 0 < self._done < self._total: + text += f" · ~{format_duration(seconds / self._done * (self._total - self._done))} left" + self.clock.setText(text) + + def finish(self): + self._timer.stop() + self.hide() + + +class TrackModel(QAbstractTableModel): + def __init__(self): + super().__init__() + self.tracks: list[Track] = [] + + def set_tracks(self, tracks): + self.beginResetModel() + self.tracks = list(tracks) + self.endResetModel() + + def add_tracks(self, tracks): + if not tracks: + return + start = len(self.tracks) + self.beginInsertRows(QModelIndex(), start, start + len(tracks) - 1) + self.tracks += tracks + self.endInsertRows() + + def remove_rows(self, rows): + for row in sorted(rows, reverse=True): + self.beginRemoveRows(QModelIndex(), row, row) + del self.tracks[row] + self.endRemoveRows() + + def refresh(self): + if self.tracks: + self.dataChanged.emit(self.index(0, 0), self.index(len(self.tracks) - 1, ART_COL)) + + def rowCount(self, parent=QModelIndex()): + return 0 if parent.isValid() else len(self.tracks) + + def columnCount(self, parent=QModelIndex()): + return 0 if parent.isValid() else len(HEADERS) + + def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): + if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: + return HEADERS[section] + return super().headerData(section, orientation, role) + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + track = self.tracks[index.row()] + col = index.column() + if col == LRC_COL: + if role == Qt.ItemDataRole.DisplayRole: + return track.lrc + if role == Qt.ItemDataRole.ForegroundRole and track.lrc != "OK": + return MISSING_BRUSH + if role == Qt.ItemDataRole.ToolTipRole: + return str(track.path.with_suffix(".lrc")) + return None + if col == ART_COL: + if role == Qt.ItemDataRole.DisplayRole: + return "OK" if track.art else "Missing" + if role == Qt.ItemDataRole.ForegroundRole and not track.art: + return MISSING_BRUSH + return None + + key = FIELDS[col][0] + if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): + return track.tags[key] + if role == Qt.ItemDataRole.BackgroundRole and track.is_dirty(key): + return DIRTY_BRUSH + if role == Qt.ItemDataRole.ToolTipRole: + if track.is_dirty(key): + return f"Original: {track.orig[key]}" + return str(track.path) if key == "FILE" else None + return None + + def flags(self, index): + flags = super().flags(index) + if index.column() < LRC_COL: + flags |= Qt.ItemFlag.ItemIsEditable + return flags + + def setData(self, index, value, role=Qt.ItemDataRole.EditRole): + if role != Qt.ItemDataRole.EditRole or index.column() >= LRC_COL: + return False + self.tracks[index.row()].tags[FIELDS[index.column()][0]] = str(value) + self.dataChanged.emit(index, index) + return True + + +class FilterProxy(QSortFilterProxyModel): + def __init__(self): + super().__init__() + self.text = "" + self.missing_lrc = False + self.missing_art = False + self.setSortCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + + def set_filter(self, text=None, missing_lrc=None, missing_art=None): + if text is not None: + self.text = text.lower() + if missing_lrc is not None: + self.missing_lrc = missing_lrc + if missing_art is not None: + self.missing_art = missing_art + self.invalidate() + + def filterAcceptsRow(self, row, parent): + track = self.sourceModel().tracks[row] + if self.missing_lrc and track.lrc == "OK": + return False + if self.missing_art and track.art: + return False + return not self.text or any(self.text in v.lower() for v in track.tags.values()) + + +class TrackTable(QTableView): + def __init__(self, source: TrackModel): + super().__init__() + self.source = source + self.proxy = FilterProxy() + self.proxy.setSourceModel(source) + self.setModel(self.proxy) + self.setSortingEnabled(True) + self.sortByColumn(-1, Qt.SortOrder.AscendingOrder) + self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.setEditTriggers(EDIT_TRIGGERS) + self.setAlternatingRowColors(True) + self.setWordWrap(False) + self.verticalHeader().setVisible(False) + self.horizontalHeader().setStretchLastSection(True) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self._context_menu) + self.extra_actions: list[QAction] = [] + + def fit_columns(self): + self.resizeColumnsToContents() + for col in range(len(HEADERS)): + self.setColumnWidth(col, min(self.columnWidth(col), 350)) + + def selected_rows(self) -> list[int]: + return sorted({self.proxy.mapToSource(i).row() for i in self.selectionModel().selectedRows()}) + + def selected_tracks(self) -> list[Track]: + """Selected rows, falling back to the current row.""" + rows = self.selected_rows() + if not rows and self.currentIndex().isValid(): + rows = [self.proxy.mapToSource(self.currentIndex()).row()] + return [self.source.tracks[r] for r in rows] + + def current_track(self) -> Track | None: + index = self.currentIndex() + if not index.isValid(): + rows = self.selected_rows() + return self.source.tracks[rows[0]] if rows else None + return self.source.tracks[self.proxy.mapToSource(index).row()] + + def target_tracks(self) -> list[Track]: + rows = self.selected_rows() or [ + self.proxy.mapToSource(self.proxy.index(r, 0)).row() for r in range(self.proxy.rowCount()) + ] + return [self.source.tracks[r] for r in rows] + + def _context_menu(self, pos): + index = self.indexAt(pos) + rows = self.selected_rows() + if not index.isValid() or not rows: + return + col = index.column() + menu = QMenu(self) + if col < LRC_COL and not (col == 0 and len(rows) > 1): + menu.addAction(f"Set {HEADERS[col]} for {len(rows)} row(s)…").triggered.connect( + lambda: self._bulk_edit(rows, col, index.data()) + ) + menu.addAction("Revert changes").triggered.connect(lambda: self._revert(rows)) + if self.extra_actions: + menu.addSeparator() + menu.addActions(self.extra_actions) + menu.exec(self.viewport().mapToGlobal(pos)) + + def _bulk_edit(self, rows, col, current): + value, ok = QInputDialog.getText(self, "Bulk edit", f"{HEADERS[col]}:", text=current or "") + if ok: + for row in rows: + self.source.tracks[row].tags[FIELDS[col][0]] = value + self.source.refresh() + + def _revert(self, rows): + for row in rows: + track = self.source.tracks[row] + track.tags = dict(track.orig) + self.source.refresh() + + +class ImportDialog(QDialog): + imported = pyqtSignal() + + def __init__(self, dest: Path | None, workers: int, nolrc: bool, parent=None): + super().__init__(parent) + self.setWindowTitle("Add Songs") + self.resize(1000, 600) + self.setAcceptDrops(True) + self.worker = None + self._copy_lock = threading.Lock() + + self.model = TrackModel() + self.table = TrackTable(self.model) + + add_files = QPushButton("Add Files…") + add_files.clicked.connect(self._pick_files) + add_folder = QPushButton("Add Folder…") + add_folder.clicked.connect(self._pick_folder) + remove = QPushButton("Remove Selected") + remove.clicked.connect(lambda: self.model.remove_rows(self.table.selected_rows())) + top = QHBoxLayout() + for w in (add_files, add_folder, remove): + top.addWidget(w) + top.addStretch() + top.addWidget(QLabel("Tip: drag & drop FLACs or folders here")) + + self.dest = QLineEdit(str(dest) if dest else "") + browse = QPushButton("Browse…") + browse.clicked.connect(self._pick_dest) + dest_row = QHBoxLayout() + dest_row.addWidget(QLabel("Destination:")) + dest_row.addWidget(self.dest, 1) + dest_row.addWidget(browse) + + self.apply_rules = QCheckBox("Apply Echo rules") + self.apply_rules.setChecked(True) + self.apply_rules.setToolTip("Rename, resample, fix blocksize, normalize loudness and resize art after copying.\n" + "Untick to copy the files as-is (only your tag edits are written).") + self.apply_rules.toggled.connect(self._update_import_label) + self.organize = QCheckBox("Organize into Artist / Album folders") + self.organize.setToolTip("Uses Album Artist when set (keeps compilations together), otherwise Artist") + self.nolrc = QCheckBox("Skip LRC download") + self.nolrc.setChecked(nolrc) + self.workers = QSpinBox() + self.workers.setRange(1, 16) + self.workers.setValue(workers) + opts = QHBoxLayout() + opts.addWidget(self.apply_rules) + opts.addWidget(self.organize) + opts.addWidget(self.nolrc) + opts.addStretch() + opts.addWidget(QLabel("Workers:")) + opts.addWidget(self.workers) + + self.log = QPlainTextEdit(readOnly=True) + self.log.setMaximumBlockCount(5000) + self.stop_action = QAction("Stop", self) + self.stop_action.setIcon(std_icon("process-stop", QStyle.StandardPixmap.SP_BrowserStop)) + self.stop_action.triggered.connect(lambda: self.worker and self.worker.stop()) + self.progress = ProgressStrip(self.stop_action) + self.import_btn = QPushButton("Import && Apply Rules") + self.import_btn.clicked.connect(self._import) + close = QPushButton("Close") + close.clicked.connect(self.close) + bottom = QHBoxLayout() + bottom.addWidget(self.progress, 1) + bottom.addStretch() + bottom.addWidget(self.import_btn) + bottom.addWidget(close) + + splitter = QSplitter(Qt.Orientation.Vertical) + splitter.addWidget(self.table) + splitter.addWidget(self.log) + splitter.setSizes([400, 150]) + self._update_import_label() + + layout = QVBoxLayout(self) + layout.addLayout(top) + layout.addWidget(splitter, 1) + layout.addLayout(dest_row) + layout.addLayout(opts) + layout.addLayout(bottom) + + def _update_import_label(self): + self.import_btn.setText("Import && Apply Rules" if self.apply_rules.isChecked() else "Import") + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + event.acceptProposedAction() + + def dropEvent(self, event): + self.add_paths([u.toLocalFile() for u in event.mimeData().urls() if u.isLocalFile()]) + + def _pick_files(self): + files, _ = QFileDialog.getOpenFileNames(self, "Add FLAC files", "", "FLAC (*.flac)") + self.add_paths(files) + + def _pick_folder(self): + folder = QFileDialog.getExistingDirectory(self, "Add folder of FLACs") + if folder: + self.add_paths([folder]) + + def _pick_dest(self): + folder = QFileDialog.getExistingDirectory(self, "Destination folder", self.dest.text()) + if folder: + self.dest.setText(folder) + + def add_paths(self, paths): + known = {t.path for t in self.model.tracks} + new = [] + for path in find_flacs(paths): + if path in known: + continue + try: + new.append(Track.load(path)) + except Exception as e: + self.log.appendPlainText(f"ERROR reading {path.name}: {e}") + self.model.add_tracks(new) + self.table.fit_columns() + + def _import_one(self, track: Track, dest_root: Path, layout: FolderLayout | None, + apply_rules: bool, nolrc: bool) -> str: + with self._copy_lock: + dest_dir = layout.folder_for(track.tags) if layout else dest_root + dest_dir.mkdir(parents=True, exist_ok=True) + target = dest_dir / (flac_name(track.tags["FILE"]) if track.tags["FILE"].strip() else track.name) + if target.exists(): + raise FileExistsError(f"{target} already exists") + target.touch() + shutil.copy2(track.path, target) + lrc = track.path.with_suffix(".lrc") + if lrc.exists(): + shutil.copy2(lrc, target.with_suffix(".lrc")) + write_tags(target, track) + if apply_rules: + return f"Copied to {dest_dir}" + core.process_file(target.resolve(), nolrc) + + lines = [f"Copied {target.name} to {dest_dir}"] + title, artist = track.tags["TITLE"].strip(), track.tags["ARTIST"].strip() + if not nolrc and not target.with_suffix(".lrc").exists() and title and artist: + found = core.download_lrc(target.with_suffix(".lrc"), title, artist) + lines.append(f" {'Fetched' if found else 'No'} LRC for {title} - {artist}") + return "\n".join(lines) + + def _import(self): + if not self.model.tracks: + return + if not self.dest.text().strip(): + QMessageBox.warning(self, "Add Songs", "Choose a destination folder first.") + return + apply_rules, nolrc = self.apply_rules.isChecked(), self.nolrc.isChecked() + if apply_rules and not tools_ok(self): + return + core._claimed_names.clear() + self.import_btn.setEnabled(False) + dest_root = Path(self.dest.text().strip()).expanduser().resolve() + layout = FolderLayout(dest_root) if self.organize.isChecked() else None + fn = lambda t: self._import_one(t, dest_root, layout, apply_rules, nolrc) + self.worker = Worker(self.model.tracks, fn, self.workers.value(), self) + self.worker.log.connect(self.log.appendPlainText) + self.worker.progress.connect(self.progress.set_progress) + self.worker.current.connect(self.progress.set_detail) + self.worker.finished.connect(self._done) + self.progress.start("Importing", len(self.worker.items)) + self.worker.start() + + def _done(self): + if not self.worker: + return + done = {id(track) for track, _ in self.worker.results} + self.model.remove_rows([i for i, t in enumerate(self.model.tracks) if id(t) in done]) + failed = len(self.model.tracks) + self.log.appendPlainText(f"Imported {len(done)} file(s).") + if failed: + self.log.appendPlainText(f"{failed} failed; their copies may remain in the library (see log).") + self.worker = None + self.progress.finish() + self.import_btn.setEnabled(True) + self.imported.emit() + + def closeEvent(self, event): + if self.worker: + QMessageBox.information(self, "Add Songs", "Import is still running.") + event.ignore() + else: + super().closeEvent(event) + + def reject(self): + self.close() + + +class ReorganizeDialog(QDialog): + """Preview of moving every track to root/Artist/Album/. Nothing is touched until the user accepts.""" + + def __init__(self, tracks: list[Track], root: Path, parent=None): + super().__init__(parent) + self.setWindowTitle("Reorganize Folders") + self.resize(1000, 600) + self.tracks, self.root = tracks, root + self.moves: list[SimpleNamespace] = [] + + intro = QLabel(f"Move every FLAC (and its .lrc) in {root} into Artist / Album folders. " + "Filenames are kept. Uses the saved tags on disk.") + intro.setWordWrap(True) + self.album_artist = QCheckBox("Use Album Artist when set") + self.album_artist.setChecked(True) + self.album_artist.setToolTip("Keeps compilations / features in one folder. Untick to always use the track Artist.") + self.album_artist.toggled.connect(self._refresh) + self.cleanup = QCheckBox("Remove empty folders afterwards") + self.cleanup.setChecked(True) + opts = QHBoxLayout() + opts.addWidget(self.album_artist) + opts.addWidget(self.cleanup) + opts.addStretch() + + self.table = QTableWidget(0, 2) + self.table.setHorizontalHeaderLabels(["From", "To"]) + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.table.verticalHeader().setVisible(False) + self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.table.setWordWrap(False) + + self.summary = QLabel() + self.ok_btn = QPushButton() + self.ok_btn.setDefault(True) + self.ok_btn.clicked.connect(self.accept) + cancel = QPushButton("Cancel") + cancel.clicked.connect(self.reject) + bottom = QHBoxLayout() + bottom.addWidget(self.summary, 1) + bottom.addWidget(self.ok_btn) + bottom.addWidget(cancel) + + layout = QVBoxLayout(self) + layout.addWidget(intro) + layout.addLayout(opts) + layout.addWidget(self.table, 1) + layout.addLayout(bottom) + self._refresh() + + def _rel(self, path: Path) -> str: + try: + return str(path.relative_to(self.root)) + except ValueError: + return str(path) + + def _refresh(self): + plan = plan_reorganize(self.tracks, self.root, self.album_artist.isChecked()) + self.moves = [m for m in plan if not m.conflict] + self.table.setRowCount(len(plan)) + for row, move in enumerate(plan): + src = QTableWidgetItem(self._rel(move.src)) + dst = QTableWidgetItem(self._rel(move.dst) + (" (conflict: skipped)" if move.conflict else "")) + src.setToolTip(str(move.src)) + dst.setToolTip(str(move.dst)) + if move.conflict: + dst.setForeground(MISSING_BRUSH) + self.table.setItem(row, 0, src) + self.table.setItem(row, 1, dst) + conflicts = len(plan) - len(self.moves) + in_place = len(self.tracks) - len(plan) + text = f"{len(self.moves)} to move · {in_place} already in place" + if conflicts: + text += f" · {conflicts} skipped (a file with the same name is already at the destination)" + self.summary.setText(text) + self.ok_btn.setText(f"Move {len(self.moves)} File(s)") + self.ok_btn.setEnabled(bool(self.moves)) + + + +def section_label(text: str) -> QLabel: + label = QLabel(text.upper()) + label.setStyleSheet("font-weight: bold; font-size: 11px; color: palette(mid); padding: 2px 0;") + return label + + +class ArtPanel(QWidget): + def __init__(self, actions: list[QAction]): + super().__init__() + self._pixmap: QPixmap | None = None + + self.image = QLabel(alignment=Qt.AlignmentFlag.AlignCenter) + self.image.setFrameShape(QFrame.Shape.StyledPanel) + self.image.setMinimumSize(160, 160) + self.image.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) + self.caption = QLabel(alignment=Qt.AlignmentFlag.AlignCenter, wordWrap=True) + self.info = QLabel(alignment=Qt.AlignmentFlag.AlignCenter) + self.info.setStyleSheet("color: palette(mid);") + + buttons = QHBoxLayout() + buttons.addStretch() + for act in actions: + btn = QToolButton() + btn.setDefaultAction(act) + btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + buttons.addWidget(btn) + buttons.addStretch() + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(section_label("Album Art")) + layout.addWidget(self.image, 1) + layout.addWidget(self.caption) + layout.addWidget(self.info) + layout.addLayout(buttons) + self.show_track(None) + + def show_track(self, track: Track | None, selected: int = 0): + self._pixmap = None + self.info.clear() + if track is None: + self.caption.setText("No track selected") + else: + self.caption.setText(track.tags["ALBUM"] or track.tags["TITLE"] or track.name) + try: + pic = covers.read_cover(track.path) + except Exception as e: + pic = None + self.info.setText(f"Could not read: {e}") + if pic: + pixmap = QPixmap() + if pixmap.loadFromData(pic.data): + self._pixmap = pixmap + fmt = (pic.mime or "image").split("/")[-1].upper() + self.info.setText(f"{pixmap.width()}×{pixmap.height()} · {fmt} · {len(pic.data) // 1024} KB") + elif not self.info.text(): + self.info.setText("No embedded album art") + if selected > 1: + self.info.setText(self.info.text() + f"\n{selected} tracks selected; edits apply to all") + self._rescale() + + def _rescale(self): + if self._pixmap: + self.image.setPixmap(self._pixmap.scaled( + self.image.size() - QSize(8, 8), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + )) + else: + self.image.setPixmap(QPixmap()) + self.image.setText("No art") + + def resizeEvent(self, event): + super().resizeEvent(event) + self._rescale() + + +class CoverDialog(QDialog): + THUMB = 180 + + def __init__(self, album: str, artist: str, count: int, parent=None): + super().__init__(parent) + self.setWindowTitle(f"Album Art · {count} track(s)") + self.resize(900, 640) + self.source: tuple[str, str] | None = None + self._task: Task | None = None + + self.album = QLineEdit(album, placeholderText="Album") + self.artist = QLineEdit(artist, placeholderText="Artist (optional)") + for edit in (self.album, self.artist): + edit.returnPressed.connect(self.search) + search = QPushButton("Search") + search.clicked.connect(self.search) + top = QHBoxLayout() + top.addWidget(self.album, 3) + top.addWidget(self.artist, 2) + top.addWidget(search) + + self.results = QListWidget() + self.results.setViewMode(QListView.ViewMode.IconMode) + self.results.setIconSize(QSize(self.THUMB, self.THUMB)) + self.results.setGridSize(QSize(self.THUMB + 30, self.THUMB + 70)) + self.results.setResizeMode(QListView.ResizeMode.Adjust) + self.results.setMovement(QListView.Movement.Static) + self.results.setWordWrap(True) + self.results.setUniformItemSizes(True) + self.results.itemDoubleClicked.connect(self._apply) + self.results.currentItemChanged.connect(self._update_apply) + + self.status = QLabel() + local = QPushButton("Local File…") + local.clicked.connect(self._pick_file) + self.apply_btn = QPushButton(f"Apply to {count} Track(s)") + self.apply_btn.setDefault(True) + self.apply_btn.setEnabled(False) + self.apply_btn.clicked.connect(self._apply) + cancel = QPushButton("Cancel") + cancel.clicked.connect(self.reject) + bottom = QHBoxLayout() + bottom.addWidget(local) + bottom.addWidget(self.status, 1) + bottom.addWidget(self.apply_btn) + bottom.addWidget(cancel) + + self.progress = ProgressStrip() + self._loaded = 0 + + layout = QVBoxLayout(self) + layout.addLayout(top) + layout.addWidget(self.progress) + layout.addWidget(self.results, 1) + layout.addLayout(bottom) + + self._placeholder = QPixmap(self.THUMB, self.THUMB) + self._placeholder.fill(QColor(128, 128, 128, 40)) + if album.strip(): + self.search() + + def search(self): + album, artist = self.album.text().strip(), self.artist.text().strip() + if not album: + self.status.setText("Enter an album name to search.") + return + self.results.clear() + self.status.clear() + self._loaded = 0 + self.progress.start("Searching MusicBrainz") + self.progress.set_detail(f"{album} — {artist}" if artist else album) + + def run(task): + for event in covers.search_with_thumbnails(album, artist): + task.progress.emit(event) + + self._task = Task(run) + self._task.progress.connect(self._on_event) + self._task.done.connect(self._on_done) + self._task.failed.connect(self._on_failed) + self._task.start() + + def _stale(self) -> bool: + return self.sender() is not self._task + + def _on_event(self, event): + if self._stale(): + return + if event[0] == "releases": + for rel in event[1]: + item = QListWidgetItem(QIcon(self._placeholder), f"{rel['title']}\n{rel['artist']}\n{rel['details']}") + item.setData(Qt.ItemDataRole.UserRole, rel["id"]) + item.setToolTip(f"{rel['title']}\n{rel['artist']}\n{rel['details']}\nMBID: {rel['id']}") + self.results.addItem(item) + if event[1]: + self.progress.set_title("Loading covers") + self.progress.set_detail(f"{len(event[1])} releases found") + self.progress.set_progress(0, len(event[1])) + else: + self.status.setText("No releases found.") + else: + _, index, data = event + self._loaded += 1 + self.progress.set_progress(self._loaded, self.results.count()) + item = self.results.item(index) + pixmap = QPixmap() + if data and pixmap.loadFromData(data): + item.setIcon(QIcon(pixmap)) + else: + item.setText(item.text() + "\n(no cover)") + item.setFlags(Qt.ItemFlag.NoItemFlags) + + def _on_done(self, _): + if self._stale(): + return + self.progress.finish() + total = self.results.count() + with_art = sum(bool(self.results.item(i).flags() & Qt.ItemFlag.ItemIsEnabled) for i in range(total)) + if total: + self.status.setText(f"{with_art} of {total} releases have cover art.") + + def _on_failed(self, message): + if not self._stale(): + self.progress.finish() + self.status.setText(f"Search failed: {message}") + + def _update_apply(self, item, _previous=None): + self.apply_btn.setEnabled(bool(item and item.flags() & Qt.ItemFlag.ItemIsEnabled)) + + def _apply(self, *_): + item = self.results.currentItem() + if item and item.flags() & Qt.ItemFlag.ItemIsEnabled: + self.source = ("mbid", item.data(Qt.ItemDataRole.UserRole)) + self.accept() + + def _pick_file(self): + path, _ = QFileDialog.getOpenFileName(self, "Choose cover image", "", "Images (*.jpg *.jpeg *.png *.webp *.bmp)") + if path: + self.source = ("file", path) + self.accept() + + +class MainWindow(QMainWindow): + def __init__(self, folder: Path | None = None): + super().__init__() + self.setWindowTitle("expergo") + self.resize(1300, 850) + self.folder: Path | None = None + self.worker: Worker | None = None + + self.model = TrackModel() + self.model.dataChanged.connect(self._update_status) + self.model.modelReset.connect(self._update_status) + self.table = TrackTable(self.model) + + def action(text, slot, icon=None, shortcut=None, tip=None, checkable=False): + act = QAction(text, self, checkable=checkable) + if slot: + act.triggered.connect(slot) + if icon: + act.setIcon(std_icon(*icon)) + if shortcut: + act.setShortcut(QKeySequence(shortcut)) + if tip: + act.setToolTip(tip) + act.setStatusTip(tip.replace("\n", " ")) + return act + + SP = QStyle.StandardPixmap + # Library + open_act = action("Open Folder…", self._pick_folder, ("document-open", SP.SP_DirOpenIcon), "Ctrl+O", + "Scan a folder recursively for FLAC files") + rescan_act = action("Rescan", self.rescan, ("view-refresh", SP.SP_BrowserReload), "F5") + save_act = action("Save", self.save_changes, ("document-save", SP.SP_DialogSaveButton), "Ctrl+S", + "Write edited tags / filenames to disk") + add_act = action("Add Songs…", self.add_songs, ("list-add", SP.SP_FileDialogNewFolder), "Ctrl+N", + "Copy new FLACs into the library (optionally applying the Echo rules)") + reorg_act = action("Reorganize…", self.reorganize, ("folder-new", SP.SP_FileDialogDetailedView), + "Ctrl+Shift+O", "Move every track in the library into Artist / Album folders (with preview)") + # Processing + rules_act = action("Apply Echo Rules", self.process, ("system-run", SP.SP_MediaPlay), "Ctrl+R", + "Rename, resample, fix blocksize, normalize, resize art, fetch LRC\n" + "(selected rows, or all visible rows if none selected)") + lrc_act = action("Fetch Missing LRC", self.fetch_missing_lrc, ("emblem-downloads", SP.SP_ArrowDown), "Ctrl+L", + "Download LRC for rows with missing/empty lyrics\n" + "(selected rows, or all visible rows if none selected)") + art_fetch_act = action("Fetch Missing Art", self.fetch_missing_art, ("folder-pictures", SP.SP_DirLinkIcon), + "Ctrl+Shift+E", + "Download the best-matching MusicBrainz cover for rows without album art,\n" + "once per album (selected rows, or all visible rows if none selected)") + self.stop_action = action("Stop", lambda: self.worker and self.worker.stop(), + ("process-stop", SP.SP_BrowserStop), tip="Stop after the files in progress") + self.stop_action.setEnabled(False) + # Album art (enabled only with a selection) + self.art_edit_action = action("Edit Art…", self.edit_cover, ("image-x-generic", SP.SP_FileDialogContentsView), + "Ctrl+E", "Pick album art from MusicBrainz / Cover Art Archive or a local file\n" + "(applies to all selected rows)") + self.art_remove_action = action("Remove", self.remove_cover, ("edit-delete", SP.SP_TrashIcon), + tip="Remove embedded album art from the selected rows") + self.art_actions = [self.art_edit_action, self.art_remove_action] + # Options + self.nolrc_action = action("Skip LRC Download", None, checkable=True, + tip="Don't fetch lyrics when applying the Echo rules or importing") + self.worker_group = QActionGroup(self) + for n in (1, 2, 4, 8): + act = action(f"{n} Worker{'s' if n > 1 else ''}", None, checkable=True) + act.setData(n) + act.setChecked(n == 1) + self.worker_group.addAction(act) + + self.busy_actions = [open_act, rescan_act, save_act, add_act, reorg_act, rules_act, lrc_act, art_fetch_act] + menubar = self.menuBar() + file_menu = menubar.addMenu("&File") + file_menu.addActions([open_act, rescan_act, save_act]) + file_menu.addSeparator() + file_menu.addActions([add_act, reorg_act]) + file_menu.addSeparator() + file_menu.addAction(action("Quit", self.close, shortcut="Ctrl+Q")) + tracks_menu = menubar.addMenu("&Tracks") + tracks_menu.addActions([rules_act, lrc_act, art_fetch_act]) + tracks_menu.addSeparator() + tracks_menu.addActions(self.art_actions) + tracks_menu.addSeparator() + tracks_menu.addAction(self.stop_action) + options_menu = QMenu("&Options", self) + options_menu.addAction(self.nolrc_action) + options_menu.addSeparator() + options_menu.addSection("Parallel Workers") + options_menu.addActions(self.worker_group.actions()) + menubar.addMenu(options_menu) + + # Toolbar: the common actions, in three groups + tb = QToolBar("Main") + tb.setMovable(False) + tb.setIconSize(QSize(18, 18)) + tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + self.addToolBar(tb) + tb.addActions([open_act, rescan_act, save_act]) + tb.addSeparator() + tb.addActions([add_act, reorg_act]) + tb.addSeparator() + tb.addAction(rules_act) + rules_btn = tb.widgetForAction(rules_act) + rules_btn.setMenu(options_menu) + rules_btn.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup) + tb.addActions([lrc_act, art_fetch_act]) + + self.table.extra_actions = self.art_actions + selection = self.table.selectionModel() + selection.selectionChanged.connect(self._on_selection) + selection.currentRowChanged.connect(self._on_selection) + + self.search = QLineEdit(placeholderText="Filter by any field…", clearButtonEnabled=True) + self.search.textChanged.connect(lambda t: self.table.proxy.set_filter(text=t)) + self.missing_lrc = QCheckBox("Missing LRC") + self.missing_lrc.toggled.connect(lambda c: self.table.proxy.set_filter(missing_lrc=c)) + self.missing_art = QCheckBox("Missing art") + self.missing_art.toggled.connect(lambda c: self.table.proxy.set_filter(missing_art=c)) + filters = QHBoxLayout() + filters.addWidget(self.search, 1) + filters.addWidget(QLabel("Show only:")) + filters.addWidget(self.missing_lrc) + filters.addWidget(self.missing_art) + + self.log_view = QPlainTextEdit(readOnly=True) + self.log_view.setMaximumBlockCount(10000) + log_box = QWidget() + log_layout = QVBoxLayout(log_box) + log_layout.setContentsMargins(0, 0, 0, 0) + log_layout.addWidget(section_label("Log")) + log_layout.addWidget(self.log_view) + self.art_panel = ArtPanel(self.art_actions) + + bottom = QSplitter(Qt.Orientation.Horizontal) + bottom.addWidget(log_box) + bottom.addWidget(self.art_panel) + bottom.setStretchFactor(0, 1) + bottom.setSizes([1000, 280]) + + splitter = QSplitter(Qt.Orientation.Vertical) + splitter.addWidget(self.table) + splitter.addWidget(bottom) + splitter.setSizes([550, 280]) + + self.progress = ProgressStrip(self.stop_action) + + central = QWidget() + layout = QVBoxLayout(central) + layout.addLayout(filters) + layout.addWidget(self.progress) + layout.addWidget(splitter, 1) + self.setCentralWidget(central) + self._on_selection() + + self.status = QLabel() + self.statusBar().addWidget(self.status, 1) + + missing = [tool for tool in REQUIRED_TOOLS if not shutil.which(tool)] + if missing: + self.log(f"WARNING: not found on PATH: {', '.join(missing)} · applying rules will fail.") + + self._update_status() + if folder: + self.load_folder(folder) + + def log(self, text: str): + self.log_view.appendPlainText(text) + + def worker_count(self) -> int: + return self.worker_group.checkedAction().data() + + def _on_selection(self, *_): + tracks = self.table.selected_tracks() + for act in self.art_actions: + act.setEnabled(bool(tracks) and not self.worker) + self.art_panel.show_track(self.table.current_track() if tracks else None, len(tracks)) + + def _update_status(self, *_): + dirty = sum(t.is_dirty() for t in self.model.tracks) + missing = sum(t.lrc != "OK" for t in self.model.tracks) + no_art = sum(not t.art for t in self.model.tracks) + where = str(self.folder) if self.folder else "No folder open" + self.status.setText(f"{where} · {len(self.model.tracks)} tracks · {missing} missing LRC · " + f"{no_art} missing art · {dirty} unsaved") + self.setWindowTitle(("* " if dirty else "") + "expergo") + + def run_worker(self, title, items, fn, on_done=None, workers=None): + self.worker = Worker(items, fn, workers or self.worker_count(), self) + self.worker.log.connect(self.log) + self.worker.progress.connect(self.progress.set_progress) + self.worker.current.connect(lambda name: self.progress.set_detail(name)) + self.worker.finished.connect(lambda: self._on_worker_done(on_done)) + self._set_busy(True) + self.progress.start(title, len(self.worker.items)) + self.worker.start() + + def _on_worker_done(self, on_done): + worker, self.worker = self.worker, None + self.progress.finish() + self._set_busy(False) + if on_done: + on_done(worker) + + def _set_busy(self, busy: bool): + for act in self.busy_actions: + act.setEnabled(not busy) + self.stop_action.setEnabled(busy) + for act in self.art_actions: + act.setEnabled(not busy and bool(self.table.selected_tracks())) + self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers if busy else EDIT_TRIGGERS) + + def _confirm_discard(self) -> bool: + if not any(t.is_dirty() for t in self.model.tracks): + return True + answer = QMessageBox.question(self, "Unsaved changes", "Discard unsaved tag edits?") + return answer == QMessageBox.StandardButton.Yes + + def _pick_folder(self): + folder = QFileDialog.getExistingDirectory(self, "Open music folder", str(self.folder or "")) + if folder: + self.load_folder(Path(folder)) + + def load_folder(self, folder: Path): + if not self._confirm_discard(): + return + self.folder = Path(folder).resolve() + files = find_flacs([self.folder]) + self.log(f"Scanning {self.folder} ({len(files)} FLAC files)…") + + def done(worker): + self.model.set_tracks(sorted((t for _, t in worker.results), key=lambda t: str(t.path).lower())) + self.table.fit_columns() + + self.run_worker("Scanning library", files, Track.load, done, workers=4) + + def rescan(self): + if self.folder: + self.load_folder(self.folder) + + def save_changes(self, tracks=None) -> bool: + ok = True + for track in [t for t in (tracks or self.model.tracks) if t.is_dirty()]: + try: + save_track(track) + self.log(f"Saved {track.name}") + except Exception as e: + ok = False + self.log(f"ERROR saving {track.name}: {e}") + self.model.refresh() + return ok + + def process(self): + tracks = self.table.target_tracks() + if not tracks or not tools_ok(self): + return + answer = QMessageBox.question( + self, + "Apply Echo rules", + f"Apply Echo formatting rules to {len(tracks)} file(s)?\n\n" + "Files are renamed, re-encoded, loudness-normalized and have their album art resized IN PLACE.\n" + "Unsaved edits on these rows are saved first.", + ) + if answer != QMessageBox.StandardButton.Yes or not self.save_changes(tracks): + return + core._claimed_names.clear() + nolrc = self.nolrc_action.isChecked() + self.run_worker("Applying Echo rules", [t.path for t in tracks], lambda p: core.process_file(p, nolrc), + lambda _: self.rescan()) + + def fetch_missing_lrc(self): + tracks = [t for t in self.table.target_tracks() if t.lrc != "OK"] + if not tracks: + self.log("No missing LRC files in the current selection.") + return + + def done(_): + for t in tracks: + t.refresh_lrc() + self.model.refresh() + self.table.proxy.invalidate() + + self.log(f"Fetching LRC for {len(tracks)} track(s)…") + self.run_worker("Fetching lyrics", tracks, fetch_lrc, done) + + def _refresh_art(self, tracks): + for t in tracks: + t.refresh_art() + self.model.refresh() + self.table.proxy.invalidate() + self._on_selection() + + def fetch_missing_art(self): + groups: dict[tuple[str, str], list[Track]] = {} + skipped = 0 + for t in self.table.target_tracks(): + if t.art: + continue + if not t.album_key[1]: + skipped += 1 + continue + groups.setdefault(t.album_key, []).append(t) + if skipped: + self.log(f"Skipped {skipped} track(s) without an Album tag (use Edit Art… for those)") + if not groups: + self.log("No tracks with missing album art in the current selection.") + return + items = [ + SimpleNamespace(key=key, tracks=tracks, name=f"{key[1]} — {key[0]}" if key[0] else key[1]) + for key, tracks in groups.items() + ] + tracks = [t for group in items for t in group.tracks] + self.log(f"Fetching album art for {len(items)} album(s) ({len(tracks)} tracks)…") + self.run_worker("Fetching album art", items, auto_cover, lambda _: self._refresh_art(tracks)) + + def edit_cover(self): + tracks = self.table.selected_tracks() + if not tracks: + return + first = self.table.current_track() or tracks[0] + dialog = CoverDialog(first.tags["ALBUM"], first.tags["ALBUMARTIST"] or first.tags["ARTIST"], len(tracks), self) + if dialog.exec() and dialog.source: + source = dialog.source + self.log(f"Applying album art ({source[0]}: {source[1]}) to {len(tracks)} track(s)…") + self.run_worker("Applying album art", [source], lambda s: apply_cover(s, tracks), + lambda _: self._refresh_art(tracks), workers=1) + origin = "Cover Art Archive" if source[0] == "mbid" else Path(source[1]).name + self.progress.set_detail(f"Downloading from {origin} and embedding into {len(tracks)} track(s)") + dialog.deleteLater() + + def remove_cover(self): + tracks = self.table.selected_tracks() + if not tracks: + return + answer = QMessageBox.question(self, "Remove album art", f"Remove embedded album art from {len(tracks)} track(s)?") + if answer != QMessageBox.StandardButton.Yes: + return + def remove(track): + covers.write_cover(track.path, None) + return f"Removed album art from {track.name}" + + self.run_worker("Removing album art", tracks, remove, lambda _: self._refresh_art(tracks)) + + def reorganize(self): + if not self.folder or not self.model.tracks: + self.log("Open a library folder first.") + return + if any(t.is_dirty() for t in self.model.tracks): + answer = QMessageBox.question( + self, "Reorganize", "Folders are based on the tags saved on disk. Save your unsaved edits first?", + QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Ignore | QMessageBox.StandardButton.Cancel, + ) + if answer == QMessageBox.StandardButton.Cancel: + return + if answer == QMessageBox.StandardButton.Save and not self.save_changes(): + return + dialog = ReorganizeDialog(self.model.tracks, self.folder, self) + accepted = dialog.exec() + moves, cleanup = dialog.moves, dialog.cleanup.isChecked() + dialog.deleteLater() + if not accepted or not moves: + return + + library_root = self.folder + + def done(worker): + moved = len(worker.results) + msg = f"Moved {moved} of {len(moves)} file(s) into Artist / Album folders" + if cleanup and library_root: + msg += f", removed {remove_empty_dirs(library_root)} empty folder(s)" + self.log(msg) + self.rescan() + + self.log(f"Reorganizing {len(moves)} file(s)…") + self.run_worker("Reorganizing folders", moves, move_track, done, workers=1) + + def add_songs(self): + dialog = ImportDialog(self.folder, self.worker_count(), self.nolrc_action.isChecked(), self) + dialog.imported.connect(self.rescan) + dialog.exec() + dialog.deleteLater() + + def closeEvent(self, event): + if self.worker: + QMessageBox.information(self, "Busy", "A task is still running. Stop it first.") + event.ignore() + elif self._confirm_discard(): + event.accept() + else: + event.ignore() + + +def main(): + app = QApplication(sys.argv) + window = MainWindow(Path(sys.argv[1]) if len(sys.argv) > 1 else None) + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/main.py b/main.py deleted file mode 100644 index e1d70a1..0000000 --- a/main.py +++ /dev/null @@ -1,244 +0,0 @@ -import argparse -import subprocess -import threading -import time -from concurrent.futures import ThreadPoolExecutor, as_completed -from pathlib import Path -from typing import Iterator -from tqdm import tqdm -import syncedlyrics -from mutagen.flac import FLAC - -_lyrics_semaphore = threading.Semaphore(3) - -_claimed_names: set[Path] = set() -_claimed_names_lock = threading.Lock() - - -def iter_files(base: Path) -> Iterator[Path]: - iterator = base.rglob("*") - for p in iterator: - if p.is_file(): - yield p.resolve() - - -def rename_file(filepath: Path, new_name: str) -> Path: - target = filepath.with_name(new_name) - filepath.rename(target) - lrc, new_lrc = filepath.with_suffix(".lrc"), target.with_suffix(".lrc") - if lrc.exists() and not new_lrc.exists(): - lrc.rename(new_lrc) - return target - - -def download_lrc(lrc_path: Path, title: str, artist: str) -> bool: - with _lyrics_semaphore: - lrc = syncedlyrics.search(f"{title} {artist}", providers=["Lrclib", "Megalobiz", "NetEase"]) - time.sleep(0.3) - - with open(lrc_path, "w", encoding="utf-8") as f: - f.write(lrc if lrc else "") - return bool(lrc) - - -def get_audio_issues(path: Path) -> dict: - audio = FLAC(str(path)) - info = audio.info - sample_rate = getattr(info, "sample_rate", 0) - bits_per_sample = getattr(info, "bits_per_sample", 24) - max_blocksize = getattr(info, "max_blocksize", 4096) - - return { - "needs_sample_rate_fix": sample_rate > 192000, - "needs_bitdepth_fix": bits_per_sample > 24, - "needs_blocksize_fix": max_blocksize > 4096, - "sample_rate": sample_rate, - "bits_per_sample": bits_per_sample, - "max_blocksize": max_blocksize, - } - - -def fix_with_ffmpeg(path: Path, fix_sample_rate: bool, fix_bitdepth: bool) -> Path: - args = [ - "ffmpeg", "-y", - "-i", str(path), - "-map", "0:a", - "-map", "0:v?", - "-c:v", "copy", - "-acodec", "flac", - ] - if fix_bitdepth: - args += ["-sample_fmt", "s24"] - if fix_sample_rate: - args += ["-ar", "192000"] - - temp_path = path.with_suffix(".tmp.flac") - args.append(str(temp_path)) - subprocess.run(args, capture_output=True, text=True, check=True) - path.unlink() - temp_path.rename(path) - return path - - -def fix_blocksize(path: Path, blocksize: int = 4096) -> Path: - temp_path = path.with_suffix(".tmp.flac") - command = ["flac", "--force", f"--blocksize={blocksize}", str(path), "-o", str(temp_path)] - try: - subprocess.run(command, capture_output=True, text=True, check=True) - path.unlink() - temp_path.rename(path) - except subprocess.CalledProcessError as e: - temp_path.unlink(missing_ok=True) - print(f" Error fixing blocksize for {path.name}:") - print(e.stderr) - return path - - -def get_track_info(path: Path) -> tuple: - audio = FLAC(str(path)) - title = audio.get("TITLE", [""]) - artist = audio.get("ARTIST", [""]) - album = audio.get("ALBUM", [""]) - return (title[0], artist[0], album[0]) - - -def resize_album_art(path: Path) -> None: - audio = FLAC(str(path)) - if not audio.pictures: - return - from io import BytesIO - from PIL import Image - for pic in audio.pictures: - with Image.open(BytesIO(pic.data)) as img: - resized = img.resize((500, 500), Image.Resampling.LANCZOS) - out = BytesIO() - fmt = img.format if img.format else "PNG" - resized.save(out, format=fmt) - pic.data = out.getvalue() - audio.save() - - -def normalize_loudness(path: Path, target_lufs: float = -14.0, target_tp: float = -1.0) -> Path: - temp_path = path.with_suffix(".tmp.flac") - subprocess.run( - [ - "ffmpeg-normalize", str(path), - "-o", str(temp_path), - "-c:a", "flac", - "-t", str(target_lufs), - "--true-peak", str(target_tp), - "-f", - "-e", "-map 0:v? -c:v copy", - ], - capture_output=True, - text=True, - check=True, - ) - path.unlink() - temp_path.rename(path) - return path - - -def sanitize_filename(name: str) -> str: - illegal = r'\/:*?"<>|' - return "".join(c for c in name if c not in illegal).strip() - - -def process_file(fp: Path, nolrc: bool) -> str: - lines = [] - log = lines.append - - log(f"\nProcessing: {fp.name}") - title, artist, album = get_track_info(fp) - - if not title: - log(f" Warning: TITLE tag is empty, using filename as title") - title = fp.stem - artist = "UNKNOWN ARTIST" - - new_stem = sanitize_filename(f"{title} - {artist}") - with _claimed_names_lock: - target = fp.with_name(new_stem + ".flac") - conflict = (target.resolve() in _claimed_names) or ( - target.exists() and target.resolve() != fp.resolve() - ) - if conflict: - if album: - log(f" Conflict detected, adding album name as differentiator") - new_stem = sanitize_filename(f"{title} - {artist} ({album})") - else: - log(f" Warning: filename conflict but no album tag, keeping original name") - new_stem = fp.stem - new_file_name = new_stem + ".flac" - _claimed_names.add(fp.with_name(new_file_name).resolve()) - if new_file_name != fp.name: - rename_file(fp, new_file_name) - fp = fp.with_name(new_file_name) - - issues = get_audio_issues(fp) - log(f" Stats: {issues['sample_rate']}Hz, {issues['bits_per_sample']}-bit, blocksize={issues['max_blocksize']}") - - if issues["needs_sample_rate_fix"] or issues["needs_bitdepth_fix"]: - reasons = [] - if issues["needs_sample_rate_fix"]: - reasons.append(f"sample rate {issues['sample_rate']}Hz -> 192000Hz") - if issues["needs_bitdepth_fix"]: - reasons.append(f"bit depth {issues['bits_per_sample']}-bit -> 24-bit") - log(f" Fixing via ffmpeg: {', '.join(reasons)}") - fp = fix_with_ffmpeg(fp, issues["needs_sample_rate_fix"], issues["needs_bitdepth_fix"]) - - log(" Normalizing loudness to -14 LUFS") - fp = normalize_loudness(fp) - - post_blocksize = getattr(FLAC(str(fp)).info, "max_blocksize", 4096) - if post_blocksize > 4096: - log(f" Fixing blocksize -> 4096 via flac CLI") - fp = fix_blocksize(fp) - - log(" Resizing album art to 500x500") - resize_album_art(fp) - - if nolrc: - return "\n".join(lines) - - if not title or not artist: - log(f" Skipping LRC for {fp.name} (missing title or artist tag)") - return "\n".join(lines) - - lrc_path = fp.with_suffix(".lrc") - if lrc_path.exists(): - log(f" Skipping LRC for {fp.name} (already exists)") - return "\n".join(lines) - - log(f" Fetching LRC for: {title} - {artist}") - download_lrc(lrc_path, title, artist) - - return "\n".join(lines) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("base_dir", type=Path) - parser.add_argument("--nolrc", action="store_true", dest="nolrc") - parser.add_argument("-n", "--workers", type=int, default=1, dest="workers", - help="Number of parallel workers (default: 1)") - args = parser.parse_args() - - files = [p for p in iter_files(args.base_dir) if p.suffix == ".flac"] - - with ThreadPoolExecutor(max_workers=args.workers) as executor: - futures = {executor.submit(process_file, fp, args.nolrc): fp for fp in files} - with tqdm(total=len(files), desc="Processing FLAC files", unit="file") as pbar: - for future in as_completed(futures): - try: - result = future.result() - tqdm.write(result) - except Exception as e: - fp = futures[future] - tqdm.write(f"\n ERROR processing {fp.name}: {e}") - finally: - pbar.update(1) - - -if __name__ == "__main__": - main() diff --git a/models.py b/models.py new file mode 100644 index 0000000..01f91c2 --- /dev/null +++ b/models.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from types import SimpleNamespace + +from mutagen.flac import FLAC + +import covers +import expergo as core + +FIELDS = [ + ("FILE", "Filename"), + ("TITLE", "Title"), + ("ARTIST", "Artist"), + ("ALBUM", "Album"), + ("ALBUMARTIST", "Album Artist"), + ("GENRE", "Genre"), +] +TAG_KEYS = [key for key, _ in FIELDS[1:]] +LRC_COL = len(FIELDS) +ART_COL = LRC_COL + 1 +HEADERS = [label for _, label in FIELDS] + ["LRC", "Art"] + + +def lrc_status(path: Path) -> str: + lrc = path.with_suffix(".lrc") + if not lrc.exists(): + return "Missing" + return "OK" if lrc.stat().st_size > 0 else "Empty" + + +def find_flacs(paths) -> list[Path]: + found = [] + for p in map(Path, paths): + if p.is_dir(): + found += core.find_flacs(p) + else: + r = p.resolve() + if r.is_file() and r.suffix.lower() == ".flac" and not r.name.endswith(".tmp.flac"): + found.append(r) + return sorted(set(found)) + + +def flac_name(name: str) -> str: + name = core.sanitize_filename(name) + return name if name.lower().endswith(".flac") else name + ".flac" + + +@dataclass +class Track: + path: Path + orig: dict + tags: dict + lrc: str + art: bool + + @classmethod + def load(cls, path: Path) -> Track: + audio = FLAC(str(path)) + orig = {"FILE": path.name, **{k: audio.get(k, [""])[0] for k in TAG_KEYS}} + return cls(path, orig, dict(orig), lrc_status(path), bool(audio.pictures)) + + @property + def name(self) -> str: + return self.path.name + + def is_dirty(self, key: str | None = None) -> bool: + return self.tags[key] != self.orig[key] if key else self.tags != self.orig + + def refresh_lrc(self) -> None: + self.lrc = lrc_status(self.path) + + def refresh_art(self) -> None: + try: + self.art = bool(FLAC(str(self.path)).pictures) + except Exception: + self.art = False + + @property + def album_key(self) -> tuple[str, str]: + return (self.tags["ALBUMARTIST"] or self.tags["ARTIST"]).strip(), self.tags["ALBUM"].strip() + + +def write_tags(path: Path, track: Track) -> None: + """Write only edited tags so untouched multi-value tags are preserved.""" + changed = [k for k in TAG_KEYS if track.is_dirty(k)] + if not changed: + return + audio = FLAC(str(path)) + for key in changed: + value = track.tags[key].strip() + if value: + audio[key] = value + elif key in audio: + del audio[key] + audio.save() + + +def save_track(track: Track) -> None: + write_tags(track.path, track) + if track.is_dirty("FILE"): + name = flac_name(track.tags["FILE"]) + if name == ".flac": + raise ValueError("filename cannot be empty") + target = track.path.with_name(name) + if target.exists() and not target.samefile(track.path): + raise FileExistsError(f"{name} already exists") + track.path = core.rename_file(track.path, name) + track.tags["FILE"] = track.path.name + track.orig = dict(track.tags) + track.refresh_lrc() + + +class FolderLayout: + """GUI adapter over main.FolderLayout (GUI tags use uppercase keys).""" + + def __init__(self, root: Path, prefer_album_artist: bool = True): + self._core = core.FolderLayout(root, prefer_album_artist) + + def folder_for(self, tags: dict) -> Path: + return self._core.folder_for({ + "artist": tags.get("ARTIST", ""), + "albumartist": tags.get("ALBUMARTIST", ""), + "album": tags.get("ALBUM", ""), + }) + + +def plan_reorganize(tracks: list[Track], root: Path, prefer_album_artist: bool) -> list[SimpleNamespace]: + plan = core.plan_reorg([t.path for t in tracks], root, prefer_album_artist) + names = {t.path: t.name for t in tracks} + return [SimpleNamespace(name=names.get(m.src, m.src.name), src=m.src, dst=m.dst, conflict=m.conflict) for m in plan] + + +def move_track(item: SimpleNamespace) -> str: + return core.move_item(core.MoveItem(src=item.src, dst=item.dst, conflict=False)) + + +def remove_empty_dirs(root: Path) -> int: + return core.remove_empty_dirs(root) + + +def fetch_lrc(track: Track) -> str: + return core.auto_lrc_file(track.path, force=False) + + +def apply_cover(source: tuple[str, str], tracks: list[Track]) -> str: + jpeg = covers.prepare_cover(covers.load_source(source)) + for track in tracks: + covers.write_cover(track.path, jpeg) + return f"Applied album art to {len(tracks)} track(s)" + + +def auto_cover(group) -> str: + return core.auto_cover_group(core.AlbumGroup(key=group.key, tracks=[t.path for t in group.tracks])) diff --git a/pyproject.toml b/pyproject.toml index 9364074..6b2ecff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,8 @@ dependencies = [ "ffmpeg-python>=0.2.0", "mutagen>=1.47.0", "pillow>=12.2.0", + "pyqt6>=6.11.0", + "requests>=2.33.1", "syncedlyrics>=1.0.1", "tqdm>=4.67.3", ] diff --git a/uv.lock b/uv.lock index 2503869..87a9a4e 100644 --- a/uv.lock +++ b/uv.lock @@ -151,6 +151,8 @@ dependencies = [ { name = "ffmpeg-python" }, { name = "mutagen" }, { name = "pillow" }, + { name = "pyqt6" }, + { name = "requests" }, { name = "syncedlyrics" }, { name = "tqdm" }, ] @@ -161,6 +163,8 @@ requires-dist = [ { name = "ffmpeg-python", specifier = ">=0.2.0" }, { name = "mutagen", specifier = ">=1.47.0" }, { name = "pillow", specifier = ">=12.2.0" }, + { name = "pyqt6", specifier = ">=6.11.0" }, + { name = "requests", specifier = ">=2.33.1" }, { name = "syncedlyrics", specifier = ">=1.0.1" }, { name = "tqdm", specifier = ">=4.67.3" }, ] @@ -250,6 +254,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, ] +[[package]] +name = "pyqt6" +version = "6.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyqt6-qt6" }, + { name = "pyqt6-sip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/47/b25c13eca5bebc6505394d0223e46d7ebf0c57dcac2ed908d7d19b18ab6b/pyqt6-6.11.0.tar.gz", hash = "sha256:45dd60aa69976de1918b5ced6b4e7b6a25abd2a919ecef5fd5826ecc76718889", size = 1087430, upload-time = "2026-03-30T09:16:13.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/44/fcd3dd3f64c83c96bf9bce76ec16cca64bd9b91702c3d08fd8e3dafc73d9/pyqt6-6.11.0-cp310-abi3-macosx_10_14_universal2.whl", hash = "sha256:f7100bc7f72b12581ec479a733f4ad11b8002668e6786e8a445ab6f4d1c743d4", size = 12429735, upload-time = "2026-03-30T09:16:03.713Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a0/bd1399740dfa80c0a94d20b02d89962a31458233dcf70eaa09bfbccf3d0f/pyqt6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8555277989fa7d114cb3c3443fd261d566909f7268ceedd41d93a5f02d37ec05", size = 8334632, upload-time = "2026-03-30T09:16:06.066Z" }, + { url = "https://files.pythonhosted.org/packages/d3/db/425b184ac2430ba1978bb507ffd285ec007a872644e2ae5df13332dbcb05/pyqt6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:0734959955adde095af9a074213a7f73386d1bbbddfc27346b4c0621641a692e", size = 8321484, upload-time = "2026-03-30T09:16:08.135Z" }, + { url = "https://files.pythonhosted.org/packages/6f/85/dd9f03d78d87460e109e0121cd6201c5802bdd655656bf2780e964870fea/pyqt6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:bd11b459c54dca068e988a42cf838303334f0d441b9d16d92ae6719fcb5ac6ba", size = 6844358, upload-time = "2026-03-30T09:16:09.766Z" }, + { url = "https://files.pythonhosted.org/packages/cd/75/970b041bde4372cc6739c5ef9db1de83a6b36e788e4992e598baa35b2255/pyqt6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:b6324e3501b19b4292c7a55b1f22e82d3e80e519e383ce4fe79b4a754c6f0288", size = 5933984, upload-time = "2026-03-30T09:16:11.817Z" }, +] + +[[package]] +name = "pyqt6-qt6" +version = "6.11.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/d2/79c88102ff88efcdae338a0ffbb5fa40b70194e0095db5ddec490106bcc6/pyqt6_qt6-6.11.2-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:6a4372baa674ca91de5caf31f241b0660fd3da90fe27fefed9a7a459e630ea89", size = 70517918, upload-time = "2026-08-23T12:59:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/a0/72/f49e11b46eaa887e1250658b9c038f13c654ca7478c7c904ada13bb5133e/pyqt6_qt6-6.11.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4f21ff2ffaacfb8a8df6ab1d619d0da7aa4e808e86f41b6daa1a7bff9c0d6eab", size = 64259572, upload-time = "2026-08-23T12:59:40.759Z" }, + { url = "https://files.pythonhosted.org/packages/69/ef/e34054e814c874f5230326ffc791f602a13b2f5590ca9599ba45b5fc4bc0/pyqt6_qt6-6.11.2-py3-none-manylinux_2_34_x86_64.whl", hash = "sha256:da7d747ad917f044d841ff834f17d2c50a0cc5d0e3b16479d8662544207d0567", size = 86012424, upload-time = "2026-08-23T12:59:45.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/01/86f3c30ceee9384d905424a28abbcba6e9489f44ca15b2f4ae74921f079c/pyqt6_qt6-6.11.2-py3-none-manylinux_2_39_aarch64.whl", hash = "sha256:2c38ee8b797c0d7f327afef4cfbb97c73676954ef2d1d503779d62c982bf5a66", size = 85506374, upload-time = "2026-08-23T12:59:51.195Z" }, + { url = "https://files.pythonhosted.org/packages/f6/56/62457dd9b5738f65b9fb4ac6b3120bc0bfa0bc335857fdd456b94b0a6079/pyqt6_qt6-6.11.2-py3-none-win_amd64.whl", hash = "sha256:4b424ed1babbef07133eb2a4174c56c848d518767bca8e759aca2c8c9c313636", size = 78000994, upload-time = "2026-08-23T12:59:56.429Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/3ce7dc9172b798e191b82296d90f7b89fcd94f111dd249b71cf7acaa803b/pyqt6_qt6-6.11.2-py3-none-win_arm64.whl", hash = "sha256:8c49432936681f325f2925f20ceef35b8fb7599e0c3e63e76d6e50397354ee1f", size = 61679846, upload-time = "2026-08-23T13:00:01.138Z" }, +] + +[[package]] +name = "pyqt6-sip" +version = "13.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/23/16c583dbb6b53e0494dfcf7d1a44778c82e324edda62326727b37f1a5b34/pyqt6_sip-13.12.0.tar.gz", hash = "sha256:a7ad45c1e3cec3a2473d37ea9870b6c3baeccc560298623c8eb59265714c06e2", size = 93979, upload-time = "2026-08-02T12:04:47.231Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/68/dd27f520678ff7b6cbafc54569d606409481b3bb251dbc0a9bb2a7423701/pyqt6_sip-13.12.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:85ac64530f6e7362a853fea1ce50e91f026cb87b411abe7a9efe4edb844458fa", size = 112520, upload-time = "2026-08-02T12:04:33.107Z" }, + { url = "https://files.pythonhosted.org/packages/34/9a/3f8127196bb2e303914c92670590633a7b3141aca640dc0122c326366c3d/pyqt6_sip-13.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5b5610a21401dbf97eaa82a2d35e727d3269ef370f299a892ff154f4b6f338ca", size = 299892, upload-time = "2026-08-02T12:04:35.614Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/2ec8cd46fb9ac112112792f879706afd4f2097f69dd8893627923110c30e/pyqt6_sip-13.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84b5df633e3c7bc5faaec18b71f45d297002ee258350f823648b6f447763524c", size = 321398, upload-time = "2026-08-02T12:04:34.296Z" }, + { url = "https://files.pythonhosted.org/packages/2d/63/f74d1de19822c69a5a6d0e9c59e06f05ab37e4502dbb17cea93d799ac0b6/pyqt6_sip-13.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:99a9016b9f6f23e4446703ba2c2271b35a3ff2fd5115e607b9fac675774abea9", size = 53342, upload-time = "2026-08-02T12:04:36.717Z" }, + { url = "https://files.pythonhosted.org/packages/b3/48/3be6fa82006c8e7827bbdcdff9673c291e4da1ec5152a8404c4765b0a8c0/pyqt6_sip-13.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:2431eef7617f7cd26d22789178a57a688ddbca814fd5215fbe577f4bf45569b8", size = 48714, upload-time = "2026-08-02T12:04:37.835Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f8/7d9e8c559446d4fe738e3faebb75c0c172093a15dd6be29771aad8b012a9/pyqt6_sip-13.12.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:beeebe34fdb4d2997310f60a618e332a7097db4f52ae970f5f0a6b8e7105cb18", size = 112587, upload-time = "2026-08-02T12:04:40.657Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4b/b6f552e79b8d5c80370b9691a381d26e09d7b5fb9e434cb444b0b436f8a8/pyqt6_sip-13.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:309a69feb94dfc0bec7e224055db376b157ce6d88b94eb4076d2e913b4c62b47", size = 299624, upload-time = "2026-08-02T12:04:43.669Z" }, + { url = "https://files.pythonhosted.org/packages/14/ee/f7c22e6a43ce23bc05fc481f1e7c8dc3ba06e9870b045adc9377234b6fbd/pyqt6_sip-13.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0874073287e6d5b900768b95fca3f0ea23209c1bef5321bb2422f5dca455a899", size = 321825, upload-time = "2026-08-02T12:04:41.713Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/30e57a4ceeb222348e60862183e3e7159490944b428230f728b465614928/pyqt6_sip-13.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:9717fc4271e5d71e1cecadbb3340c3990ea9d58923fbf076b70cf8bc0f339639", size = 54971, upload-time = "2026-08-02T12:04:45.001Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cb/806b3fcc7ab9dc77fc42a26dc010f7d87aa32c230af3ddebb958eed02e80/pyqt6_sip-13.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:6815e719d1fd4c71c0d3ed85e7e91b615959d949dac7af6e3f11a29a3a565676", size = 49576, upload-time = "2026-08-02T12:04:46.074Z" }, +] + [[package]] name = "rapidfuzz" version = "3.14.5" -- cgit v1.2.3