Add single file support to auto-import worker
Loose audio files in the staging root are now picked up alongside album folders. Singles are identified via embedded tags, filename parsing (Artist - Title.ext), or AcoustID fingerprinting, then matched against the configured metadata source. Confidence-gated processing applies the same way as album folders (90%+ auto, 70-90% review, <70% manual).
This commit is contained in:
parent
c20529dbb5
commit
d66adb3c6e
1 changed files with 180 additions and 7 deletions
|
|
@ -1,9 +1,12 @@
|
||||||
"""Auto-Import Worker — watches staging folder, identifies music, and processes automatically.
|
"""Auto-Import Worker — watches staging folder, identifies music, and processes automatically.
|
||||||
|
|
||||||
Scans the staging folder for audio files, groups them by folder (album),
|
Scans the staging folder for audio files and album folders, identifies them
|
||||||
identifies them using tags/folder names/AcoustID, matches to metadata source
|
using tags/filenames/AcoustID, matches to metadata source tracklists, and
|
||||||
tracklists, and processes high-confidence matches through the post-processing
|
processes high-confidence matches through the post-processing pipeline.
|
||||||
pipeline. Lower-confidence matches are queued for user review.
|
Lower-confidence matches are queued for user review.
|
||||||
|
|
||||||
|
Supports both album folders (directories containing audio files) and single
|
||||||
|
loose audio files in the staging root.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
@ -32,6 +35,7 @@ class FolderCandidate:
|
||||||
audio_files: List[str] = field(default_factory=list)
|
audio_files: List[str] = field(default_factory=list)
|
||||||
disc_structure: Dict[int, List[str]] = field(default_factory=dict) # disc_num -> files
|
disc_structure: Dict[int, List[str]] = field(default_factory=dict) # disc_num -> files
|
||||||
folder_hash: str = ''
|
folder_hash: str = ''
|
||||||
|
is_single: bool = False # True for loose files in staging root
|
||||||
|
|
||||||
|
|
||||||
def _compute_folder_hash(audio_files: List[str]) -> str:
|
def _compute_folder_hash(audio_files: List[str]) -> str:
|
||||||
|
|
@ -312,7 +316,7 @@ class AutoImportWorker:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _enumerate_folders(self, staging: str) -> List[FolderCandidate]:
|
def _enumerate_folders(self, staging: str) -> List[FolderCandidate]:
|
||||||
"""Find album folder candidates in staging directory."""
|
"""Find album folder and single file candidates in staging directory."""
|
||||||
candidates = []
|
candidates = []
|
||||||
try:
|
try:
|
||||||
entries = sorted(os.listdir(staging))
|
entries = sorted(os.listdir(staging))
|
||||||
|
|
@ -321,6 +325,16 @@ class AutoImportWorker:
|
||||||
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
full_path = os.path.join(staging, entry)
|
full_path = os.path.join(staging, entry)
|
||||||
|
|
||||||
|
# Loose audio file in staging root → single track candidate
|
||||||
|
if os.path.isfile(full_path) and os.path.splitext(entry)[1].lower() in AUDIO_EXTENSIONS:
|
||||||
|
folder_hash = _compute_folder_hash([full_path])
|
||||||
|
candidates.append(FolderCandidate(
|
||||||
|
path=full_path, name=entry, audio_files=[full_path],
|
||||||
|
folder_hash=folder_hash, is_single=True
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
if not os.path.isdir(full_path):
|
if not os.path.isdir(full_path):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -395,7 +409,10 @@ class AutoImportWorker:
|
||||||
# ── Identification ──
|
# ── Identification ──
|
||||||
|
|
||||||
def _identify_folder(self, candidate: FolderCandidate) -> Optional[Dict]:
|
def _identify_folder(self, candidate: FolderCandidate) -> Optional[Dict]:
|
||||||
"""Identify what album a folder contains. Returns identification dict or None."""
|
"""Identify what album/track a folder or single file contains."""
|
||||||
|
|
||||||
|
if candidate.is_single:
|
||||||
|
return self._identify_single(candidate)
|
||||||
|
|
||||||
# Strategy 1: Read tags
|
# Strategy 1: Read tags
|
||||||
tag_result = self._identify_from_tags(candidate)
|
tag_result = self._identify_from_tags(candidate)
|
||||||
|
|
@ -414,6 +431,141 @@ class AutoImportWorker:
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _identify_single(self, candidate: FolderCandidate) -> Optional[Dict]:
|
||||||
|
"""Identify a single audio file from tags, filename, or AcoustID."""
|
||||||
|
file_path = candidate.audio_files[0]
|
||||||
|
tags = _read_file_tags(file_path)
|
||||||
|
|
||||||
|
artist = tags.get('artist', '')
|
||||||
|
title = tags.get('title', '')
|
||||||
|
album = tags.get('album', '')
|
||||||
|
|
||||||
|
# Fallback: parse filename (Artist - Title.ext)
|
||||||
|
if not artist or not title:
|
||||||
|
basename = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
|
parts = re.split(r'\s*[-–—]\s*', basename, maxsplit=1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
artist = artist or parts[0].strip()
|
||||||
|
title = title or parts[1].strip()
|
||||||
|
elif not title:
|
||||||
|
title = basename.strip()
|
||||||
|
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Search metadata source for track
|
||||||
|
result = self._search_single_track(artist, title, album)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Fallback: AcoustID fingerprint
|
||||||
|
try:
|
||||||
|
from core.acoustid_client import AcoustIDClient
|
||||||
|
client = AcoustIDClient()
|
||||||
|
fp_result = client.fingerprint_and_lookup(file_path)
|
||||||
|
if fp_result and fp_result.get('recordings'):
|
||||||
|
best = fp_result['recordings'][0]
|
||||||
|
fp_artist = best.get('artist', artist)
|
||||||
|
fp_title = best.get('title', title)
|
||||||
|
if fp_artist and fp_title:
|
||||||
|
result = self._search_single_track(fp_artist, fp_title, '')
|
||||||
|
if result:
|
||||||
|
result['method'] = 'acoustid'
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# If we have artist and title but no metadata match, still return basic identification
|
||||||
|
if artist and title:
|
||||||
|
return {
|
||||||
|
'album_id': None,
|
||||||
|
'album_name': album or title, # Use title as album name for singles
|
||||||
|
'artist_name': artist,
|
||||||
|
'track_name': title,
|
||||||
|
'image_url': '',
|
||||||
|
'total_tracks': 1,
|
||||||
|
'source': 'tags',
|
||||||
|
'method': 'tags' if tags.get('artist') else 'filename',
|
||||||
|
'identification_confidence': 0.7 if tags.get('artist') else 0.5,
|
||||||
|
'is_single': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _search_single_track(self, artist: str, title: str, album: str) -> Optional[Dict]:
|
||||||
|
"""Search metadata source for a single track match."""
|
||||||
|
try:
|
||||||
|
from core.metadata_service import get_primary_source, get_client_for_source
|
||||||
|
|
||||||
|
source = get_primary_source()
|
||||||
|
client = get_client_for_source(source)
|
||||||
|
if not client or not hasattr(client, 'search_tracks'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
query = f"{artist} {title}" if artist else title
|
||||||
|
results = client.search_tracks(query, limit=5)
|
||||||
|
if not results:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Score results
|
||||||
|
best_result = None
|
||||||
|
best_score = 0
|
||||||
|
|
||||||
|
for r in results:
|
||||||
|
r_title = getattr(r, 'name', '') or getattr(r, 'title', '') or ''
|
||||||
|
r_artists = getattr(r, 'artists', [])
|
||||||
|
r_artist = ''
|
||||||
|
if r_artists:
|
||||||
|
a = r_artists[0]
|
||||||
|
r_artist = a.get('name', str(a)) if isinstance(a, dict) else str(a)
|
||||||
|
|
||||||
|
score = _similarity(title, r_title) * 0.6
|
||||||
|
if artist:
|
||||||
|
score += _similarity(artist, r_artist) * 0.4
|
||||||
|
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_result = r
|
||||||
|
|
||||||
|
if not best_result or best_score < 0.5:
|
||||||
|
return None
|
||||||
|
|
||||||
|
r_artist = ''
|
||||||
|
r_album = ''
|
||||||
|
r_album_id = ''
|
||||||
|
r_image = ''
|
||||||
|
if hasattr(best_result, 'artists') and best_result.artists:
|
||||||
|
a = best_result.artists[0]
|
||||||
|
r_artist = a.get('name', str(a)) if isinstance(a, dict) else str(a)
|
||||||
|
if hasattr(best_result, 'album'):
|
||||||
|
alb = best_result.album
|
||||||
|
if isinstance(alb, dict):
|
||||||
|
r_album = alb.get('name', '')
|
||||||
|
r_album_id = alb.get('id', '')
|
||||||
|
images = alb.get('images', [])
|
||||||
|
if images:
|
||||||
|
r_image = images[0].get('url', '') if isinstance(images[0], dict) else str(images[0])
|
||||||
|
elif isinstance(alb, str):
|
||||||
|
r_album = alb
|
||||||
|
|
||||||
|
return {
|
||||||
|
'album_id': r_album_id or None,
|
||||||
|
'album_name': r_album or title,
|
||||||
|
'artist_name': r_artist or artist or '',
|
||||||
|
'track_name': getattr(best_result, 'name', '') or title,
|
||||||
|
'track_id': getattr(best_result, 'id', ''),
|
||||||
|
'image_url': r_image,
|
||||||
|
'total_tracks': 1,
|
||||||
|
'source': source,
|
||||||
|
'method': 'tags',
|
||||||
|
'identification_confidence': best_score,
|
||||||
|
'is_single': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Single track search failed for '{artist} - {title}': {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def _identify_from_tags(self, candidate: FolderCandidate) -> Optional[Dict]:
|
def _identify_from_tags(self, candidate: FolderCandidate) -> Optional[Dict]:
|
||||||
"""Try to identify album from embedded file tags."""
|
"""Try to identify album from embedded file tags."""
|
||||||
tags_list = []
|
tags_list = []
|
||||||
|
|
@ -556,6 +708,27 @@ class AutoImportWorker:
|
||||||
|
|
||||||
def _match_tracks(self, candidate: FolderCandidate, identification: Dict) -> Optional[Dict]:
|
def _match_tracks(self, candidate: FolderCandidate, identification: Dict) -> Optional[Dict]:
|
||||||
"""Match staging files to the identified album's tracklist."""
|
"""Match staging files to the identified album's tracklist."""
|
||||||
|
# Singles: no album tracklist to match against — the file IS the match
|
||||||
|
if candidate.is_single or identification.get('is_single'):
|
||||||
|
conf = identification.get('identification_confidence', 0.7)
|
||||||
|
track_data = {
|
||||||
|
'name': identification.get('track_name', identification.get('album_name', '')),
|
||||||
|
'artists': [{'name': identification.get('artist_name', '')}],
|
||||||
|
'id': identification.get('track_id', ''),
|
||||||
|
'track_number': 1,
|
||||||
|
'disc_number': 1,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'matches': [{'track': track_data, 'file': candidate.audio_files[0], 'confidence': conf}],
|
||||||
|
'unmatched_files': [],
|
||||||
|
'total_tracks': 1,
|
||||||
|
'matched_count': 1,
|
||||||
|
'coverage': 1.0,
|
||||||
|
'confidence': conf,
|
||||||
|
'album_data': {'id': identification.get('album_id') or '', 'name': identification.get('album_name', ''),
|
||||||
|
'tracks': {'items': [track_data]}},
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from core.metadata_service import get_client_for_source, get_album_tracks_for_source
|
from core.metadata_service import get_client_for_source, get_album_tracks_for_source
|
||||||
|
|
||||||
|
|
@ -727,7 +900,7 @@ class AutoImportWorker:
|
||||||
context_key = f"auto_import_{candidate.folder_hash}_{track_number}"
|
context_key = f"auto_import_{candidate.folder_hash}_{track_number}"
|
||||||
context = {
|
context = {
|
||||||
'spotify_artist': {
|
'spotify_artist': {
|
||||||
'id': identification.get('album_id', 'auto_import'),
|
'id': identification.get('album_id') or 'auto_import',
|
||||||
'name': artist_name,
|
'name': artist_name,
|
||||||
'genres': [],
|
'genres': [],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue