Add OneTagger integration module
This commit is contained in:
284
onetagger_integration.py
Normal file
284
onetagger_integration.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
OneTagger CLI integration helpers.
|
||||
|
||||
This module wraps the `onetagger-cli autotagger` command so the rest of the
|
||||
application can tag a single audio file in isolation. Key expectations:
|
||||
- The user has installed onetagger-cli (>= 1.7.0) and created an Auto Tag
|
||||
profile via the OneTagger GUI, exporting its JSON config. Authentication
|
||||
(Discogs token, Spotify client ID/secret, etc.) is managed in OneTagger.
|
||||
- The profile should enable platforms Beatport/Spotify/Discogs/iTunes/Bandcamp,
|
||||
enable tag overwrites and album art (1000px for Beatport/iTunes), include
|
||||
Artist/Title/Album/Label/Genre/Style/Release/Publish dates, enable Shazam
|
||||
matching and filename parsing, ID3v2.4 + short title, and save album art.
|
||||
- OneTagger writes run reports as success-*.m3u and failed-*.m3u under its
|
||||
data_dir/runs; this module inspects those to decide whether tagging succeeded.
|
||||
|
||||
Usage:
|
||||
from onetagger_integration import auto_tag_postprocess, OneTaggerResult
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OneTaggerError(Exception):
|
||||
"""Base exception for OneTagger integration errors."""
|
||||
|
||||
|
||||
class OneTaggerCliNotFoundError(OneTaggerError):
|
||||
"""Raised when onetagger-cli cannot be located or executed."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class OneTaggerResult:
|
||||
success: bool
|
||||
tagged_file: Path
|
||||
log_file: Optional[Path]
|
||||
success_playlist: Optional[Path]
|
||||
failed_playlist: Optional[Path]
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
|
||||
def get_default_onetagger_data_dir() -> Path:
|
||||
"""Return the default OneTagger data directory based on platform."""
|
||||
if sys.platform.startswith("linux"):
|
||||
return Path.home() / ".config" / "onetagger"
|
||||
if sys.platform == "darwin":
|
||||
return Path.home() / "Library" / "Preferences" / "com.OneTagger.OneTagger"
|
||||
if sys.platform.startswith("win"):
|
||||
appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
||||
return Path(appdata) / "OneTagger" / "OneTagger"
|
||||
return Path.home() / ".onetagger"
|
||||
|
||||
|
||||
def find_latest_run_playlists(data_dir: Path) -> tuple[Optional[Path], Optional[Path]]:
|
||||
"""Return the most recent (by mtime, then name) success/failed playlists."""
|
||||
runs_dir = data_dir / "runs"
|
||||
if not runs_dir.exists():
|
||||
return None, None
|
||||
|
||||
def _latest(prefix: str) -> Optional[Path]:
|
||||
candidates = list(runs_dir.glob(f"{prefix}-*.m3u"))
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(key=lambda p: (p.stat().st_mtime, p.name))
|
||||
return candidates[-1]
|
||||
|
||||
return _latest("success"), _latest("failed")
|
||||
|
||||
|
||||
def playlist_contains_path(playlist: Path, target: Path) -> bool:
|
||||
"""Check if target path appears in the .m3u playlist."""
|
||||
if not playlist or not playlist.exists():
|
||||
return False
|
||||
try:
|
||||
target_norm = str(target.resolve())
|
||||
except Exception:
|
||||
target_norm = str(target)
|
||||
target_norm = os.path.normcase(os.path.normpath(target_norm))
|
||||
try:
|
||||
with playlist.open("r", encoding="utf-8", errors="ignore") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
try:
|
||||
cand = os.path.normcase(os.path.normpath(str(Path(line).expanduser().resolve())))
|
||||
except Exception:
|
||||
cand = os.path.normcase(os.path.normpath(line))
|
||||
if cand == target_norm:
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_cli_path(onetagger_cli_path: Path | str) -> Path:
|
||||
"""Resolve CLI path or raise OneTaggerCliNotFoundError.
|
||||
|
||||
Tries (in order):
|
||||
- Exact path if provided (absolute or relative to cwd)
|
||||
- Binary alongside this module (useful when bundled in the project root)
|
||||
- cwd / <name> when only a bare name was provided
|
||||
- PATH lookup
|
||||
"""
|
||||
cli_path = Path(onetagger_cli_path) if not isinstance(onetagger_cli_path, Path) else onetagger_cli_path
|
||||
candidates = []
|
||||
candidates.append(cli_path)
|
||||
if not cli_path.is_absolute():
|
||||
module_dir = Path(__file__).resolve().parent
|
||||
candidates.append(module_dir / cli_path.name)
|
||||
candidates.append(Path.cwd() / cli_path)
|
||||
for cand in candidates:
|
||||
if cand.exists():
|
||||
if os.access(cand, os.X_OK):
|
||||
return cand.resolve()
|
||||
raise OneTaggerCliNotFoundError(f"onetagger-cli not executable: {cand}")
|
||||
found = shutil.which(str(onetagger_cli_path))
|
||||
if found:
|
||||
return Path(found).resolve()
|
||||
raise OneTaggerCliNotFoundError(f"onetagger-cli not found at {onetagger_cli_path}")
|
||||
|
||||
|
||||
def _list_run_playlists(data_dir: Path) -> set[Path]:
|
||||
runs_dir = data_dir / "runs"
|
||||
if not runs_dir.exists():
|
||||
return set()
|
||||
return set(runs_dir.glob("*.m3u"))
|
||||
|
||||
|
||||
def _sanitize_config(config_path: Path, work_dir: Path) -> Path:
|
||||
"""Create a sanitized copy of the config that drops platforms lacking creds."""
|
||||
try:
|
||||
cfg = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return config_path
|
||||
|
||||
platforms = cfg.get("platforms")
|
||||
if not isinstance(platforms, list):
|
||||
return config_path
|
||||
|
||||
modified = False
|
||||
spotify_cfg = cfg.get("spotify") or {}
|
||||
discogs_cfg = ((cfg.get("custom") or {}).get("discogs")) if isinstance(cfg.get("custom"), dict) else {}
|
||||
|
||||
def _missing(val):
|
||||
return val is None or (isinstance(val, str) and not val.strip())
|
||||
|
||||
if "spotify" in platforms:
|
||||
if _missing(spotify_cfg.get("clientId")) or _missing(spotify_cfg.get("clientSecret")):
|
||||
platforms = [p for p in platforms if p != "spotify"]
|
||||
cfg["platforms"] = platforms
|
||||
cfg["spotify"] = None
|
||||
modified = True
|
||||
|
||||
if "discogs" in platforms:
|
||||
token = discogs_cfg.get("token") if isinstance(discogs_cfg, dict) else None
|
||||
if _missing(token):
|
||||
platforms = [p for p in platforms if p != "discogs"]
|
||||
cfg["platforms"] = platforms
|
||||
if isinstance(cfg.get("custom"), dict) and "discogs" in cfg["custom"]:
|
||||
cfg["custom"]["discogs"]["token"] = ""
|
||||
modified = True
|
||||
|
||||
if not modified:
|
||||
return config_path
|
||||
|
||||
sanitized_path = work_dir / "sanitized-config.json"
|
||||
sanitized_path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
||||
return sanitized_path
|
||||
|
||||
|
||||
def tag_audio_with_onetagger(
|
||||
audio_file: Path,
|
||||
onetagger_cli_path: Path | str,
|
||||
config_path: Path,
|
||||
data_dir: Optional[Path] = None,
|
||||
timeout: int = 1800,
|
||||
) -> OneTaggerResult:
|
||||
"""Tag a single audio file using OneTagger CLI Auto Tag.
|
||||
|
||||
The file is copied into a temporary directory and only that copy is tagged.
|
||||
If tagging succeeds (per success-*.m3u), the original file is atomically
|
||||
replaced by the tagged copy.
|
||||
"""
|
||||
src = Path(audio_file)
|
||||
if not src.exists() or not src.is_file():
|
||||
raise FileNotFoundError(f"Audio file not found: {src}")
|
||||
cfg = Path(config_path)
|
||||
if not cfg.exists() or not cfg.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"OneTagger config not found: {cfg}. Export a valid Auto Tag profile from OneTagger GUI."
|
||||
)
|
||||
cli = _resolve_cli_path(onetagger_cli_path)
|
||||
data_dir = Path(data_dir) if data_dir else get_default_onetagger_data_dir()
|
||||
runs_before = _list_run_playlists(data_dir)
|
||||
|
||||
log_file = data_dir / "onetagger.log"
|
||||
log_file = log_file if log_file.exists() else None
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="onetagger_work_") as tmpdir:
|
||||
tmpdir_path = Path(tmpdir)
|
||||
tmp_file = tmpdir_path / src.name
|
||||
shutil.copy2(src, tmp_file)
|
||||
|
||||
config_to_use = _sanitize_config(cfg, tmpdir_path)
|
||||
|
||||
cmd = [str(cli), "autotagger", "--config", str(config_to_use), "--path", str(tmpdir_path)]
|
||||
LOG.debug("Running OneTagger: %s", " ".join(cmd))
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=timeout,
|
||||
)
|
||||
LOG.debug("OneTagger exited %s", result.returncode)
|
||||
|
||||
runs_after = _list_run_playlists(data_dir)
|
||||
new_runs = runs_after - runs_before
|
||||
|
||||
success_playlist, failed_playlist = find_latest_run_playlists(data_dir)
|
||||
# Prefer freshly created playlists if present
|
||||
def _select(prefix: str, fallback: Optional[Path]) -> Optional[Path]:
|
||||
candidates = [p for p in new_runs if p.name.startswith(f"{prefix}-")]
|
||||
if candidates:
|
||||
candidates.sort(key=lambda p: (p.stat().st_mtime, p.name))
|
||||
return candidates[-1]
|
||||
return fallback
|
||||
|
||||
success_playlist = _select("success", success_playlist)
|
||||
failed_playlist = _select("failed", failed_playlist)
|
||||
|
||||
success_in_playlist = playlist_contains_path(success_playlist, tmp_file) if success_playlist else False
|
||||
failed_in_playlist = playlist_contains_path(failed_playlist, tmp_file) if failed_playlist else False
|
||||
|
||||
cli_ok = result.returncode == 0
|
||||
tagging_success = cli_ok and success_in_playlist and not failed_in_playlist
|
||||
|
||||
if tagging_success:
|
||||
os.replace(tmp_file, src)
|
||||
else:
|
||||
if not cli_ok and not result.stderr:
|
||||
result.stderr = f"onetagger-cli exited with code {result.returncode}"
|
||||
if cli_ok and not (success_in_playlist or failed_in_playlist):
|
||||
result.stderr = (
|
||||
result.stderr
|
||||
+ "\n"
|
||||
if result.stderr
|
||||
else ""
|
||||
) + "File not found in success or failed playlists; tagging uncertain."
|
||||
|
||||
return OneTaggerResult(
|
||||
success=tagging_success,
|
||||
tagged_file=src,
|
||||
log_file=log_file,
|
||||
success_playlist=success_playlist,
|
||||
failed_playlist=failed_playlist,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
)
|
||||
|
||||
|
||||
def auto_tag_postprocess(audio_file: Path, settings) -> OneTaggerResult:
|
||||
"""Convenience wrapper that reads OneTagger settings from an app settings object."""
|
||||
return tag_audio_with_onetagger(
|
||||
audio_file=audio_file,
|
||||
onetagger_cli_path=settings.onetagger_cli_path,
|
||||
config_path=settings.onetagger_config_path,
|
||||
data_dir=getattr(settings, "onetagger_data_dir", None),
|
||||
timeout=getattr(settings, "onetagger_timeout", 1800),
|
||||
)
|
||||
Reference in New Issue
Block a user