# =========================================
# Файл: .gitignore
# =========================================

__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.env

transcribed/
output/
*.wav
*.flac
*.mp3
!tests/fixtures/*

preset.json
*.log


# =========================================
# Файл: main.py
# =========================================

#!/usr/bin/env python3
"""Song Transcriber — Qt GUI entry point."""
from __future__ import annotations

import ctypes
import os
import sys

# Must be set BEFORE PySide6 is imported.
os.environ.setdefault("QT_LOGGING_RULES", "qt.qpa.wayland.textinput=false")


def preload_cudnn() -> None:
    try:
        import nvidia.cudnn
    except ImportError:
        return
    lib_dir = os.path.join(nvidia.cudnn.__path__[0], "lib")
    if not os.path.isdir(lib_dir):
        return
    for fname in sorted(os.listdir(lib_dir)):
        if ".so" in fname:
            try:
                ctypes.CDLL(os.path.join(lib_dir, fname), mode=ctypes.RTLD_GLOBAL)
            except OSError:
                pass


def enable_tf32() -> None:
    """Enable TF32 for faster matmul on Ampere+ GPUs (RTX 30/40 series)."""
    try:
        import torch
        if torch.cuda.is_available():
            torch.backends.cuda.matmul.allow_tf32 = True
            torch.backends.cudnn.allow_tf32 = True
    except ImportError:
        pass


def main() -> int:
    preload_cudnn()
    enable_tf32()

    from PySide6.QtWidgets import QApplication
    from gui.theme import DARK_STYLESHEET
    from gui.main_window import MainWindow

    app = QApplication(sys.argv)
    app.setApplicationName("Audio Transcriber")
    app.setStyle("Fusion")
    app.setStyleSheet(DARK_STYLESHEET)

    window = MainWindow()
    window.show()
    return app.exec()


if __name__ == "__main__":
    sys.exit(main())


# =========================================
# Файл: LICENSE
# =========================================

MIT License

Copyright (c) 2026 jads

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 
associated documentation files (the "Software"), to deal in the Software without restriction, including 
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the 
following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial 
portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE 
USE OR OTHER DEALINGS IN THE SOFTWARE.


# =========================================
# Файл: core/separate.py
# =========================================

"""Vocal separation via unblend (Mel-Band RoFormer).

Two entry points:

- separate_vocals()       single file, subprocess per call (sequential mode)
- separate_vocals_batch() many files in ONE subprocess, model loaded once
                          (staged mode — big speedup on batches)
"""
from __future__ import annotations

import os
import subprocess
from pathlib import Path


def _build_env(custom_kernels: bool) -> dict:
    env = os.environ.copy()
    env["UNBLEND_CUSTOM_KERNELS"] = "1" if custom_kernels else "0"
    return env


def _check_result(result: subprocess.CompletedProcess, what: str) -> None:
    if result.returncode != 0:
        raise RuntimeError(
            f"unblend failed ({what}):\n{result.stderr.strip()[:1200]}"
        )


def separate_vocals(
    input_wav: Path,
    work_dir: Path,
    model: str = "bs_roformer_anvuew",
    isolate_stem: str = "vocals",
    unblend_path: str = "unblend",
    custom_kernels: bool = False,
    device: str = "cuda",
) -> Path:
    """Separate one file. Returns path to the isolated stem WAV."""
    work_dir.mkdir(parents=True, exist_ok=True)
    output_pattern = work_dir / "{track}" / "{stem}.wav"
    cmd = [
        unblend_path, "separate", str(input_wav),
        "-m", model,
        "-o", str(output_pattern),
        "--isolate-stem", isolate_stem,
        "--device", device,
    ]
    result = subprocess.run(cmd, capture_output=True, text=True, env=_build_env(custom_kernels))
    _check_result(result, input_wav.name)

    candidates = sorted(work_dir.rglob(f"{isolate_stem}.wav"))
    if not candidates:
        raise RuntimeError(
            f"unblend finished but stem '{isolate_stem}.wav' was not found in {work_dir}"
        )
    return candidates[0]


def separate_vocals_batch(
    input_wavs: list[Path],
    work_root: Path,
    model: str = "bs_roformer_anvuew",
    isolate_stem: str = "vocals",
    unblend_path: str = "unblend",
    custom_kernels: bool = False,
    device: str = "cuda",
) -> dict[Path, Path]:
    """Separate many files in one unblend invocation.

    Input WAVs are expected to live in distinct directories
    (e.g. <work_root>/<stem>/convert.wav). The output pattern uses {parent}
    so each result lands next to its input as <parent>/vocals.wav.

    Returns: {input_path: vocal_path}
    """
    if not input_wavs:
        return {}

    work_root.mkdir(parents=True, exist_ok=True)
    output_pattern = work_root / "{parent}" / "{stem}.wav"

    cmd = [
        unblend_path, "separate",
        *[str(p) for p in input_wavs],
        "-m", model,
        "-o", str(output_pattern),
        "--isolate-stem", isolate_stem,
        "--device", device,
    ]

    result = subprocess.run(cmd, capture_output=True, text=True, env=_build_env(custom_kernels))
    _check_result(result, f"{len(input_wavs)} files")

    mapping: dict[Path, Path] = {}
    missing: list[Path] = []
    for src in input_wavs:
        candidate = src.parent / f"{isolate_stem}.wav"
        if candidate.exists():
            mapping[src] = candidate
        else:
            missing.append(src)

    if missing:
        # Fallback: search anywhere under work_root
        found = sorted(work_root.rglob(f"{isolate_stem}.wav"))
        if len(found) == len(input_wavs):
            for src, dst in zip(input_wavs, found):
                mapping[src] = dst
        else:
            names = ", ".join(p.name for p in missing[:3])
            raise RuntimeError(
                f"unblend finished but outputs missing for {len(missing)} file(s): {names}"
            )

    return mapping


# =========================================
# Файл: core/init.py
# =========================================

"""Core pipeline: convert, separate, transcribe."""


# =========================================
# Файл: core/convert.py
# =========================================

"""Convert any audio file to a WAV with chosen sample rate and channels via ffmpeg."""
from __future__ import annotations

import subprocess
from pathlib import Path


def convert_to_wav(
    input_path: Path,
    output_wav: Path,
    sample_rate: int = 44100,
    channels: int = 2,
    ffmpeg_path: str = "ffmpeg",
) -> None:
    """Run ffmpeg to produce a WAV file. Raises RuntimeError on ffmpeg failure."""
    output_wav.parent.mkdir(parents=True, exist_ok=True)
    cmd = [
        ffmpeg_path, "-y", "-hide_banner", "-loglevel", "error",
        "-i", str(input_path),
        "-ar", str(sample_rate),
        "-ac", str(channels),
        str(output_wav),
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        raise RuntimeError(
            f"ffmpeg failed for {input_path.name}:\n{result.stderr.strip()[:800]}"
        )


# =========================================
# Файл: core/metadata.py
# =========================================

"""Read audio file tags via mutagen. Always safe — never raises."""
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Optional


@dataclass
class TrackMetadata:
    title: Optional[str] = None
    artist: Optional[str] = None
    album: Optional[str] = None
    duration: Optional[float] = None
    has_identity: bool = False


def read_metadata(path: Path) -> TrackMetadata:
    """Extract title/artist/album/duration from tags. Never raises."""
    try:
        from mutagen import File
    except ImportError:
        return TrackMetadata()

    try:
        audio = File(str(path), easy=True)
    except Exception:
        return TrackMetadata()

    if audio is None:
        return TrackMetadata()

    def _first(key: str) -> Optional[str]:
        v = audio.get(key)
        if isinstance(v, list):
            return v[0] if v else None
        if isinstance(v, str):
            return v
        return None

    title = _first("title")
    artist = _first("artist")
    album = _first("album")

    duration: Optional[float] = None
    try:
        if audio.info is not None:
            duration = float(audio.info.length)
    except Exception:
        pass

    return TrackMetadata(
        title=title,
        artist=artist,
        album=album,
        duration=duration,
        has_identity=bool(title and artist),
    )


# =========================================
# Файл: core/pipeline.py
# =========================================

"""Orchestrate identify -> convert -> separate -> transcribe -> lyrics-match.

Pipeline order per track (short-circuits as soon as text is found):
  1. metadata tags -> LRCLIB search (if `use_tags_first`)
  2. Shazam fingerprint (if enabled)
  3. Shazam result -> LRCLIB search (if `use_shazam_result`)
  4. full pipeline: convert -> separate -> transcribe
  5. post-ASR: LRCLIB search by recognized text (fuzzy match)

Two modes:
  - sequential: run all of the above per file
  - staged:     identification pass first, then convert/separate/transcribe
                passes only for unresolved files, then post-ASR lyrics pass.
"""
from __future__ import annotations

import difflib
import json
import shutil
import tempfile
import threading
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Optional

from .config import PipelineConfig
from .convert import convert_to_wav
from .lyrics import LyricsMatch, search_by_identity, search_by_text
from .metadata import TrackMetadata, read_metadata
from .recognize import ShazamResult, recognize
from .separate import separate_vocals, separate_vocals_batch
from .transcribe import transcribe


AUDIO_EXTENSIONS = {
    ".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".opus", ".wma",
}

LogFn = Callable[[str], None]
ProgressFn = Callable[[int, int, str], None]


@dataclass
class FileResult:
    input_path: Path
    text: Optional[str]
    output_dir: Optional[Path]
    error: Optional[str] = None
    source: Optional[str] = None
    metadata: Optional[dict] = field(default=None)


class Cancelled(Exception):
    """Raised when the stop event is set."""


# ---------------------------------------------------------------- helpers

def collect_audio_files(input_dir: Path, recursive: bool) -> list[Path]:
    it = input_dir.rglob("*") if recursive else input_dir.iterdir()
    return sorted(p for p in it if p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS)


def _common_parent(paths: list[Path]) -> Path:
    """Return the deepest common parent directory of the given paths."""
    if not paths:
        raise ValueError("empty paths")
    parts = [p.resolve().parts for p in paths]
    common = []
    for chunk in zip(*parts):
        if all(c == chunk[0] for c in chunk):
            common.append(chunk[0])
        else:
            break
    if not common:
        return paths[0].parent
    return Path(*common)


def _check_cancel(stop_event: Optional[threading.Event]) -> None:
    if stop_event is not None and stop_event.is_set():
        raise Cancelled()


def _text_head(text: str, n_words: int = 8) -> str:
    return " ".join(text.split()[:n_words])


def _build_metadata_dict(
    metadata: TrackMetadata,
    shazam: Optional[ShazamResult],
    source: Optional[str],
) -> Optional[dict]:
    d: dict = {}
    if metadata.has_identity:
        d["tags"] = {
            "title": metadata.title,
            "artist": metadata.artist,
            "album": metadata.album,
            "duration": metadata.duration,
        }
    if shazam:
        d["shazam"] = {
            "title": shazam.title,
            "artist": shazam.artist,
            "album": shazam.album,
            "year": shazam.year,
        }
    if source:
        d["source"] = source
    return d if d else None


def _write_metadata_json(path: Path, data: Optional[dict]) -> None:
    if not data:
        return
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(
        json.dumps(data, indent=2, ensure_ascii=False),
        encoding="utf-8",
    )


@dataclass
class IdentifyResult:
    metadata: TrackMetadata
    shazam: Optional[ShazamResult] = None
    text: Optional[str] = None
    source: Optional[str] = None
    needs_asr: bool = True


def _identify_file(
    input_path: Path,
    work_dir: Path,
    config: PipelineConfig,
    log: LogFn,
) -> IdentifyResult:
    """Run cheap identification steps. May populate .text and .source,
    and set .needs_asr=False if the track is fully resolved."""
    result = IdentifyResult(metadata=read_metadata(input_path))

    # --- tags -> LRCLIB ---
    if (
        config.lyrics.enabled
        and config.lyrics.use_tags_first
        and result.metadata.has_identity
    ):
        log("    LRCLIB по тегам...")
        match = search_by_identity(
            title=result.metadata.title,
            artist=result.metadata.artist,
            album=result.metadata.album,
            duration=result.metadata.duration,
            user_agent=config.lyrics.user_agent,
            timeout=config.lyrics.timeout,
        )
        if match:
            result.text = match.lyrics
            result.source = "tag+lrclib"
            result.needs_asr = False
            log(f"    ✓ {match.artist} — {match.title}")
            return result

    # --- Shazam ---
    if config.shazam.enabled:
        log("    Shazam...")
        shazam_wav = work_dir / "shazam_16k.wav"
        try:
            convert_to_wav(
                input_path, shazam_wav,
                sample_rate=16000, channels=1,
                ffmpeg_path=config.convert.ffmpeg_path,
            )
            result.shazam = recognize(shazam_wav)
        except Exception as e:  # noqa: BLE001
            log(f"    ⚠ Shazam error: {e}")
            result.shazam = None

        if result.shazam:
            log(f"    ✓ {result.shazam.artist} — {result.shazam.title}")

            if config.lyrics.enabled and config.lyrics.use_shazam_result:
                match = search_by_identity(
                    title=result.shazam.title,
                    artist=result.shazam.artist,
                    album=result.shazam.album,
                    duration=result.metadata.duration,
                    user_agent=config.lyrics.user_agent,
                    timeout=config.lyrics.timeout,
                )
                if match:
                    result.text = match.lyrics
                    result.source = "shazam+lrclib"
                    result.needs_asr = False
                    log("    ✓ текст найден через LRCLIB")
                    return result

            # Identified, but no lyrics
            result.source = "shazam"
            if not config.lyrics.transcribe_if_identified_no_lyrics:
                result.needs_asr = False
            log("    ⚠ текста нет")
        else:
            log("    ⚠ не распознан")

    return result


def _try_post_asr_match(
    text: str,
    config: PipelineConfig,
    log: LogFn,
) -> Optional[str]:
    if not text:
        return None

    # Filter out common ASR watermarks/credits
    ignore_phrases = ["субтитры", "редактор", "перевод", "сделал", "озвучка"]
    lines = []
    for line in text.splitlines():
        line = line.strip()
        if not line:
            continue
        if any(p in line.lower() for p in ignore_phrases):
            continue
        lines.append(line)

    if not lines:
        return None

    # Take the first line with at least 3 words, strip punctuation
    query = ""
    for line in lines:
        words = line.split()
        if len(words) >= 3:
            clean_words = [re.sub(r'[^\w\s]', '', w) for w in words]
            query = " ".join(clean_words[:6])
            break

    if not query:
        return None

    log(f"    LRCLIB по тексту: {query!r}...")

    try:
        candidates = search_by_text(
            query, limit=5,
            user_agent=config.lyrics.user_agent,
            timeout=config.lyrics.timeout,
        )
    except Exception as e:  # noqa: BLE001
        log(f"    ⚠ LRCLIB error: {e}")
        return None

    if not candidates:
        log("    ⚠ нет кандидатов")
        return None

    best: Optional[LyricsMatch] = None
    best_ratio = 0.0
    for c in candidates:
        ratio = difflib.SequenceMatcher(None, text.lower(), c.lyrics.lower()).ratio()
        if ratio > best_ratio:
            best_ratio = ratio
            best = c

    if best is not None and best_ratio >= config.lyrics.fuzzy_threshold:
        log(f"    ✓ совпадение {best_ratio:.2f}: {best.artist} — {best.title}")
        return best.lyrics

    log(f"    ⚠ лучшее совпадение {best_ratio:.2f} < {config.lyrics.fuzzy_threshold}")
    return None


def _persist_artifacts(
    work_dir: Path,
    persist_dir: Path,
    config: PipelineConfig,
    has_text: bool,
    has_metadata: bool,
    log: LogFn,
) -> None:
    """Move wanted artifacts from work_dir to persist_dir."""
    persist_dir.mkdir(parents=True, exist_ok=True)

    names: list[str] = []
    if has_metadata and (work_dir / "metadata.json").exists():
        names.append("metadata.json")
    if has_text and (work_dir / "text.txt").exists():
        names.append("text.txt")

    if config.keep_intermediate:
        for n in ("convert.wav", "vocals.wav"):
            if (work_dir / n).exists():
                names.append(n)
    elif not has_text:
        if config.separate.enabled and (work_dir / "vocals.wav").exists():
            names.append("vocals.wav")
        elif config.convert.enabled and (work_dir / "convert.wav").exists():
            names.append("convert.wav")

    for name in names:
        src = work_dir / name
        dst = persist_dir / name
        if dst.exists():
            dst.unlink()
        shutil.move(str(src), str(dst))
        log(f"    [persist] {name}")


# ---------------------------------------------------------------- entry

def run_pipeline(
    config: PipelineConfig,
    log: LogFn = print,
    on_progress: Optional[ProgressFn] = None,
    stop_event: Optional[threading.Event] = None,
) -> list[FileResult]:
    input_dir = Path(config.input_dir)
    if not input_dir.is_dir():
        raise FileNotFoundError(f"Input directory not found: {input_dir}")

    output_dir = Path(config.output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    files = collect_audio_files(input_dir, config.recursive)
    if not files:
        log("Аудиофайлы не найдены.")
        return []

    mode = (config.mode or "sequential").lower()
    log(
        f"Найдено треков: {len(files)}   |   режим: {mode}   |   "
        f"keep_intermediate={config.keep_intermediate}"
    )
    if config.shazam.enabled:
        log("  Shazam: включён")
    if config.lyrics.enabled:
        log(f"  LRCLIB: включён (порог fuzzy={config.lyrics.fuzzy_threshold:.2f})")

    if mode == "staged":
        return _run_staged(config, files, log, on_progress, stop_event)
    return _run_sequential(config, files, log, on_progress, stop_event)


# ---------------------------------------------------------------- sequential

def _run_sequential(
    config: PipelineConfig,
    files: list[Path],
    log: LogFn,
    on_progress: Optional[ProgressFn],
    stop_event: Optional[threading.Event],
) -> list[FileResult]:
    total = len(files)
    results: list[FileResult] = []
    output_root = Path(config.output_dir)

    for idx, f in enumerate(files, 1):
        if stop_event is not None and stop_event.is_set():
            log("⏹ Отменено")
            break
        if on_progress:
            on_progress(idx - 1, total, "file")
        log(f"\n▶ [{idx}/{total}] {f.name}")

        tmp_root = Path(tempfile.mkdtemp(prefix=f"at_{f.stem}_"))
        track_out = output_root / f.stem
        track_out.mkdir(parents=True, exist_ok=True)

        text: Optional[str] = None
        source: Optional[str] = None
        error: Optional[str] = None
        metadata_dict: Optional[dict] = None

        try:
            # --- Phase 1: identification ---
            ident = _identify_file(f, tmp_root, config, log)

            if not ident.needs_asr:
                text = ident.text
                source = ident.source
                metadata_dict = _build_metadata_dict(ident.metadata, ident.shazam, source)
                if text:
                    (tmp_root / "text.txt").write_text(text, encoding="utf-8")
                    log(f"  ✓ text.txt ({len(text)} символов, {source})")
                if metadata_dict:
                    _write_metadata_json(tmp_root / "metadata.json", metadata_dict)
                    log("  ✓ metadata.json")
                _persist_artifacts(
                    tmp_root, track_out, config,
                    has_text=text is not None,
                    has_metadata=bool(metadata_dict),
                    log=log,
                )
                results.append(FileResult(f, text, track_out, None, source, metadata_dict))
                continue

            # --- Phase 2: full pipeline ---
            audio_path: Path = f
            if config.convert.enabled:
                _check_cancel(stop_event)
                log("  [1] Конвертация в WAV...")
                target = tmp_root / "convert.wav"
                if config.overwrite or not target.exists():
                    convert_to_wav(
                        f, target,
                        sample_rate=config.convert.sample_rate,
                        channels=config.convert.channels,
                        ffmpeg_path=config.convert.ffmpeg_path,
                    )
                audio_path = target
                log("  ✓ convert.wav")

            if config.separate.enabled:
                _check_cancel(stop_event)
                log("  [2] Отделение вокала...")
                target = tmp_root / "vocals.wav"
                with tempfile.TemporaryDirectory() as tmp:
                    raw = separate_vocals(
                        audio_path, Path(tmp),
                        model=config.separate.model,
                        isolate_stem=config.separate.isolate_stem,
                        unblend_path=config.separate.unblend_path,
                        custom_kernels=config.separate.custom_kernels,
                    )
                    shutil.move(str(raw), str(target))
                audio_path = target
                log("  ✓ vocals.wav")

            if config.transcribe.enabled:
                _check_cancel(stop_event)
                log("  [3] Транскрибация...")
                text = transcribe(
                    audio_path,
                    engine=config.transcribe.engine,
                    model=config.transcribe.model,
                    language=config.transcribe.language,
                    device=config.transcribe.device,
                    compute_type=config.transcribe.compute_type,
                    batch_size=config.transcribe.batch_size,
                    gigaam_provider=config.transcribe.gigaam_provider,
                    local_files_only=config.transcribe.local_files_only,
                )
                source = config.transcribe.engine
                log(f"  ✓ распознано {len(text)} символов")

                if config.lyrics.enabled and config.lyrics.try_after_asr:
                    matched = _try_post_asr_match(text, config, log)
                    if matched:
                        text = matched
                        source = f"{source}+lrclib"

                (tmp_root / "text.txt").write_text(text, encoding="utf-8")

            metadata_dict = _build_metadata_dict(ident.metadata, ident.shazam, source)
            if metadata_dict:
                _write_metadata_json(tmp_root / "metadata.json", metadata_dict)

            _persist_artifacts(
                tmp_root, track_out, config,
                has_text=text is not None,
                has_metadata=bool(metadata_dict),
                log=log,
            )

        except Cancelled:
            log("⏹ Отменено")
            break
        except Exception as e:  # noqa: BLE001
            error = f"{type(e).__name__}: {e}"
            log(f"  ✗ Ошибка: {error}")
        finally:
            shutil.rmtree(tmp_root, ignore_errors=True)
            if on_progress:
                on_progress(idx, total, "file")
            results.append(
                FileResult(f, text, track_out if not error else None, error, source, metadata_dict)
            )

    _log_summary(results, log)
    return results


# ---------------------------------------------------------------- staged

def _run_staged(
    config: PipelineConfig,
    files: list[Path],
    log: LogFn,
    on_progress: Optional[ProgressFn],
    stop_event: Optional[threading.Event],
) -> list[FileResult]:
    output_root = Path(config.output_dir)
    total = len(files)

    state: dict[Path, dict] = {
        f: {
            "audio_path": f,
            "ident": None,
            "text": None,
            "source": None,
            "metadata_dict": None,
            "error": None,
            "resolved": False,
        }
        for f in files
    }

    def active() -> list[Path]:
        return [f for f in files if state[f]["error"] is None and not state[f]["resolved"]]

    def cancelled() -> bool:
        return stop_event is not None and stop_event.is_set()

    # --- Stage 0: identification ---
    if config.shazam.enabled or config.lyrics.enabled:
        log(f"\n══ Этап 0 · Идентификация ({total} файлов) ══")
        ident_tmp = Path(tempfile.mkdtemp(prefix="at_ident_"))
        try:
            for i, f in enumerate(files, 1):
                if cancelled():
                    log("⏹ Отменено")
                    break
                if on_progress:
                    on_progress(i, total, "identify")
                try:
                    ident = _identify_file(f, ident_tmp / f.stem, config, log)
                    state[f]["ident"] = ident

                    if not ident.needs_asr:
                        state[f]["resolved"] = True
                        state[f]["text"] = ident.text
                        state[f]["source"] = ident.source
                        state[f]["metadata_dict"] = _build_metadata_dict(
                            ident.metadata, ident.shazam, ident.source,
                        )
                        # Persist resolved tracks right away
                        track_out = output_root / f.stem
                        track_out.mkdir(parents=True, exist_ok=True)
                        if ident.text:
                            (track_out / "text.txt").write_text(ident.text, encoding="utf-8")
                        if state[f]["metadata_dict"]:
                            _write_metadata_json(track_out / "metadata.json", state[f]["metadata_dict"])
                        log(f"  ✓ {f.name} [{ident.source}]")
                    else:
                        log(f"  → {f.name} (нужен полный пайплайн)")
                except Exception as e:  # noqa: BLE001
                    state[f]["error"] = f"{type(e).__name__}: {e}"
                    log(f"  ✗ {f.name}: {e}")
        finally:
            shutil.rmtree(ident_tmp, ignore_errors=True)

    # --- Stage 1: convert ---
    if config.convert.enabled and not cancelled():
        todo = active()
        log(f"\n══ Этап 1 · Конвертация ({len(todo)} файлов) ══")
        for i, f in enumerate(todo, 1):
            if cancelled():
                log("⏹ Отменено")
                break
            if on_progress:
                on_progress(i, len(todo), "convert")
            try:
                target = Path(tempfile.mkdtemp(prefix=f"at_{f.stem}_")) / "convert.wav"
                if config.overwrite or not target.exists():
                    convert_to_wav(
                        f, target,
                        sample_rate=config.convert.sample_rate,
                        channels=config.convert.channels,
                        ffmpeg_path=config.convert.ffmpeg_path,
                    )
                state[f]["audio_path"] = target
                log(f"  ✓ {f.name}")
            except Exception as e:  # noqa: BLE001
                state[f]["error"] = f"{type(e).__name__}: {e}"
                log(f"  ✗ {f.name}: {e}")

    # ---- Stage 2: separate (batched) ----
    if config.separate.enabled and not cancelled():
        todo = active()
        log(f"\n══ Этап 2/3 · Отделение вокала ({len(todo)} файлов) ══")
        if not todo:
            pass
        elif len(todo) == 1:
            # Single file — no batching advantage
            f = todo[0]
            if on_progress:
                on_progress(1, 1, "separate")
            try:
                src = state[f]["audio_path"]
                target = src.parent / "vocals.wav"
                with tempfile.TemporaryDirectory() as tmp:
                    raw = separate_vocals(
                        src, Path(tmp),
                        model=config.separate.model,
                        isolate_stem=config.separate.isolate_stem,
                        unblend_path=config.separate.unblend_path,
                        custom_kernels=config.separate.custom_kernels,
                    )
                    shutil.move(str(raw), str(target))
                state[f]["audio_path"] = target
                log(f"  ✓ {f.name}")
            except Exception as e:  # noqa: BLE001
                state[f]["error"] = f"{type(e).__name__}: {e}"
                log(f"  ✗ {f.name}: {e}")
        else:
            # Batch: one unblend invocation, model loaded once
            input_wavs = [state[f]["audio_path"] for f in todo]
            common_root = _common_parent(input_wavs)
            log(f"  Батч: {len(todo)} файлов, один вызов unblend (модель загрузится один раз)")
            if on_progress:
                on_progress(0, len(todo), "separate")
            try:
                mapping = separate_vocals_batch(
                    input_wavs,
                    common_root,
                    model=config.separate.model,
                    isolate_stem=config.separate.isolate_stem,
                    unblend_path=config.separate.unblend_path,
                    custom_kernels=config.separate.custom_kernels,
                    progress_callback=lambda p: on_progress(p, len(todo), "separate") if on_progress else None,
                )
                for f in todo:
                    src = state[f]["audio_path"]
                    vocal = mapping.get(src)
                    if vocal is None:
                        state[f]["error"] = "batch produced no output"
                        log(f"  ✗ {f.name}: нет результата в батче")
                        continue
                    state[f]["audio_path"] = vocal
                    log(f"  ✓ {f.name}")
                if on_progress:
                    on_progress(len(todo), len(todo), "separate")
            except Exception as e:  # noqa: BLE001
                log(f"  ✗ Батч упал: {e}")
                log("  ↻ Fallback: по одному...")
                for i, f in enumerate(todo, 1):
                    if cancelled():
                        break
                    if on_progress:
                        on_progress(i, len(todo), "separate")
                    try:
                        src = state[f]["audio_path"]
                        target = src.parent / "vocals.wav"
                        with tempfile.TemporaryDirectory() as tmp:
                            raw = separate_vocals(
                                src, Path(tmp),
                                model=config.separate.model,
                                isolate_stem=config.separate.isolate_stem,
                                unblend_path=config.separate.unblend_path,
                                custom_kernels=config.separate.custom_kernels,
                            )
                            shutil.move(str(raw), str(target))
                        state[f]["audio_path"] = target
                        log(f"  ✓ {f.name}")
                    except Exception as inner:  # noqa: BLE001
                        state[f]["error"] = f"{type(inner).__name__}: {inner}"
                        log(f"  ✗ {f.name}: {inner}")

    # --- Stage 3: transcribe ---
    if config.transcribe.enabled and not cancelled():
        todo = active()
        log(f"\n══ Этап 3 · Транскрибация ({len(todo)} файлов) ══")
        for i, f in enumerate(todo, 1):
            if cancelled():
                log("⏹ Отменено")
                break
            if on_progress:
                on_progress(i, len(todo), "transcribe")
            try:
                text = transcribe(
                    state[f]["audio_path"],
                    engine=config.transcribe.engine,
                    model=config.transcribe.model,
                    language=config.transcribe.language,
                    device=config.transcribe.device,
                    compute_type=config.transcribe.compute_type,
                    batch_size=config.transcribe.batch_size,
                    gigaam_provider=config.transcribe.gigaam_provider,
                    local_files_only=config.transcribe.local_files_only,
                )
                state[f]["text"] = text
                state[f]["source"] = config.transcribe.engine
                log(f"  ✓ {f.name} ({len(text)} символов)")
            except Exception as e:  # noqa: BLE001
                state[f]["error"] = f"{type(e).__name__}: {e}"
                log(f"  ✗ {f.name}: {e}")

    # --- Stage 4: post-ASR lyrics match + persist ---
    if not cancelled():
        log("\n══ Финализация ══")
        for f in files:
            s = state[f]
            if s["error"] is not None:
                continue
            if s["resolved"]:
                continue  # already persisted in identification pass

            text = s["text"]
            source = s["source"]

            if text and config.lyrics.enabled and config.lyrics.try_after_asr:
                matched = _try_post_asr_match(text, config, log)
                if matched:
                    text = matched
                    source = f"{source}+lrclib"
                    s["text"] = text
                    s["source"] = source

            ident = s["ident"]
            metadata_dict = _build_metadata_dict(
                ident.metadata, ident.shazam, source,
            ) if ident else None
            s["metadata_dict"] = metadata_dict

            track_out = output_root / f.stem
            track_out.mkdir(parents=True, exist_ok=True)

            audio_work = s["audio_path"]
            work_dir = audio_work.parent if isinstance(audio_work, Path) else track_out
            if text:
                (work_dir / "text.txt").write_text(text, encoding="utf-8")
            if metadata_dict:
                _write_metadata_json(work_dir / "metadata.json", metadata_dict)

            _persist_artifacts(
                work_dir, track_out, config,
                has_text=text is not None,
                has_metadata=bool(metadata_dict),
                log=log,
            )
            log(f"  ✓ {f.name}")

    # cleanup temp dirs
    for f in files:
        audio_path = state[f]["audio_path"]
        if isinstance(audio_path, Path) and audio_path.parent.name.startswith(f"at_{f.stem}"):
            shutil.rmtree(audio_path.parent, ignore_errors=True)

    results = [
        FileResult(
            f,
            state[f]["text"],
            output_root / f.stem if state[f]["error"] is None else None,
            state[f]["error"],
            state[f]["source"],
            state[f]["metadata_dict"],
        )
        for f in files
    ]
    _log_summary(results, log)
    return results


# ---------------------------------------------------------------- summary

def _log_summary(results: list[FileResult], log: LogFn) -> None:
    from collections import Counter
    counts = Counter(r.source or ("error" if r.error else "no_result") for r in results)
    log("\n══ Итог ══")
    for src, n in sorted(counts.items(), key=lambda kv: -kv[1]):
        log(f"  {src}: {n}")


# =========================================
# Файл: core/transcribe.py
# =========================================

"""Speech recognition via WhisperX or GigaAM (onnx-asr).

Includes aggressive VRAM cleanup to prevent OOM on long batches,
and automatic model reload if the CUDA context gets corrupted.
"""
from __future__ import annotations

import gc
import time
from pathlib import Path
from typing import Optional


def _resolve_device(device: str) -> str:
    if device in ("cuda", "cpu"):
        return device
    try:
        import torch
        return "cuda" if torch.cuda.is_available() else "cpu"
    except Exception:
        return "cpu"


def _retry(fn, attempts: int = 3, base_delay: float = 2.0):
    last = None
    for i in range(attempts):
        try:
            return fn()
        except Exception as e:
            last = e
            if i < attempts - 1:
                time.sleep(base_delay * (i + 1))
    raise last


class _WhisperXModels:
    _cache: dict = {}

    @classmethod
    def get(cls, model_name: str, device: str, compute_type: Optional[str],
            local_files_only: bool = False):
        if compute_type is None:
            compute_type = "int8_float16" if device == "cuda" else "int8"
        key = (model_name, device, compute_type, local_files_only)
        if key not in cls._cache:
            import whisperx
            print(f"  [ASR] loading WhisperX {model_name} on {device} "
                  f"({compute_type}, local_files_only={local_files_only})...")

            def load():
                return whisperx.load_model(
                    model_name, device,
                    compute_type=compute_type,
                    local_files_only=local_files_only,
                )

            cls._cache[key] = _retry(load)
            print("  [ASR] model loaded.")
        return cls._cache[key]


class _GigaAMModels:
    _cache: dict = {}

    @classmethod
    def get(cls, provider: str):
        providers = {
            "cuda": ["CUDAExecutionProvider"],
            "cpu": ["CPUExecutionProvider"],
            "auto": ["CUDAExecutionProvider", "CPUExecutionProvider"],
        }.get(provider, ["CPUExecutionProvider"])
        key = tuple(providers)
        if key not in cls._cache:
            import onnx_asr

            def load():
                return onnx_asr.load_model("gigaam-v3-ctc", providers=list(providers))

            cls._cache[key] = _retry(load)
        return cls._cache[key]


def transcribe(
    audio_path: Path,
    engine: str = "whisperx",
    model: str = "large-v3",
    language: Optional[str] = "ru",
    device: str = "auto",
    compute_type: Optional[str] = None,
    batch_size: int = 8,
    gigaam_provider: str = "auto",
    local_files_only: bool = False,
    vad_filter: bool = True,
) -> str:
    device = _resolve_device(device)

    if engine == "whisperx":
        try:
            return _transcribe_whisperx(
                audio_path, model, language, device, compute_type,
                batch_size, local_files_only, vad_filter,
            )
        except Exception as e:
            err = str(e).lower()
            if "out of memory" in err or "cuda" in err or "device ordinal" in err:
                print(f"  [ASR] CUDA error: {e}. Clearing model cache and retrying...")
                _WhisperXModels._cache.clear()
                gc.collect()
                try:
                    import torch
                    if torch.cuda.is_available():
                        torch.cuda.empty_cache()
                except ImportError:
                    pass
                return _transcribe_whisperx(
                    audio_path, model, language, device, compute_type,
                    batch_size, local_files_only, vad_filter,
                )
            raise

    if engine == "gigaam":
        return _transcribe_gigaam(audio_path, gigaam_provider)
    raise ValueError(f"Unknown ASR engine: {engine!r}")


def _transcribe_whisperx(
    audio_path: Path,
    model_name: str,
    language: Optional[str],
    device: str,
    compute_type: Optional[str],
    batch_size: int,
    local_files_only: bool,
    vad_filter: bool,
) -> str:
    import whisperx
    model = _WhisperXModels.get(model_name, device, compute_type, local_files_only)
    audio = whisperx.load_audio(str(audio_path))
    result = model.transcribe(
        audio,
        batch_size=batch_size,
        language=language,
    )
    
    text = " ".join(seg["text"].strip() for seg in result["segments"]).strip()
    
    # Free VRAM aggressively to prevent fragmentation/OOM on long batches
    del audio
    del result
    gc.collect()
    try:
        import torch
        if torch.cuda.is_available():
            torch.cuda.empty_cache()
    except ImportError:
        pass
        
    return text


def _transcribe_gigaam(audio_path: Path, provider: str) -> str:
    model = _GigaAMModels.get(provider)
    return model.recognize(str(audio_path), channel="mean").strip()


# =========================================
# Файл: core/recognize.py
# =========================================

"""Audio fingerprinting via Shazam (unofficial, no API key needed).

Wraps shazamlite. If Shazam can't identify the track — return None.
Never raises on 'not found', only on missing dependency.
"""
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Optional


@dataclass
class ShazamResult:
    title: str
    artist: str
    album: Optional[str] = None
    year: Optional[str] = None


def recognize(audio_wav: Path) -> Optional[ShazamResult]:
    """Run Shazam fingerprinting on a WAV file. Returns None if not found."""
    try:
        from shazamlite import Shazam
    except ImportError as e:
        raise RuntimeError(
            "shazamlite не установлен. Установите: pip install shazamlite numpy soundfile"
        ) from e

    shazam = Shazam()
    try:
        track = shazam.recognize(str(audio_wav))
    except Exception:
        return None

    if track is None:
        return None

    title = getattr(track, "title", None) or getattr(track, "track", None)
    artist = getattr(track, "artist", None) or getattr(track, "subtitle", None)
    album = getattr(track, "album", None)
    year = getattr(track, "year", None) or getattr(track, "released", None)

    if not title or not artist:
        return None

    return ShazamResult(
        title=str(title),
        artist=str(artist),
        album=str(album) if album else None,
        year=str(year) if year else None,
    )


# =========================================
# Файл: core/lyrics.py
# =========================================

"""Lyrics lookup via LRCLIB (https://lrclib.net) — free, no API key.

Rate limit: LRCLIB allows ~90 req/min with a proper User-Agent,
~45 without. We throttle to 0.4s between requests (150/min theoretical,
well within limits) and always send a UA.
"""
from __future__ import annotations

import threading
import time
from dataclasses import dataclass
from typing import Optional

import requests


DEFAULT_USER_AGENT = "audio-transcriber/1.0"
_API_BASE = "https://lrclib.net/api"

_throttle_lock = threading.Lock()
_last_request_at = 0.0
_MIN_INTERVAL = 0.4  # seconds


@dataclass
class LyricsMatch:
    title: str
    artist: str
    lyrics: str
    duration: Optional[float] = None


def _throttle() -> None:
    global _last_request_at
    with _throttle_lock:
        now = time.monotonic()
        wait = _last_request_at + _MIN_INTERVAL - now
        if wait > 0:
            time.sleep(wait)
        _last_request_at = time.monotonic()


def _get(url: str, params: dict, user_agent: str, timeout: float = 10.0) -> Optional[dict]:
    _throttle()
    try:
        resp = requests.get(
            url, params=params,
            headers={"User-Agent": user_agent},
            timeout=timeout,
        )
        if resp.status_code == 404:
            return None
        resp.raise_for_status()
        return resp.json()
    except requests.RequestException:
        return None


def _make_match(item: dict) -> Optional[LyricsMatch]:
    lyrics = item.get("plainLyrics") or item.get("syncedLyrics")
    if not lyrics:
        return None
    return LyricsMatch(
        title=item.get("trackName", ""),
        artist=item.get("artistName", ""),
        lyrics=lyrics.strip(),
        duration=item.get("duration"),
    )


def search_by_identity(
    title: Optional[str],
    artist: Optional[str],
    album: Optional[str] = None,
    duration: Optional[float] = None,
    user_agent: str = DEFAULT_USER_AGENT,
    timeout: float = 10.0,
) -> Optional[LyricsMatch]:
    """Search LRCLIB by track metadata. Works even with messy tags."""
    if not title and not artist:
        return None

    query = " ".join(filter(None, [artist or "", title or ""])).strip()
    if not query:
        return None

    data = _get(f"{_API_BASE}/search", {"q": query}, user_agent, timeout)
    if not isinstance(data, list) or not data:
        return None

    for item in data[:5]:
        match = _make_match(item)
        if match:
            return match
    return None


def search_by_text(
    query: str,
    limit: int = 5,
    user_agent: str = DEFAULT_USER_AGENT,
    timeout: float = 10.0,
) -> list[LyricsMatch]:
    """Search LRCLIB by plain text (e.g. first words of recognized speech)."""
    if not query or not query.strip():
        return []
    data = _get(f"{_API_BASE}/search", {"q": query.strip()}, user_agent, timeout)
    if not isinstance(data, list):
        return []
    matches: list[LyricsMatch] = []
    for item in data[:limit]:
        match = _make_match(item)
        if match:
            matches.append(match)
    return matches


# =========================================
# Файл: core/config.py
# =========================================

"""Pipeline configuration dataclasses with JSON save/load."""
from __future__ import annotations

import json
from dataclasses import asdict, dataclass, field, fields
from pathlib import Path
from typing import Optional


MODES = ("sequential", "staged")


@dataclass
class ConvertConfig:
    enabled: bool = True
    sample_rate: int = 44100
    channels: int = 2
    ffmpeg_path: str = "ffmpeg"


@dataclass
class SeparateConfig:
    enabled: bool = True
    model: str = "bs_roformer_anvuew"
    isolate_stem: str = "vocals"
    unblend_path: str = "unblend"
    custom_kernels: bool = False


@dataclass
class TranscribeConfig:
    enabled: bool = True
    engine: str = "whisperx"
    model: str = "large-v3"
    language: Optional[str] = "ru"
    device: str = "auto"
    compute_type: Optional[str] = None
    batch_size: int = 8
    gigaam_provider: str = "auto"
    local_files_only: bool = False


@dataclass
class ShazamConfig:
    enabled: bool = False


@dataclass
class LyricsConfig:
    enabled: bool = False
    user_agent: str = "audio-transcriber/1.0"
    timeout: float = 10.0
    use_tags_first: bool = True
    use_shazam_result: bool = True
    try_after_asr: bool = True
    fuzzy_threshold: float = 0.6          # difflib ratio, 0..1
    transcribe_if_identified_no_lyrics: bool = True


@dataclass
class PipelineConfig:
    input_dir: str = ""
    output_dir: str = "transcribed"
    recursive: bool = False
    keep_intermediate: bool = True
    overwrite: bool = True
    mode: str = "sequential"

    convert: ConvertConfig = field(default_factory=ConvertConfig)
    separate: SeparateConfig = field(default_factory=SeparateConfig)
    transcribe: TranscribeConfig = field(default_factory=TranscribeConfig)
    shazam: ShazamConfig = field(default_factory=ShazamConfig)
    lyrics: LyricsConfig = field(default_factory=LyricsConfig)

    def to_dict(self) -> dict:
        return asdict(self)

    def to_json(self) -> str:
        return json.dumps(self.to_dict(), indent=2, ensure_ascii=False)

    @classmethod
    def from_dict(cls, data: dict) -> "PipelineConfig":
        cfg = cls()
        for top in fields(cls):
            name = top.name
            if name not in data:
                continue
            value = data[name]
            current = getattr(cfg, name)
            if hasattr(current, "__dataclass_fields__"):
                sub = {f.name for f in fields(current)}
                for k, v in value.items():
                    if k in sub:
                        setattr(current, k, v)
            else:
                setattr(cfg, name, value)
        return cfg

    @classmethod
    def from_json(cls, text: str) -> "PipelineConfig":
        return cls.from_dict(json.loads(text))

    def save(self, path: Path) -> None:
        Path(path).write_text(self.to_json(), encoding="utf-8")

    @classmethod
    def load(cls, path: Path) -> "PipelineConfig":
        return cls.from_json(Path(path).read_text(encoding="utf-8"))


# =========================================
# Файл: README.md
# =========================================

# Audio Transcriber Combine

Локальное десктоп-приложение для извлечения текста из аудио.

## Этапы

1. **convert** — любой аудиоформат → WAV через `ffmpeg` (настраиваемый sample rate / каналы).
2. **separate** — отделение вокала через `unblend` (Mel-Band RoFormer).
3. **transcribe** — ASR через **WhisperX** или **GigaAM** (onnx-asr).

Каждый этап можно включать/выключать отдельно. Промежуточные файлы
можно сохранять (по умолчанию — да) или удалять, оставляя только финальный
результат последнего включённого этапа.

## Раскладка результатов


# =========================================
# Файл: requirements.txt
# =========================================

# GUI
PySide6>=6.6

# ASR
whisperx>=3.1
onnx-asr>=0.4

# Recognition & lyrics
shazamlite>=0.1
mutagen>=1.47
requests>=2.31

# WhisperX pulls torch; for CUDA you must install the matching torch build, e.g.:
#   pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu121
# For cuDNN preload helper used in main.py:
#   pip install nvidia-cudnn-cu12

# System dependencies (not pip-installable):
#   ffmpeg          — must be in PATH
#   unblend         — vocal separation CLI (Mel-Band RoFormer), must be in PATH


# =========================================
# Файл: gui/main_window.py
# =========================================

"""Main application window — dark theme, mode selector, staged/sequential pipelines."""
from __future__ import annotations

import shutil
from pathlib import Path
from typing import Optional

from PySide6.QtCore import Qt, QUrl
from PySide6.QtGui import QColor, QDesktopServices
from PySide6.QtWidgets import (
    QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox,
    QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox,
    QPlainTextEdit, QProgressBar, QPushButton, QSpinBox, QTabWidget,
    QVBoxLayout, QWidget,
)

from core.config import (
    ConvertConfig, LyricsConfig, PipelineConfig, SeparateConfig,
    ShazamConfig, TranscribeConfig,
)
from gui.worker import PipelineWorker


LANGUAGES = ["auto", "ru", "en", "de", "fr", "es", "it", "uk", "ja", "zh", "ko"]
SAMPLE_RATES = [16000, 22050, 44100, 48000]
ENGINES = ["whisperx", "gigaam"]

STAGE_LABELS = {
    "file": "Файлы",
    "identify": "Идентификация",
    "convert": "Конвертация",
    "separate": "Отделение",
    "transcribe": "Транскрибация",
}

MODE_LABELS = {
    "sequential": "Последовательно (файл за файлом)",
    "staged": "Пакетно (по этапам)",
}


def _detect_device_info() -> tuple[str, str]:
    try:
        import torch
    except ImportError:
        return "⚠ PyTorch не установлен", "#f38ba8"

    if torch.cuda.is_available():
        try:
            name = torch.cuda.get_device_name(0)
            vram = torch.cuda.get_device_properties(0).total_memory / (1024 ** 3)
            return f"✓ CUDA: {name} · {vram:.1f} GB VRAM", "#a6e3a1"
        except Exception:  # noqa: BLE001
            return "✓ CUDA доступна", "#a6e3a1"

    return "⚠ CUDA не обнаружена — расчёт пойдёт на CPU (медленно)", "#f9e2af"


class MainWindow(QMainWindow):
    def __init__(self) -> None:
        super().__init__()
        self.setWindowTitle("Song Transcriber")
        self.resize(1140, 880)
        self._worker: Optional[PipelineWorker] = None
        self._build_ui()
        self._apply_defaults()
        self._sync_tab_indicators()
        self._sync_engine_fields()
        self._log_device_info()

    # ---------------- UI ----------------

    def _build_ui(self) -> None:
        central = QWidget()
        self.setCentralWidget(central)
        root = QVBoxLayout(central)
        root.setContentsMargins(18, 16, 18, 16)
        root.setSpacing(12)

        header = QHBoxLayout()
        title = QLabel("🎵 Song Transcriber")
        title.setObjectName("appTitle")
        header.addWidget(title)
        header.addStretch(1)
        self.device_label = QLabel("")
        self.device_label.setObjectName("deviceInfo")
        header.addWidget(self.device_label)
        root.addLayout(header)

        top_box = QGroupBox("Общие настройки")
        top_layout = QFormLayout(top_box)
        top_layout.setSpacing(10)

        self.input_edit = QLineEdit()
        top_layout.addRow("Входная папка:", self._with_browse(self.input_edit, self._pick_input_dir))

        self.output_edit = QLineEdit()
        top_layout.addRow("Выходная папка:", self._with_browse(self.output_edit, self._pick_output_dir))

        self.mode_cb = QComboBox()
        for key, label in MODE_LABELS.items():
            self.mode_cb.addItem(label, key)
        top_layout.addRow("Режим:", self.mode_cb)

        opts = QHBoxLayout()
        self.recursive_cb = QCheckBox("Рекурсивный обход")
        self.keep_intermediate_cb = QCheckBox("Сохранять промежуточные")
        self.overwrite_cb = QCheckBox("Перезаписывать")
        opts.addWidget(self.recursive_cb)
        opts.addWidget(self.keep_intermediate_cb)
        opts.addWidget(self.overwrite_cb)
        opts.addStretch(1)
        top_layout.addRow("Опции:", opts)

        root.addWidget(top_box)

        self.tabs = QTabWidget()
        self.tabs.addTab(self._build_shazam_tab(), "Shazam")
        self.tabs.addTab(self._build_lrclib_tab(), "LRCLIB")
        self.tabs.addTab(self._build_convert_tab(), "Конвертация")
        self.tabs.addTab(self._build_separate_tab(), "Отделение вокала")
        self.tabs.addTab(self._build_transcribe_tab(), "Транскрибация")
        root.addWidget(self.tabs)

        self.shazam_enabled_cb.toggled.connect(self._sync_tab_indicators)
        self.lyrics_enabled_cb.toggled.connect(self._sync_tab_indicators)
        self.convert_enabled_cb.toggled.connect(self._sync_tab_indicators)
        self.separate_enabled_cb.toggled.connect(self._sync_tab_indicators)
        self.transcribe_enabled_cb.toggled.connect(self._sync_tab_indicators)
        self.engine_cb.currentTextChanged.connect(self._sync_engine_fields)

        row = QHBoxLayout()
        row.setSpacing(10)
        self.start_btn = QPushButton("▶  Запустить")
        self.start_btn.setObjectName("primary")
        self.start_btn.clicked.connect(self._on_start)
        self.stop_btn = QPushButton("⏹  Стоп")
        self.stop_btn.setObjectName("danger")
        self.stop_btn.setEnabled(False)
        self.stop_btn.clicked.connect(self._on_stop)
        self.open_out_btn = QPushButton("📂  Открыть результат")
        self.open_out_btn.clicked.connect(self._open_output_dir)
        self.save_preset_btn = QPushButton("💾  Сохранить пресет")
        self.save_preset_btn.clicked.connect(self._save_preset)
        self.load_preset_btn = QPushButton("📁  Загрузить пресет")
        self.load_preset_btn.clicked.connect(self._load_preset)

        row.addWidget(self.start_btn)
        row.addWidget(self.stop_btn)
        row.addStretch(1)
        row.addWidget(self.open_out_btn)
        row.addWidget(self.save_preset_btn)
        row.addWidget(self.load_preset_btn)
        root.addLayout(row)

        self.progress = QProgressBar()
        self.progress.setFormat("Ожидание")
        root.addWidget(self.progress)

        self.log_view = QPlainTextEdit()
        self.log_view.setObjectName("log")
        self.log_view.setReadOnly(True)
        self.log_view.setPlaceholderText("Здесь появится лог выполнения…")
        root.addWidget(self.log_view, 1)

    def _with_browse(self, edit: QLineEdit, picker) -> QWidget:
        w = QWidget()
        h = QHBoxLayout(w)
        h.setContentsMargins(0, 0, 0, 0)
        h.setSpacing(6)
        h.addWidget(edit)
        btn = QPushButton("…")
        btn.setFixedWidth(36)
        btn.clicked.connect(picker)
        h.addWidget(btn)
        return w

    def _build_shazam_tab(self) -> QWidget:
        w = QWidget()
        form = QFormLayout(w)
        form.setContentsMargins(16, 16, 16, 16)
        form.setSpacing(10)

        self.shazam_enabled_cb = QCheckBox("Включён")
        form.addRow("", self.shazam_enabled_cb)

        info = QLabel(
            "Идентификация трека через Shazam (unofficial API).\n"
            "При совпадении сохраняются метаданные и (если возможно) текст из LRCLIB.\n"
            "Требует ffmpeg (для конвертации в 16 кГц моно)."
        )
        info.setWordWrap(True)
        info.setStyleSheet("color: #a6adc8;")
        form.addRow(info)

        return w

    def _build_lrclib_tab(self) -> QWidget:
        w = QWidget()
        form = QFormLayout(w)
        form.setContentsMargins(16, 16, 16, 16)
        form.setSpacing(10)

        self.lyrics_enabled_cb = QCheckBox("Включён")
        form.addRow("", self.lyrics_enabled_cb)

        self.lyrics_user_agent = QLineEdit()
        form.addRow("User-Agent:", self.lyrics_user_agent)

        self.lyrics_use_tags_cb = QCheckBox("Искать по тегам (artist + title)")
        form.addRow("", self.lyrics_use_tags_cb)

        self.lyrics_use_shazam_cb = QCheckBox("Искать по результату Shazam")
        form.addRow("", self.lyrics_use_shazam_cb)

        self.lyrics_after_asr_cb = QCheckBox("Проверять после ASR (fuzzy-match)")
        form.addRow("", self.lyrics_after_asr_cb)

        self.lyrics_transcribe_unresolved_cb = QCheckBox(
            "Запускать ASR, если Shazam опознал, но текста нет"
        )
        form.addRow("", self.lyrics_transcribe_unresolved_cb)

        self.lyrics_threshold = QDoubleSpinBox()
        self.lyrics_threshold.setRange(0.0, 1.0)
        self.lyrics_threshold.setSingleStep(0.05)
        self.lyrics_threshold.setDecimals(2)
        self.lyrics_threshold.setToolTip(
            "Насколько должен совпасть распознанный ASR-текст с текстом из LRCLIB,\n"
            "чтобы считать это тем же треком. 1.0 = точное совпадение, 0.0 = любое."
        )
        form.addRow("Порог fuzzy:", self.lyrics_threshold)

        return w

    def _build_convert_tab(self) -> QWidget:
        w = QWidget()
        form = QFormLayout(w)
        form.setContentsMargins(16, 16, 16, 16)
        form.setSpacing(10)

        self.convert_enabled_cb = QCheckBox("Включён")
        form.addRow("", self.convert_enabled_cb)

        self.convert_sr = QComboBox()
        for sr in SAMPLE_RATES:
            self.convert_sr.addItem(f"{sr} Hz", sr)
        form.addRow("Sample rate:", self.convert_sr)

        self.convert_ch = QComboBox()
        self.convert_ch.addItem("Моно", 1)
        self.convert_ch.addItem("Стерео", 2)
        form.addRow("Каналы:", self.convert_ch)

        self.convert_ffmpeg = QLineEdit()
        form.addRow("ffmpeg:", self.convert_ffmpeg)
        return w

    def _build_separate_tab(self) -> QWidget:
        w = QWidget()
        form = QFormLayout(w)
        form.setContentsMargins(16, 16, 16, 16)
        form.setSpacing(10)

        self.separate_enabled_cb = QCheckBox("Включён")
        form.addRow("", self.separate_enabled_cb)

        self.separate_model = QLineEdit()
        form.addRow("Модель:", self.separate_model)

        self.separate_stem = QLineEdit()
        form.addRow("Isolate stem:", self.separate_stem)

        self.separate_unblend = QLineEdit()
        form.addRow("unblend CLI:", self.separate_unblend)

        self.separate_kernels_cb = QCheckBox("Использовать кастомные ядра")
        form.addRow("", self.separate_kernels_cb)
        return w

    def _build_transcribe_tab(self) -> QWidget:
        w = QWidget()
        form = QFormLayout(w)
        form.setContentsMargins(16, 16, 16, 16)
        form.setSpacing(10)

        self.transcribe_enabled_cb = QCheckBox("Включён")
        form.addRow("", self.transcribe_enabled_cb)

        self.engine_cb = QComboBox()
        self.engine_cb.addItems(ENGINES)
        form.addRow("Движок:", self.engine_cb)

        self.model_edit = QLineEdit()
        form.addRow("Модель:", self.model_edit)

        self.lang_cb = QComboBox()
        self.lang_cb.setEditable(True)
        self.lang_cb.addItems(LANGUAGES)
        self.lang_cb.setToolTip(
            "'auto' — WhisperX определяет язык на каждом файле.\n"
            "Если знаешь язык — выбери его явно, будет быстрее."
        )
        form.addRow("Язык:", self.lang_cb)

        self.device_cb = QComboBox()
        self.device_cb.addItems(["auto", "cuda", "cpu"])
        form.addRow("Device:", self.device_cb)

        self.compute_type_cb = QComboBox()
        self.compute_type_cb.addItems(["auto", "int8_float16", "int8", "float16", "float32"])
        form.addRow("Compute type:", self.compute_type_cb)

        self.batch_spin = QSpinBox()
        self.batch_spin.setRange(1, 512)
        self.batch_spin.setToolTip(
            "Размер батча сегментов ВНУТРИ одного файла (не файлов!).\n"
            "8–16 для 8 ГБ VRAM, 32–64 для 24 ГБ."
        )
        form.addRow("Batch size:", self.batch_spin)

        self.gigaam_provider_cb = QComboBox()
        self.gigaam_provider_cb.addItems(["auto", "cuda", "cpu"])
        form.addRow("GigaAM provider:", self.gigaam_provider_cb)

        self.local_files_only_cb = QCheckBox("Не проверять HuggingFace (offline)")
        self.local_files_only_cb.setToolTip(
            "Если модель уже скачана — включите, чтобы не ждать сетевую проверку."
        )
        form.addRow("", self.local_files_only_cb)
        return w

    # ---------------- visual sync ----------------

    def _log_device_info(self) -> None:
        text, color = _detect_device_info()
        self.device_label.setText(text)
        self.device_label.setStyleSheet(f"color: {color};")
        self._append_log(text)

    def _sync_tab_indicators(self) -> None:
        active = QColor("#89b4fa")
        inactive = QColor("#585b70")
        bar = self.tabs.tabBar()
        bar.setTabTextColor(0, active if self.shazam_enabled_cb.isChecked() else inactive)
        bar.setTabTextColor(1, active if self.lyrics_enabled_cb.isChecked() else inactive)
        bar.setTabTextColor(2, active if self.convert_enabled_cb.isChecked() else inactive)
        bar.setTabTextColor(3, active if self.separate_enabled_cb.isChecked() else inactive)
        bar.setTabTextColor(4, active if self.transcribe_enabled_cb.isChecked() else inactive)

    def _sync_engine_fields(self) -> None:
        is_whisperx = self.engine_cb.currentText() == "whisperx"
        for w in (self.lang_cb, self.device_cb, self.compute_type_cb, self.batch_spin):
            w.setEnabled(is_whisperx)
        self.gigaam_provider_cb.setEnabled(not is_whisperx)

    # ---------------- defaults & config <-> UI ----------------

    def _apply_defaults(self) -> None:
        self._apply_config(PipelineConfig())

    def _apply_config(self, cfg: PipelineConfig) -> None:
        self.input_edit.setText(cfg.input_dir)
        self.output_edit.setText(str(Path(cfg.output_dir).absolute()))
        self.recursive_cb.setChecked(cfg.recursive)
        self.keep_intermediate_cb.setChecked(cfg.keep_intermediate)
        self.overwrite_cb.setChecked(cfg.overwrite)

        i = self.mode_cb.findData(cfg.mode)
        if i >= 0:
            self.mode_cb.setCurrentIndex(i)

        self.shazam_enabled_cb.setChecked(cfg.shazam.enabled)

        self.lyrics_enabled_cb.setChecked(cfg.lyrics.enabled)
        self.lyrics_user_agent.setText(cfg.lyrics.user_agent)
        self.lyrics_use_tags_cb.setChecked(cfg.lyrics.use_tags_first)
        self.lyrics_use_shazam_cb.setChecked(cfg.lyrics.use_shazam_result)
        self.lyrics_after_asr_cb.setChecked(cfg.lyrics.try_after_asr)
        self.lyrics_transcribe_unresolved_cb.setChecked(
            cfg.lyrics.transcribe_if_identified_no_lyrics
        )
        self.lyrics_threshold.setValue(cfg.lyrics.fuzzy_threshold)

        self.convert_enabled_cb.setChecked(cfg.convert.enabled)
        i = self.convert_sr.findData(cfg.convert.sample_rate)
        if i >= 0:
            self.convert_sr.setCurrentIndex(i)
        i = self.convert_ch.findData(cfg.convert.channels)
        if i >= 0:
            self.convert_ch.setCurrentIndex(i)
        self.convert_ffmpeg.setText(cfg.convert.ffmpeg_path)

        self.separate_enabled_cb.setChecked(cfg.separate.enabled)
        self.separate_model.setText(cfg.separate.model)
        self.separate_stem.setText(cfg.separate.isolate_stem)
        self.separate_unblend.setText(cfg.separate.unblend_path)
        self.separate_kernels_cb.setChecked(cfg.separate.custom_kernels)

        self.transcribe_enabled_cb.setChecked(cfg.transcribe.enabled)
        self.engine_cb.setCurrentText(cfg.transcribe.engine)
        self.model_edit.setText(cfg.transcribe.model)
        self.lang_cb.setCurrentText(cfg.transcribe.language or "auto")
        self.device_cb.setCurrentText(cfg.transcribe.device)
        self.compute_type_cb.setCurrentText(cfg.transcribe.compute_type or "auto")
        self.batch_spin.setValue(cfg.transcribe.batch_size)
        self.gigaam_provider_cb.setCurrentText(cfg.transcribe.gigaam_provider)
        self.local_files_only_cb.setChecked(cfg.transcribe.local_files_only)

    def _collect_config(self) -> PipelineConfig:
        cfg = PipelineConfig()
        cfg.input_dir = self.input_edit.text().strip()
        cfg.output_dir = self.output_edit.text().strip() or "transcribed"
        cfg.recursive = self.recursive_cb.isChecked()
        cfg.keep_intermediate = self.keep_intermediate_cb.isChecked()
        cfg.overwrite = self.overwrite_cb.isChecked()
        cfg.mode = self.mode_cb.currentData()

        cfg.shazam = ShazamConfig(enabled=self.shazam_enabled_cb.isChecked())

        cfg.lyrics = LyricsConfig(
            enabled=self.lyrics_enabled_cb.isChecked(),
            user_agent=self.lyrics_user_agent.text().strip() or "audio-transcriber/1.0",
            use_tags_first=self.lyrics_use_tags_cb.isChecked(),
            use_shazam_result=self.lyrics_use_shazam_cb.isChecked(),
            try_after_asr=self.lyrics_after_asr_cb.isChecked(),
            transcribe_if_identified_no_lyrics=self.lyrics_transcribe_unresolved_cb.isChecked(),
            fuzzy_threshold=float(self.lyrics_threshold.value()),
        )

        cfg.convert = ConvertConfig(
            enabled=self.convert_enabled_cb.isChecked(),
            sample_rate=int(self.convert_sr.currentData()),
            channels=int(self.convert_ch.currentData()),
            ffmpeg_path=self.convert_ffmpeg.text().strip() or "ffmpeg",
        )
        cfg.separate = SeparateConfig(
            enabled=self.separate_enabled_cb.isChecked(),
            model=self.separate_model.text().strip(),
            isolate_stem=self.separate_stem.text().strip() or "vocals",
            unblend_path=self.separate_unblend.text().strip() or "unblend",
            custom_kernels=self.separate_kernels_cb.isChecked(),
        )

        lang = self.lang_cb.currentText().strip()
        compute = self.compute_type_cb.currentText().strip()
        cfg.transcribe = TranscribeConfig(
            enabled=self.transcribe_enabled_cb.isChecked(),
            engine=self.engine_cb.currentText(),
            model=self.model_edit.text().strip() or "large-v3",
            language=None if lang in ("", "auto") else lang,
            device=self.device_cb.currentText(),
            compute_type=None if compute == "auto" else compute,
            batch_size=self.batch_spin.value(),
            gigaam_provider=self.gigaam_provider_cb.currentText(),
            local_files_only=self.local_files_only_cb.isChecked(),
        )
        return cfg

    # ---------------- handlers ----------------

    def _pick_input_dir(self) -> None:
        d = QFileDialog.getExistingDirectory(self, "Выберите входную папку")
        if d:
            self.input_edit.setText(d)

    def _pick_output_dir(self) -> None:
        d = QFileDialog.getExistingDirectory(self, "Выберите выходную папку")
        if d:
            self.output_edit.setText(d)

    def _open_output_dir(self) -> None:
        p = Path(self.output_edit.text().strip() or "transcribed").absolute()
        p.mkdir(parents=True, exist_ok=True)
        QDesktopServices.openUrl(QUrl.fromLocalFile(str(p)))

    def _save_preset(self) -> None:
        path, _ = QFileDialog.getSaveFileName(self, "Сохранить пресет", "preset.json", "JSON (*.json)")
        if not path:
            return
        try:
            self._collect_config().save(Path(path))
        except Exception as exc:  # noqa: BLE001
            QMessageBox.critical(self, "Ошибка", str(exc))

    def _load_preset(self) -> None:
        path, _ = QFileDialog.getOpenFileName(self, "Загрузить пресет", "", "JSON (*.json)")
        if not path:
            return
        try:
            self._apply_config(PipelineConfig.load(Path(path)))
        except Exception as exc:  # noqa: BLE001
            QMessageBox.critical(self, "Ошибка", str(exc))

    def _on_start(self) -> None:
        cfg = self._collect_config()
        if not cfg.input_dir or not Path(cfg.input_dir).is_dir():
            QMessageBox.warning(self, "Нет входной папки", "Выберите существующую входную папку.")
            return
        has_work = any([
            cfg.shazam.enabled, cfg.lyrics.enabled,
            cfg.convert.enabled, cfg.separate.enabled, cfg.transcribe.enabled,
        ])
        if not has_work:
            QMessageBox.warning(self, "Нет этапов", "Включите хотя бы один этап.")
            return
        needs_ffmpeg = cfg.convert.enabled or cfg.shazam.enabled
        if needs_ffmpeg and not shutil.which(cfg.convert.ffmpeg_path):
            QMessageBox.warning(self, "ffmpeg не найден",
                                f"Команда '{cfg.convert.ffmpeg_path}' не найдена в PATH.")
            return
        if cfg.separate.enabled and not shutil.which(cfg.separate.unblend_path):
            QMessageBox.warning(self, "unblend не найден",
                                f"Команда '{cfg.separate.unblend_path}' не найдена в PATH.")
            return

        self.log_view.clear()
        self._log_device_info()
        if cfg.transcribe.enabled and cfg.transcribe.language is None:
            self._append_log("⚠ Язык = auto: WhisperX определит язык на каждом файле.")

        self.progress.setValue(0)
        self.progress.setFormat("Запуск…")
        self.start_btn.setEnabled(False)
        self.stop_btn.setEnabled(True)

        self._worker = PipelineWorker(cfg)
        self._worker.log.connect(self._append_log)
        self._worker.progress.connect(self._on_progress)
        self._worker.finished_ok.connect(self._on_finished)
        self._worker.failed.connect(self._on_failed)
        self._worker.start()

    def _on_stop(self) -> None:
        if self._worker is not None:
            self._worker.stop()
            self._append_log("⏹ Запрошена остановка…")

    def _append_log(self, text: str) -> None:
        self.log_view.appendPlainText(text)
        sb = self.log_view.verticalScrollBar()
        sb.setValue(sb.maximum())

    def _on_progress(self, done: int, total: int, stage: str) -> None:
        self.progress.setMaximum(total)
        self.progress.setValue(done)
        label = STAGE_LABELS.get(stage, stage)
        self.progress.setFormat(f"{label} — %v / %m")

    def _on_finished(self, results: list) -> None:
        self.start_btn.setEnabled(True)
        self.stop_btn.setEnabled(False)
        ok = sum(1 for r in results if r.error is None)
        fail = len(results) - ok
        self.progress.setFormat(f"Готово — успешно {ok}, с ошибкой {fail}")
        self._append_log(f"\n✅ Готово. Успешно: {ok}, с ошибкой: {fail}.")

    def _on_failed(self, message: str) -> None:
        self.start_btn.setEnabled(True)
        self.stop_btn.setEnabled(False)
        self.progress.setFormat("Ошибка")
        self._append_log(f"\n✗ Ошибка пайплайна: {message}")
        QMessageBox.critical(self, "Ошибка", message)


# =========================================
# Файл: gui/worker.py
# =========================================

"""QThread wrapper around core.pipeline.run_pipeline."""
from __future__ import annotations

import threading

from PySide6.QtCore import QThread, Signal

from core.config import PipelineConfig
from core.pipeline import run_pipeline


class PipelineWorker(QThread):
    log = Signal(str)
    progress = Signal(int, int, str)   # done, total, stage
    finished_ok = Signal(list)
    failed = Signal(str)

    def __init__(self, config: PipelineConfig, parent=None):
        super().__init__(parent)
        self._config = config
        self._stop_event = threading.Event()

    def stop(self) -> None:
        self._stop_event.set()

    def run(self) -> None:
        try:
            results = run_pipeline(
                self._config,
                log=self.log.emit,
                on_progress=lambda d, t, s: self.progress.emit(d, t, s),
                stop_event=self._stop_event,
            )
            self.finished_ok.emit(results)
        except Exception as exc:  # noqa: BLE001
            self.failed.emit(f"{type(exc).__name__}: {exc}")


# =========================================
# Файл: gui/init.py
# =========================================

"""Qt GUI package."""


# =========================================
# Файл: gui/theme.py
# =========================================

"""Dark theme for the whole application (Catppuccin Macchiato-inspired)."""
from __future__ import annotations

DARK_STYLESHEET = """
QWidget {
    background-color: #1e1e2e;
    color: #cdd6f4;
    font-family: 'Inter', 'Segoe UI', 'Noto Sans', sans-serif;
    font-size: 10pt;
}
QMainWindow { background-color: #181825; }

QLabel { background: transparent; }
QLabel#appTitle {
    font-size: 18pt;
    font-weight: 700;
    color: #89b4fa;
    padding: 4px 0 10px 0;
}
QLabel#appSubtitle {
    color: #7f849c;
    font-size: 9pt;
    padding-bottom: 12px;
}
QLabel#deviceInfo {
    color: #a6adc8;
    font-size: 9pt;
    padding: 4px 8px;
    background-color: #181825;
    border: 1px solid #313244;
    border-radius: 6px;
}

QGroupBox {
    border: 1px solid #313244;
    border-radius: 10px;
    margin-top: 14px;
    padding: 16px 12px 12px 12px;
    background-color: #1e1e2e;
    font-weight: 600;
}
QGroupBox::title {
    subcontrol-origin: margin;
    subcontrol-position: top left;
    left: 12px;
    padding: 0 6px;
    color: #89b4fa;
}

QLineEdit, QSpinBox, QComboBox, QPlainTextEdit {
    background-color: #181825;
    border: 1px solid #313244;
    border-radius: 6px;
    padding: 6px 10px;
    color: #cdd6f4;
    selection-background-color: #89b4fa;
    selection-color: #1e1e2e;
    min-height: 22px;
}
QLineEdit:focus, QSpinBox:focus, QComboBox:focus, QPlainTextEdit:focus {
    border: 1px solid #89b4fa;
}
QLineEdit:disabled, QComboBox:disabled, QSpinBox:disabled {
    color: #585b70;
    background-color: #11111b;
}

QComboBox::drop-down { border: none; width: 22px; }
QComboBox::down-arrow {
    width: 0; height: 0;
    border-left: 4px solid transparent;
    border-right: 4px solid transparent;
    border-top: 5px solid #89b4fa;
    margin-right: 8px;
}
QComboBox QAbstractItemView {
    background-color: #181825;
    border: 1px solid #313244;
    selection-background-color: #313244;
    selection-color: #89b4fa;
    outline: none;
}

/* ─── buttons: higher contrast ─────────────────────────────────── */
QPushButton {
    background-color: #3d4159;
    border: 1px solid #5c6080;
    border-radius: 6px;
    padding: 8px 16px;
    color: #ffffff;
    font-weight: 600;
    min-height: 22px;
}
QPushButton:hover  { background-color: #4a4e6b; border-color: #7a7fa0; }
QPushButton:pressed { background-color: #585b70; }
QPushButton:focus  { border: 1px solid #a6c8ff; outline: none; }
QPushButton:disabled {
    background-color: #2a2d42;
    color: #6c7086;
    border-color: #3d4055;
}

QPushButton#primary {
    background-color: #89b4fa;
    color: #11111b;
    font-weight: 700;
    border: none;
    padding: 8px 20px;
}
QPushButton#primary:hover   { background-color: #a6c8ff; }
QPushButton#primary:pressed { background-color: #74a0e0; }
QPushButton#primary:disabled {
    background-color: #45475a;
    color: #6c7086;
}

QPushButton#danger {
    background-color: #f38ba8;
    color: #11111b;
    font-weight: 700;
    border: none;
    padding: 8px 20px;
}
QPushButton#danger:hover   { background-color: #f5a4b8; }
QPushButton#danger:pressed { background-color: #e07898; }
QPushButton#danger:disabled {
    background-color: #45475a;
    color: #6c7086;
}

