mirror of
https://github.com/wahyd4/you-music.git
synced 2026-08-08 20:59:47 +10:00
395 lines
15 KiB
Python
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
|