Files
you-music/backend/app/services/scanner.py
T
2025-10-30 22:35:02 +11:00

395 lines
15 KiB
Python

import os
import logging
from pathlib import Path
from typing import Optional, Dict, Any
from datetime import datetime
from mutagen import File as MutagenFile
from mutagen.easyid3 import EasyID3
from mutagen.mp3 import MP3
from mutagen.flac import FLAC
from mutagen.mp4 import MP4
from mutagen.oggvorbis import OggVorbis
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from app.models.models import Music, AppSettings
from app.core.config import settings
logger = logging.getLogger(__name__)
# Global scan status
scan_status = {
"is_scanning": False,
"progress": 0,
"total": 0,
"current_file": None,
"started_at": None,
"completed_at": None,
"files_added": 0,
"files_updated": 0,
"files_missing": 0,
"errors": []
}
def get_scan_status() -> Dict[str, Any]:
"""Get current scan status"""
return scan_status.copy()
def reset_scan_status():
"""Reset scan status"""
global scan_status
scan_status = {
"is_scanning": False,
"progress": 0,
"total": 0,
"current_file": None,
"started_at": None,
"completed_at": None,
"files_added": 0,
"files_updated": 0,
"files_missing": 0,
"errors": []
}
def extract_metadata(file_path: str) -> Optional[Dict[str, Any]]:
"""Extract metadata from audio file using mutagen"""
try:
audio = MutagenFile(file_path, easy=True)
if audio is None:
return None
metadata = {
"title": None,
"artist": None,
"album": None,
"duration": None,
"file_format": None,
"file_size": os.path.getsize(file_path),
"thumbnail": None
}
# Get file format
file_ext = Path(file_path).suffix.lower().lstrip('.')
metadata["file_format"] = file_ext
# Extract duration
if hasattr(audio, 'info') and hasattr(audio.info, 'length'):
metadata["duration"] = audio.info.length
# Extract tags - try different tag formats
if isinstance(audio, MP3):
try:
tags = EasyID3(file_path)
metadata["title"] = tags.get("title", [None])[0]
metadata["artist"] = tags.get("artist", [None])[0]
metadata["album"] = tags.get("album", [None])[0]
except:
pass
# Extract embedded thumbnail from MP3
try:
from mutagen.id3 import ID3, APIC
audio_id3 = MP3(file_path, ID3=ID3)
if audio_id3.tags:
for tag in audio_id3.tags.values():
if isinstance(tag, APIC):
# Save thumbnail to thumbnails directory
thumbnails_dir = os.path.join(settings.MUSIC_DIR, "thumbnails")
os.makedirs(thumbnails_dir, exist_ok=True)
audio_basename = Path(file_path).stem
thumbnail_filename = f"{audio_basename}.jpg"
thumbnail_path = os.path.join(thumbnails_dir, thumbnail_filename)
# Save thumbnail
with open(thumbnail_path, 'wb') as img_file:
img_file.write(tag.data)
# Store relative path
metadata["thumbnail"] = f"thumbnails/{thumbnail_filename}"
break
except Exception as e:
logger.debug(f"No thumbnail in MP3 file: {e}")
elif audio.tags:
# Try common tag keys
title_keys = ['title', 'TITLE', 'Title', '\xa9nam']
artist_keys = ['artist', 'ARTIST', 'Artist', '\xa9ART']
album_keys = ['album', 'ALBUM', 'Album', '\xa9alb']
for key in title_keys:
if key in audio.tags:
value = audio.tags[key]
metadata["title"] = str(value[0]) if isinstance(value, list) else str(value)
break
for key in artist_keys:
if key in audio.tags:
value = audio.tags[key]
metadata["artist"] = str(value[0]) if isinstance(value, list) else str(value)
break
for key in album_keys:
if key in audio.tags:
value = audio.tags[key]
metadata["album"] = str(value[0]) if isinstance(value, list) else str(value)
break
# Extract thumbnail from other formats (FLAC, MP4, etc.)
try:
if isinstance(audio, FLAC) and audio.pictures:
picture = audio.pictures[0]
thumbnails_dir = os.path.join(settings.MUSIC_DIR, "thumbnails")
os.makedirs(thumbnails_dir, exist_ok=True)
audio_basename = Path(file_path).stem
thumbnail_filename = f"{audio_basename}.jpg"
thumbnail_path = os.path.join(thumbnails_dir, thumbnail_filename)
with open(thumbnail_path, 'wb') as img_file:
img_file.write(picture.data)
metadata["thumbnail"] = f"thumbnails/{thumbnail_filename}"
elif isinstance(audio, MP4) and 'covr' in audio.tags:
cover = audio.tags['covr'][0]
thumbnails_dir = os.path.join(settings.MUSIC_DIR, "thumbnails")
os.makedirs(thumbnails_dir, exist_ok=True)
audio_basename = Path(file_path).stem
thumbnail_filename = f"{audio_basename}.jpg"
thumbnail_path = os.path.join(thumbnails_dir, thumbnail_filename)
with open(thumbnail_path, 'wb') as img_file:
img_file.write(bytes(cover))
metadata["thumbnail"] = f"thumbnails/{thumbnail_filename}"
except Exception as e:
logger.debug(f"No thumbnail in audio file: {e}")
# Fallback to filename if no title
if not metadata["title"]:
metadata["title"] = Path(file_path).stem
return metadata
except Exception as e:
logger.warning(f"Failed to extract metadata from {file_path}: {e}")
# Return basic info even if metadata extraction fails
return {
"title": Path(file_path).stem,
"artist": None,
"album": None,
"duration": None,
"file_format": Path(file_path).suffix.lower().lstrip('.'),
"file_size": os.path.getsize(file_path) if os.path.exists(file_path) else None,
"thumbnail": None
}
async def create_or_update_music(db: AsyncSession, file_path: str, metadata: Dict[str, Any], source_dir: str) -> str:
"""Create or update music entry in database"""
try:
# Calculate relative path from MUSIC_DIR or use absolute path
try:
relative_path = str(Path(file_path).relative_to(settings.MUSIC_DIR))
except ValueError:
# File is not in MUSIC_DIR, use relative to LOCAL_MUSIC_DIR or absolute
relative_path = file_path
# Check if music already exists
result = await db.execute(
select(Music).where(Music.file_path == relative_path)
)
existing = result.scalar_one_or_none()
if existing:
# Update existing entry
existing.title = metadata.get("title") or existing.title
existing.artist = metadata.get("artist") or existing.artist
existing.album = metadata.get("album") or existing.album
existing.duration = metadata.get("duration") or existing.duration
existing.file_size = metadata.get("file_size") or existing.file_size
existing.file_format = metadata.get("file_format") or existing.file_format
existing.file_location = file_path
existing.file_exists = True
existing.last_scanned_at = datetime.utcnow()
existing.updated_at = datetime.utcnow()
# Update thumbnail if found in metadata
if metadata.get("thumbnail"):
existing.thumbnail = metadata.get("thumbnail")
await db.commit()
logger.info(f"Updated music: {existing.title}")
return "updated"
else:
# Create new entry
new_music = Music(
title=metadata.get("title", Path(file_path).stem),
artist=metadata.get("artist"),
album=metadata.get("album"),
duration=metadata.get("duration"),
file_path=relative_path,
file_location=file_path,
file_size=metadata.get("file_size"),
file_format=metadata.get("file_format"),
file_exists=True,
source_type="local",
thumbnail=metadata.get("thumbnail"),
last_scanned_at=datetime.utcnow()
)
db.add(new_music)
await db.commit()
logger.info(f"Added new music: {new_music.title}")
return "added"
except Exception as e:
logger.error(f"Error creating/updating music {file_path}: {e}")
await db.rollback()
raise
async def scan_directory(db: AsyncSession, directory: str) -> Dict[str, int]:
"""Scan a directory for music files"""
if not directory or not os.path.exists(directory):
logger.warning(f"Directory does not exist: {directory}")
return {"added": 0, "updated": 0, "errors": 0}
supported_formats = {'.mp3', '.flac', '.m4a', '.mp4', '.wav', '.ogg', '.wma', '.aac'}
stats = {"added": 0, "updated": 0, "errors": 0}
logger.info(f"Scanning directory: {directory}")
# Walk through directory
for root, _, files in os.walk(directory):
for file in files:
file_ext = Path(file).suffix.lower()
if file_ext in supported_formats:
file_path = os.path.join(root, file)
scan_status["current_file"] = file
scan_status["progress"] += 1
try:
metadata = extract_metadata(file_path)
if metadata:
result = await create_or_update_music(db, file_path, metadata, directory)
if result == "added":
stats["added"] += 1
scan_status["files_added"] += 1
elif result == "updated":
stats["updated"] += 1
scan_status["files_updated"] += 1
except Exception as e:
logger.error(f"Error processing {file_path}: {e}")
stats["errors"] += 1
scan_status["errors"].append(f"{file}: {str(e)}")
return stats
async def check_existing_files(db: AsyncSession, delete_missing: bool = False) -> int:
"""Check if existing database entries still exist on disk"""
result = await db.execute(select(Music))
all_music = result.scalars().all()
missing_count = 0
for music in all_music:
# Construct full path
if music.file_location:
file_path = music.file_location
else:
file_path = os.path.join(settings.MUSIC_DIR, music.file_path)
exists = os.path.exists(file_path)
if not exists and music.file_exists:
missing_count += 1
scan_status["files_missing"] += 1
logger.warning(f"File not found: {file_path}")
if delete_missing:
await db.delete(music)
logger.info(f"Deleted missing music: {music.title}")
else:
music.file_exists = False
music.last_scanned_at = datetime.utcnow()
elif exists and not music.file_exists:
# File came back
music.file_exists = True
music.last_scanned_at = datetime.utcnow()
await db.commit()
return missing_count
async def full_scan(db: AsyncSession, delete_missing: bool = False):
"""Perform a full scan of all music directories"""
global scan_status
if scan_status["is_scanning"]:
logger.warning("Scan already in progress")
return
logger.info("🔍 Starting full music library scan...")
reset_scan_status()
scan_status["is_scanning"] = True
scan_status["started_at"] = datetime.utcnow()
try:
# Count total files first
total_files = 0
supported_formats = {'.mp3', '.flac', '.m4a', '.mp4', '.wav', '.ogg', '.wma', '.aac'}
for directory in [settings.MUSIC_DIR, settings.LOCAL_MUSIC_DIR]:
if directory and os.path.exists(directory):
for root, _, files in os.walk(directory):
total_files += sum(1 for f in files if Path(f).suffix.lower() in supported_formats)
scan_status["total"] = total_files
logger.info(f"Found {total_files} music files to scan")
# Scan MUSIC_DIR
if os.path.exists(settings.MUSIC_DIR):
logger.info(f"Scanning MUSIC_DIR: {settings.MUSIC_DIR}")
await scan_directory(db, settings.MUSIC_DIR)
# Scan LOCAL_MUSIC_DIR if configured
if settings.LOCAL_MUSIC_DIR and os.path.exists(settings.LOCAL_MUSIC_DIR):
logger.info(f"Scanning LOCAL_MUSIC_DIR: {settings.LOCAL_MUSIC_DIR}")
await scan_directory(db, settings.LOCAL_MUSIC_DIR)
# Check for missing files
logger.info("Checking for missing files...")
await check_existing_files(db, delete_missing)
# Update last scan time in settings
result = await db.execute(
select(AppSettings).where(AppSettings.key == "last_scan_at")
)
setting = result.scalar_one_or_none()
if setting:
setting.value = datetime.utcnow().isoformat()
setting.updated_at = datetime.utcnow()
else:
setting = AppSettings(key="last_scan_at", value=datetime.utcnow().isoformat())
db.add(setting)
await db.commit()
scan_status["completed_at"] = datetime.utcnow()
logger.info(
f"✅ Scan completed! Added: {scan_status['files_added']}, "
f"Updated: {scan_status['files_updated']}, "
f"Missing: {scan_status['files_missing']}, "
f"Errors: {len(scan_status['errors'])}"
)
except Exception as e:
logger.error(f"Error during full scan: {e}")
scan_status["errors"].append(f"Scan error: {str(e)}")
finally:
scan_status["is_scanning"] = False