QTabWidget::pane {
    border: 1px solid #313244;
    border-radius: 10px;
    background-color: #1e1e2e;
    top: -1px;
}
QTabBar::tab {
    background-color: #181825;
    color: #7f849c;
    padding: 8px 20px;
    border: 1px solid #313244;
    border-bottom: none;
    border-top-left-radius: 8px;
    border-top-right-radius: 8px;
    margin-right: 3px;
    font-weight: 500;
}
QTabBar::tab:selected {
    background-color: #1e1e2e;
    color: #89b4fa;
    border-color: #313244;
}
QTabBar::tab:hover:!selected { background-color: #252535; }

QCheckBox {
    spacing: 8px;
    background: transparent;
}
QCheckBox::indicator {
    width: 16px;
    height: 16px;
    border: 1px solid #5c6080;
    border-radius: 4px;
    background-color: #181825;
}
QCheckBox::indicator:hover { border-color: #89b4fa; }
QCheckBox::indicator:checked {
    background-color: #89b4fa;
    border-color: #89b4fa;
}

QProgressBar {
    background-color: #181825;
    border: 1px solid #313244;
    border-radius: 8px;
    text-align: center;
    color: #cdd6f4;
    min-height: 24px;
    max-height: 24px;
    font-weight: 500;
}
QProgressBar::chunk {
    background-color: #89b4fa;
    border-radius: 7px;
    margin: 1px;
}

QScrollBar:vertical {
    background: #181825;
    width: 12px;
    border-radius: 6px;
    margin: 0;
}
QScrollBar::handle:vertical {
    background: #45475a;
    border-radius: 6px;
    min-height: 24px;
    margin: 2px;
}
QScrollBar::handle:vertical:hover { background: #585b70; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; }

QSplitter::handle { background-color: #313244; }

QPlainTextEdit#log {
    background-color: #11111b;
    border: 1px solid #313244;
    border-radius: 10px;
    padding: 10px;
    font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
    font-size: 9.5pt;
    color: #cdd6f4;
}
"""


