Merge pull request #580 from Nezreka/refactor/import-routes-extraction
Extract import staging route helpers
This commit is contained in:
commit
2a2ffaa192
3 changed files with 432 additions and 165 deletions
186
core/imports/routes.py
Normal file
186
core/imports/routes.py
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
"""Import/staging controller helpers for Flask-style endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable, Dict
|
||||||
|
|
||||||
|
from core.imports.staging import (
|
||||||
|
AUDIO_EXTENSIONS,
|
||||||
|
get_import_suggestions_cache,
|
||||||
|
get_staging_path as _get_staging_path,
|
||||||
|
read_staging_file_metadata as _read_staging_file_metadata,
|
||||||
|
)
|
||||||
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
module_logger = get_logger("imports.routes")
|
||||||
|
|
||||||
|
|
||||||
|
def _default_read_tags(file_path: str):
|
||||||
|
from mutagen import File as MutagenFile
|
||||||
|
|
||||||
|
return MutagenFile(file_path, easy=True)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportRouteRuntime:
|
||||||
|
"""Dependencies needed to service import/staging HTTP endpoints."""
|
||||||
|
|
||||||
|
get_staging_path: Callable[[], str] = _get_staging_path
|
||||||
|
read_staging_file_metadata: Callable[[str, str], Dict[str, Any]] = _read_staging_file_metadata
|
||||||
|
read_tags: Callable[[str], Any] = _default_read_tags
|
||||||
|
logger: Any = module_logger
|
||||||
|
|
||||||
|
|
||||||
|
def staging_files(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
|
||||||
|
"""Scan the staging folder and return audio files with tag metadata."""
|
||||||
|
try:
|
||||||
|
staging_path = runtime.get_staging_path()
|
||||||
|
os.makedirs(staging_path, exist_ok=True)
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for root, _dirs, filenames in os.walk(staging_path):
|
||||||
|
for fname in filenames:
|
||||||
|
ext = os.path.splitext(fname)[1].lower()
|
||||||
|
if ext not in AUDIO_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
full_path = os.path.join(root, fname)
|
||||||
|
rel_path = os.path.relpath(full_path, staging_path)
|
||||||
|
|
||||||
|
meta = runtime.read_staging_file_metadata(full_path, rel_path)
|
||||||
|
|
||||||
|
files.append(
|
||||||
|
{
|
||||||
|
"filename": fname,
|
||||||
|
"rel_path": rel_path,
|
||||||
|
"full_path": full_path,
|
||||||
|
"title": meta["title"],
|
||||||
|
"artist": meta["albumartist"] or meta["artist"] or "Unknown Artist",
|
||||||
|
"album": meta["album"],
|
||||||
|
"track_number": meta["track_number"],
|
||||||
|
"disc_number": meta["disc_number"],
|
||||||
|
"extension": ext,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
files.sort(key=lambda f: f["filename"].lower())
|
||||||
|
return {"success": True, "files": files, "staging_path": staging_path}, 200
|
||||||
|
except Exception as exc:
|
||||||
|
runtime.logger.error("Error scanning staging files: %s", exc)
|
||||||
|
return {"success": False, "error": str(exc)}, 500
|
||||||
|
|
||||||
|
|
||||||
|
def staging_groups(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
|
||||||
|
"""Auto-detect album groups from staging files based on their tags."""
|
||||||
|
try:
|
||||||
|
staging_path = runtime.get_staging_path()
|
||||||
|
if not os.path.isdir(staging_path):
|
||||||
|
return {"success": True, "groups": []}, 200
|
||||||
|
|
||||||
|
album_groups = {}
|
||||||
|
for root, _dirs, filenames in os.walk(staging_path):
|
||||||
|
for fname in filenames:
|
||||||
|
ext = os.path.splitext(fname)[1].lower()
|
||||||
|
if ext not in AUDIO_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
full_path = os.path.join(root, fname)
|
||||||
|
rel_path = os.path.relpath(full_path, staging_path)
|
||||||
|
|
||||||
|
meta = runtime.read_staging_file_metadata(full_path, rel_path)
|
||||||
|
album = meta["album"]
|
||||||
|
artist = meta["albumartist"] or meta["artist"]
|
||||||
|
if not album or not artist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = (album.lower().strip(), artist.lower().strip())
|
||||||
|
if key not in album_groups:
|
||||||
|
album_groups[key] = {"album": album.strip(), "artist": artist.strip(), "files": []}
|
||||||
|
album_groups[key]["files"].append(
|
||||||
|
{
|
||||||
|
"filename": fname,
|
||||||
|
"full_path": full_path,
|
||||||
|
"title": meta["title"],
|
||||||
|
"track_number": meta["track_number"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
groups = []
|
||||||
|
for group in album_groups.values():
|
||||||
|
if len(group["files"]) >= 2:
|
||||||
|
group["files"].sort(key=lambda f: f.get("track_number") or 999)
|
||||||
|
groups.append(
|
||||||
|
{
|
||||||
|
"album": group["album"],
|
||||||
|
"artist": group["artist"],
|
||||||
|
"file_count": len(group["files"]),
|
||||||
|
"files": group["files"],
|
||||||
|
"file_paths": [f["full_path"] for f in group["files"]],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
groups.sort(key=lambda g: g["file_count"], reverse=True)
|
||||||
|
return {"success": True, "groups": groups}, 200
|
||||||
|
except Exception as exc:
|
||||||
|
runtime.logger.error("Error building staging groups: %s", exc)
|
||||||
|
return {"success": False, "error": str(exc)}, 500
|
||||||
|
|
||||||
|
|
||||||
|
def staging_hints(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
|
||||||
|
"""Extract album search hints from staging folder tags and folder names."""
|
||||||
|
try:
|
||||||
|
staging_path = runtime.get_staging_path()
|
||||||
|
if not os.path.isdir(staging_path):
|
||||||
|
return {"success": True, "hints": []}, 200
|
||||||
|
|
||||||
|
tag_albums = {}
|
||||||
|
folder_hints = {}
|
||||||
|
for root, _dirs, filenames in os.walk(staging_path):
|
||||||
|
audio_files = [f for f in filenames if os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS]
|
||||||
|
if not audio_files:
|
||||||
|
continue
|
||||||
|
|
||||||
|
rel_dir = os.path.relpath(root, staging_path)
|
||||||
|
if rel_dir != ".":
|
||||||
|
top_folder = rel_dir.split(os.sep)[0]
|
||||||
|
folder_hints[top_folder] = folder_hints.get(top_folder, 0) + len(audio_files)
|
||||||
|
|
||||||
|
for fname in audio_files:
|
||||||
|
full_path = os.path.join(root, fname)
|
||||||
|
try:
|
||||||
|
tags = runtime.read_tags(full_path)
|
||||||
|
if tags:
|
||||||
|
album = (tags.get("album") or [None])[0]
|
||||||
|
artist = (tags.get("artist") or (tags.get("albumartist") or [None]))[0]
|
||||||
|
if album:
|
||||||
|
key = (album.strip(), (artist or "").strip())
|
||||||
|
tag_albums[key] = tag_albums.get(key, 0) + 1
|
||||||
|
except Exception as exc:
|
||||||
|
runtime.logger.debug("tag read failed: %s", exc)
|
||||||
|
|
||||||
|
queries = []
|
||||||
|
seen_queries_lower = set()
|
||||||
|
|
||||||
|
for (album, artist), _count in sorted(tag_albums.items(), key=lambda x: -x[1]):
|
||||||
|
query = f"{album} {artist}".strip() if artist else album
|
||||||
|
if query.lower() not in seen_queries_lower:
|
||||||
|
seen_queries_lower.add(query.lower())
|
||||||
|
queries.append(query)
|
||||||
|
|
||||||
|
for folder, _count in sorted(folder_hints.items(), key=lambda x: -x[1]):
|
||||||
|
query = folder.replace("_", " ")
|
||||||
|
if query.lower() not in seen_queries_lower:
|
||||||
|
seen_queries_lower.add(query.lower())
|
||||||
|
queries.append(query)
|
||||||
|
|
||||||
|
return {"success": True, "hints": queries[:5]}, 200
|
||||||
|
except Exception as exc:
|
||||||
|
runtime.logger.error("Error getting staging hints: %s", exc)
|
||||||
|
return {"success": False, "error": str(exc)}, 500
|
||||||
|
|
||||||
|
|
||||||
|
def staging_suggestions() -> tuple[Dict[str, Any], int]:
|
||||||
|
"""Return cached import suggestions and readiness state."""
|
||||||
|
cache = get_import_suggestions_cache()
|
||||||
|
return {"success": True, "suggestions": cache["suggestions"], "ready": cache["built"]}, 200
|
||||||
230
tests/imports/test_import_routes.py
Normal file
230
tests/imports/test_import_routes.py
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
import core.imports.routes as import_routes
|
||||||
|
from core.imports.routes import ImportRouteRuntime, staging_files, staging_groups, staging_hints, staging_suggestions
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeLogger:
|
||||||
|
def __init__(self):
|
||||||
|
self.debug_messages = []
|
||||||
|
self.error_messages = []
|
||||||
|
|
||||||
|
def debug(self, msg, *args):
|
||||||
|
self.debug_messages.append(msg % args if args else msg)
|
||||||
|
|
||||||
|
def error(self, msg, *args):
|
||||||
|
self.error_messages.append(msg % args if args else msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _touch(path):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(b"")
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_for(files):
|
||||||
|
def _read_metadata(file_path, rel_path):
|
||||||
|
return files[rel_path]
|
||||||
|
|
||||||
|
return _read_metadata
|
||||||
|
|
||||||
|
|
||||||
|
def test_staging_files_returns_audio_files_with_metadata(tmp_path):
|
||||||
|
_touch(tmp_path / "Artist" / "02 - Song.flac")
|
||||||
|
_touch(tmp_path / "cover.jpg")
|
||||||
|
rel_song = os.path.join("Artist", "02 - Song.flac")
|
||||||
|
metadata = {
|
||||||
|
rel_song: {
|
||||||
|
"title": "Song",
|
||||||
|
"artist": "Track Artist",
|
||||||
|
"albumartist": "Album Artist",
|
||||||
|
"album": "Album",
|
||||||
|
"track_number": 2,
|
||||||
|
"disc_number": 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runtime = ImportRouteRuntime(
|
||||||
|
get_staging_path=lambda: str(tmp_path),
|
||||||
|
read_staging_file_metadata=_metadata_for(metadata),
|
||||||
|
logger=_FakeLogger(),
|
||||||
|
)
|
||||||
|
|
||||||
|
payload, status = staging_files(runtime)
|
||||||
|
|
||||||
|
assert status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["staging_path"] == str(tmp_path)
|
||||||
|
assert len(payload["files"]) == 1
|
||||||
|
assert payload["files"] == [
|
||||||
|
{
|
||||||
|
"filename": "02 - Song.flac",
|
||||||
|
"rel_path": rel_song,
|
||||||
|
"full_path": str(tmp_path / "Artist" / "02 - Song.flac"),
|
||||||
|
"title": "Song",
|
||||||
|
"artist": "Album Artist",
|
||||||
|
"album": "Album",
|
||||||
|
"track_number": 2,
|
||||||
|
"disc_number": 1,
|
||||||
|
"extension": ".flac",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_staging_groups_only_returns_multi_file_album_groups(tmp_path):
|
||||||
|
_touch(tmp_path / "a.mp3")
|
||||||
|
_touch(tmp_path / "b.mp3")
|
||||||
|
_touch(tmp_path / "single.mp3")
|
||||||
|
metadata = {
|
||||||
|
"a.mp3": {
|
||||||
|
"title": "A",
|
||||||
|
"artist": "Artist",
|
||||||
|
"albumartist": "",
|
||||||
|
"album": "Album",
|
||||||
|
"track_number": 2,
|
||||||
|
"disc_number": 1,
|
||||||
|
},
|
||||||
|
"b.mp3": {
|
||||||
|
"title": "B",
|
||||||
|
"artist": "Artist",
|
||||||
|
"albumartist": "",
|
||||||
|
"album": "Album",
|
||||||
|
"track_number": 1,
|
||||||
|
"disc_number": 1,
|
||||||
|
},
|
||||||
|
"single.mp3": {
|
||||||
|
"title": "Single",
|
||||||
|
"artist": "Other",
|
||||||
|
"albumartist": "",
|
||||||
|
"album": "Other Album",
|
||||||
|
"track_number": 1,
|
||||||
|
"disc_number": 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
runtime = ImportRouteRuntime(
|
||||||
|
get_staging_path=lambda: str(tmp_path),
|
||||||
|
read_staging_file_metadata=_metadata_for(metadata),
|
||||||
|
logger=_FakeLogger(),
|
||||||
|
)
|
||||||
|
|
||||||
|
payload, status = staging_groups(runtime)
|
||||||
|
|
||||||
|
assert status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert len(payload["groups"]) == 1
|
||||||
|
group = payload["groups"][0]
|
||||||
|
assert group["album"] == "Album"
|
||||||
|
assert group["artist"] == "Artist"
|
||||||
|
assert group["file_count"] == 2
|
||||||
|
assert [f["filename"] for f in group["files"]] == ["b.mp3", "a.mp3"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_staging_hints_prefers_tag_queries_then_folder_queries(tmp_path):
|
||||||
|
_touch(tmp_path / "Folder_Album" / "01.mp3")
|
||||||
|
_touch(tmp_path / "Folder_Album" / "02.mp3")
|
||||||
|
_touch(tmp_path / "Loose" / "track.flac")
|
||||||
|
|
||||||
|
def _read_tags(file_path):
|
||||||
|
if file_path.endswith("01.mp3") or file_path.endswith("02.mp3"):
|
||||||
|
return {"album": ["Tagged Album"], "artist": ["Tagged Artist"]}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
runtime = ImportRouteRuntime(
|
||||||
|
get_staging_path=lambda: str(tmp_path),
|
||||||
|
read_tags=_read_tags,
|
||||||
|
logger=_FakeLogger(),
|
||||||
|
)
|
||||||
|
|
||||||
|
payload, status = staging_hints(runtime)
|
||||||
|
|
||||||
|
assert status == 200
|
||||||
|
assert payload == {
|
||||||
|
"success": True,
|
||||||
|
"hints": ["Tagged Album Tagged Artist", "Folder Album", "Loose"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_staging_suggestions_returns_cache_payload(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
import_routes,
|
||||||
|
"get_import_suggestions_cache",
|
||||||
|
lambda: {"suggestions": [{"album": "Album"}], "built": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload, status = staging_suggestions()
|
||||||
|
|
||||||
|
assert status == 200
|
||||||
|
assert payload == {
|
||||||
|
"success": True,
|
||||||
|
"suggestions": [{"album": "Album"}],
|
||||||
|
"ready": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_staging_groups_returns_empty_for_missing_staging_path(tmp_path):
|
||||||
|
runtime = ImportRouteRuntime(
|
||||||
|
get_staging_path=lambda: str(tmp_path / "missing"),
|
||||||
|
logger=_FakeLogger(),
|
||||||
|
)
|
||||||
|
|
||||||
|
payload, status = staging_groups(runtime)
|
||||||
|
|
||||||
|
assert status == 200
|
||||||
|
assert payload == {"success": True, "groups": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_staging_hints_returns_empty_for_missing_staging_path(tmp_path):
|
||||||
|
runtime = ImportRouteRuntime(
|
||||||
|
get_staging_path=lambda: str(tmp_path / "missing"),
|
||||||
|
logger=_FakeLogger(),
|
||||||
|
)
|
||||||
|
|
||||||
|
payload, status = staging_hints(runtime)
|
||||||
|
|
||||||
|
assert status == 200
|
||||||
|
assert payload == {"success": True, "hints": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_staging_files_returns_error_when_path_resolution_fails():
|
||||||
|
logger = _FakeLogger()
|
||||||
|
runtime = ImportRouteRuntime(
|
||||||
|
get_staging_path=lambda: (_ for _ in ()).throw(RuntimeError("path boom")),
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload, status = staging_files(runtime)
|
||||||
|
|
||||||
|
assert status == 500
|
||||||
|
assert payload["success"] is False
|
||||||
|
assert payload["error"] == "path boom"
|
||||||
|
assert logger.error_messages == ["Error scanning staging files: path boom"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_staging_groups_returns_error_when_metadata_read_fails(tmp_path):
|
||||||
|
_touch(tmp_path / "a.mp3")
|
||||||
|
logger = _FakeLogger()
|
||||||
|
runtime = ImportRouteRuntime(
|
||||||
|
get_staging_path=lambda: str(tmp_path),
|
||||||
|
read_staging_file_metadata=lambda _file_path, _rel_path: (_ for _ in ()).throw(RuntimeError("tag boom")),
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload, status = staging_groups(runtime)
|
||||||
|
|
||||||
|
assert status == 500
|
||||||
|
assert payload["success"] is False
|
||||||
|
assert payload["error"] == "tag boom"
|
||||||
|
assert logger.error_messages == ["Error building staging groups: tag boom"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_staging_hints_returns_error_when_path_resolution_fails():
|
||||||
|
logger = _FakeLogger()
|
||||||
|
runtime = ImportRouteRuntime(
|
||||||
|
get_staging_path=lambda: (_ for _ in ()).throw(RuntimeError("hint boom")),
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload, status = staging_hints(runtime)
|
||||||
|
|
||||||
|
assert status == 500
|
||||||
|
assert payload["success"] is False
|
||||||
|
assert payload["error"] == "hint boom"
|
||||||
|
assert logger.error_messages == ["Error getting staging hints: hint boom"]
|
||||||
181
web_server.py
181
web_server.py
|
|
@ -169,7 +169,6 @@ from core.imports.album import (
|
||||||
from core.imports.album_naming import resolve_album_group as _resolve_album_group
|
from core.imports.album_naming import resolve_album_group as _resolve_album_group
|
||||||
from core.imports.filename import extract_track_number_from_filename, parse_filename_metadata
|
from core.imports.filename import extract_track_number_from_filename, parse_filename_metadata
|
||||||
from core.imports.staging import (
|
from core.imports.staging import (
|
||||||
get_import_suggestions_cache,
|
|
||||||
get_primary_source,
|
get_primary_source,
|
||||||
get_staging_path,
|
get_staging_path,
|
||||||
read_staging_file_metadata,
|
read_staging_file_metadata,
|
||||||
|
|
@ -178,6 +177,11 @@ from core.imports.staging import (
|
||||||
search_import_tracks,
|
search_import_tracks,
|
||||||
start_import_suggestions_cache,
|
start_import_suggestions_cache,
|
||||||
)
|
)
|
||||||
|
from core.imports.routes import ImportRouteRuntime as _ImportRouteRuntime
|
||||||
|
from core.imports.routes import staging_files as _import_staging_files
|
||||||
|
from core.imports.routes import staging_groups as _import_staging_groups
|
||||||
|
from core.imports.routes import staging_hints as _import_staging_hints
|
||||||
|
from core.imports.routes import staging_suggestions as _import_staging_suggestions
|
||||||
from core.imports.paths import build_final_path_for_track as _build_final_path_for_track
|
from core.imports.paths import build_final_path_for_track as _build_final_path_for_track
|
||||||
from core.imports.pipeline import build_import_pipeline_runtime as _build_import_pipeline_runtime
|
from core.imports.pipeline import build_import_pipeline_runtime as _build_import_pipeline_runtime
|
||||||
from core.metadata.common import get_file_lock
|
from core.metadata.common import get_file_lock
|
||||||
|
|
@ -34264,174 +34268,26 @@ def repair_job_progress():
|
||||||
# IMPORT / STAGING SYSTEM
|
# IMPORT / STAGING SYSTEM
|
||||||
# ================================================================================================
|
# ================================================================================================
|
||||||
|
|
||||||
AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.opus', '.m4a', '.aac', '.wav', '.wma', '.aiff', '.aif', '.ape'}
|
def _build_import_route_runtime():
|
||||||
|
return _ImportRouteRuntime(logger=logger)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/import/staging/files', methods=['GET'])
|
@app.route('/api/import/staging/files', methods=['GET'])
|
||||||
def import_staging_files():
|
def import_staging_files():
|
||||||
"""Scan the staging folder and return audio files with tag metadata."""
|
payload, status = _import_staging_files(_build_import_route_runtime())
|
||||||
try:
|
return jsonify(payload), status
|
||||||
staging_path = get_staging_path()
|
|
||||||
os.makedirs(staging_path, exist_ok=True)
|
|
||||||
|
|
||||||
files = []
|
|
||||||
for root, _dirs, filenames in os.walk(staging_path):
|
|
||||||
for fname in filenames:
|
|
||||||
ext = os.path.splitext(fname)[1].lower()
|
|
||||||
if ext not in AUDIO_EXTENSIONS:
|
|
||||||
continue
|
|
||||||
full_path = os.path.join(root, fname)
|
|
||||||
rel_path = os.path.relpath(full_path, staging_path)
|
|
||||||
|
|
||||||
meta = read_staging_file_metadata(full_path, rel_path)
|
|
||||||
|
|
||||||
files.append({
|
|
||||||
'filename': fname,
|
|
||||||
'rel_path': rel_path,
|
|
||||||
'full_path': full_path,
|
|
||||||
'title': meta['title'],
|
|
||||||
'artist': meta['albumartist'] or meta['artist'] or 'Unknown Artist',
|
|
||||||
'album': meta['album'],
|
|
||||||
'track_number': meta['track_number'],
|
|
||||||
'disc_number': meta['disc_number'],
|
|
||||||
'extension': ext
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort by filename
|
|
||||||
files.sort(key=lambda f: f['filename'].lower())
|
|
||||||
return jsonify({'success': True, 'files': files, 'staging_path': staging_path})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error scanning staging files: {e}")
|
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/import/staging/groups', methods=['GET'])
|
@app.route('/api/import/staging/groups', methods=['GET'])
|
||||||
def import_staging_groups():
|
def import_staging_groups():
|
||||||
"""Auto-detect album groups from staging files based on their tags.
|
payload, status = _import_staging_groups(_build_import_route_runtime())
|
||||||
|
return jsonify(payload), status
|
||||||
Groups files by (album_tag, artist) where both are non-empty and at least 2 files share
|
|
||||||
the same album+artist combo. Returns groups sorted by file count descending.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
staging_path = get_staging_path()
|
|
||||||
if not os.path.isdir(staging_path):
|
|
||||||
return jsonify({'success': True, 'groups': []})
|
|
||||||
|
|
||||||
# Scan files and group by album+artist tags
|
|
||||||
album_groups = {} # (album_lower, artist_lower) -> {album, artist, files: []}
|
|
||||||
for root, _dirs, filenames in os.walk(staging_path):
|
|
||||||
for fname in filenames:
|
|
||||||
ext = os.path.splitext(fname)[1].lower()
|
|
||||||
if ext not in AUDIO_EXTENSIONS:
|
|
||||||
continue
|
|
||||||
full_path = os.path.join(root, fname)
|
|
||||||
rel_path = os.path.relpath(full_path, staging_path)
|
|
||||||
|
|
||||||
meta = read_staging_file_metadata(full_path, rel_path)
|
|
||||||
album = meta['album']
|
|
||||||
artist = meta['albumartist'] or meta['artist']
|
|
||||||
if not album or not artist:
|
|
||||||
continue
|
|
||||||
|
|
||||||
key = (album.lower().strip(), artist.lower().strip())
|
|
||||||
if key not in album_groups:
|
|
||||||
album_groups[key] = {
|
|
||||||
'album': album.strip(),
|
|
||||||
'artist': artist.strip(),
|
|
||||||
'files': []
|
|
||||||
}
|
|
||||||
album_groups[key]['files'].append({
|
|
||||||
'filename': fname,
|
|
||||||
'full_path': full_path,
|
|
||||||
'title': meta['title'],
|
|
||||||
'track_number': meta['track_number'],
|
|
||||||
})
|
|
||||||
|
|
||||||
# Only return groups with 2+ files
|
|
||||||
groups = []
|
|
||||||
for group in album_groups.values():
|
|
||||||
if len(group['files']) >= 2:
|
|
||||||
group['files'].sort(key=lambda f: f.get('track_number') or 999)
|
|
||||||
groups.append({
|
|
||||||
'album': group['album'],
|
|
||||||
'artist': group['artist'],
|
|
||||||
'file_count': len(group['files']),
|
|
||||||
'files': group['files'],
|
|
||||||
'file_paths': [f['full_path'] for f in group['files']],
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort by file count descending
|
|
||||||
groups.sort(key=lambda g: g['file_count'], reverse=True)
|
|
||||||
|
|
||||||
return jsonify({'success': True, 'groups': groups})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error building staging groups: {e}")
|
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/import/staging/hints', methods=['GET'])
|
@app.route('/api/import/staging/hints', methods=['GET'])
|
||||||
def import_staging_hints():
|
def import_staging_hints():
|
||||||
"""Extract album search hints from staging folder (tags + folder names). Fast — no Spotify calls."""
|
payload, status = _import_staging_hints(_build_import_route_runtime())
|
||||||
try:
|
return jsonify(payload), status
|
||||||
staging_path = get_staging_path()
|
|
||||||
if not os.path.isdir(staging_path):
|
|
||||||
return jsonify({'success': True, 'hints': []})
|
|
||||||
|
|
||||||
# Collect hints from tags and folder structure
|
|
||||||
tag_albums = {} # (album, artist) -> file count
|
|
||||||
folder_hints = {} # subfolder name -> file count
|
|
||||||
|
|
||||||
for root, _dirs, filenames in os.walk(staging_path):
|
|
||||||
audio_files = [f for f in filenames if os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS]
|
|
||||||
if not audio_files:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Folder-based hint: use immediate subfolder name relative to staging
|
|
||||||
rel_dir = os.path.relpath(root, staging_path)
|
|
||||||
if rel_dir != '.':
|
|
||||||
# Use the top-level subfolder as the hint
|
|
||||||
top_folder = rel_dir.split(os.sep)[0]
|
|
||||||
folder_hints[top_folder] = folder_hints.get(top_folder, 0) + len(audio_files)
|
|
||||||
|
|
||||||
# Tag-based hints
|
|
||||||
for fname in audio_files:
|
|
||||||
full_path = os.path.join(root, fname)
|
|
||||||
try:
|
|
||||||
from mutagen import File as MutagenFile
|
|
||||||
tags = MutagenFile(full_path, easy=True)
|
|
||||||
if tags:
|
|
||||||
album = (tags.get('album') or [None])[0]
|
|
||||||
artist = (tags.get('artist') or (tags.get('albumartist') or [None]))[0]
|
|
||||||
if album:
|
|
||||||
key = (album.strip(), (artist or '').strip())
|
|
||||||
tag_albums[key] = tag_albums.get(key, 0) + 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("tag read failed: %s", e)
|
|
||||||
|
|
||||||
# Build search queries, prioritizing tag-based hints (more specific)
|
|
||||||
queries = []
|
|
||||||
seen_queries_lower = set()
|
|
||||||
|
|
||||||
# Tag-based: sort by file count descending
|
|
||||||
for (album, artist), _count in sorted(tag_albums.items(), key=lambda x: -x[1]):
|
|
||||||
q = f"{album} {artist}".strip() if artist else album
|
|
||||||
if q.lower() not in seen_queries_lower:
|
|
||||||
seen_queries_lower.add(q.lower())
|
|
||||||
queries.append(q)
|
|
||||||
|
|
||||||
# Folder-based: parse "Artist - Album" pattern or use as-is
|
|
||||||
for folder, _count in sorted(folder_hints.items(), key=lambda x: -x[1]):
|
|
||||||
q = folder.replace('_', ' ')
|
|
||||||
if q.lower() not in seen_queries_lower:
|
|
||||||
seen_queries_lower.add(q.lower())
|
|
||||||
queries.append(q)
|
|
||||||
|
|
||||||
# Cap at 5 queries to keep it fast
|
|
||||||
queries = queries[:5]
|
|
||||||
|
|
||||||
return jsonify({'success': True, 'hints': queries})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting staging hints: {e}")
|
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/import/search/albums', methods=['GET'])
|
@app.route('/api/import/search/albums', methods=['GET'])
|
||||||
|
|
@ -34942,13 +34798,8 @@ def auto_import_clear_completed():
|
||||||
|
|
||||||
@app.route('/api/import/staging/suggestions', methods=['GET'])
|
@app.route('/api/import/staging/suggestions', methods=['GET'])
|
||||||
def import_staging_suggestions():
|
def import_staging_suggestions():
|
||||||
"""Return cached import suggestions. If cache isn't built yet, returns partial/empty with a flag."""
|
payload, status = _import_staging_suggestions()
|
||||||
cache = get_import_suggestions_cache()
|
return jsonify(payload), status
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'suggestions': cache['suggestions'],
|
|
||||||
'ready': cache['built'],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================================================
|
# ================================================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue