Files
you-music/backend/app/api/music.py
T
2025-11-07 14:58:43 +11:00

536 lines
17 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Request
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_
from typing import List, Optional
import os
import shutil
from pathlib import Path
import re
import logging
from app.db.session import get_db
from app.models.models import Music
from app.schemas.schemas import Music as MusicSchema, MusicCreate, MusicUpdate, MusicDetailInfo
from app.services.downloader import music_downloader
from app.core.config import settings
router = APIRouter()
logger = logging.getLogger(__name__)
def normalize_artist_name(artist: str) -> str:
"""
Normalize artist name by standardizing the separator to comma.
Keeps all artists but uses consistent formatting.
Examples:
"蒋明/冬子/刘东明/好妹妹乐队/钟立风/小河" -> "蒋明, 冬子, 刘东明, 好妹妹乐队, 钟立风, 小河"
"Taylor Swift,Ed Sheeran" -> "Taylor Swift, Ed Sheeran"
"Jay Chou" -> "Jay Chou"
"""
if not artist or artist == "Unknown":
return "Unknown"
import re
# Split by / or , and clean up
artists = re.split(r'[/,]', artist)
# Remove empty strings and strip whitespace
artists = [a.strip() for a in artists if a.strip()]
if not artists:
return "Unknown"
# Join with comma-space for consistency
return ", ".join(artists)
@router.post("/{music_id}/rescan")
async def rescan_music_metadata(
music_id: int,
db: AsyncSession = Depends(get_db)
):
"""Rescan metadata for a single music file"""
# Get music record
result = await db.execute(select(Music).where(Music.id == music_id))
music = result.scalar_one_or_none()
if not music:
raise HTTPException(status_code=404, detail="Music not found")
# Get file path
if music.file_location and os.path.isabs(music.file_location):
file_path = music.file_location
else:
file_path = os.path.join(settings.MUSIC_DIR, music.file_path)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="File not found on disk")
# Re-read metadata
metadata = await music_downloader.get_music_metadata(file_path)
# Normalize artist name
raw_artist = metadata.get("artist", "Unknown")
normalized_artist = normalize_artist_name(raw_artist)
# Update record
old_artist = music.artist
music.artist = normalized_artist
music.title = metadata.get("title", music.title)
music.album = metadata.get("album", music.album or "")
music.duration = metadata.get("duration", music.duration)
await db.commit()
await db.refresh(music)
return {
"message": "Metadata rescanned successfully",
"old_artist": old_artist,
"new_artist": normalized_artist,
"music": music
}
@router.get("/stats")
async def get_music_stats(db: AsyncSession = Depends(get_db)):
"""Get music library statistics"""
from sqlalchemy import func
# Total count
total_result = await db.execute(select(func.count(Music.id)))
total_count = total_result.scalar()
# Count by file_exists
existing_result = await db.execute(
select(func.count(Music.id)).where(Music.file_exists == True)
)
existing_count = existing_result.scalar()
missing_count = total_count - existing_count
return {
"total_songs": total_count,
"existing_songs": existing_count,
"missing_songs": missing_count
}
@router.get("/", response_model=List[MusicSchema])
async def get_all_music(
skip: int = 0,
limit: int = 100,
include_missing: bool = True,
sort_by: str = Query("created_at", regex="^(title|artist|created_at|duration|album)$"),
sort_order: str = Query("desc", regex="^(asc|desc)$"),
db: AsyncSession = Depends(get_db)
):
"""Get all music files with sorting - optimized for performance"""
from sqlalchemy import asc, desc
from sqlalchemy.orm import noload
# Don't load relationships for this endpoint (causes N+1 queries)
query = select(Music).options(noload(Music.playlists))
# Filter out missing files if requested
if not include_missing:
query = query.where(Music.file_exists == True)
# Apply sorting
sort_column = getattr(Music, sort_by)
if sort_order == "asc":
query = query.order_by(asc(sort_column))
else:
query = query.order_by(desc(sort_column))
# Execute with limit and offset
result = await db.execute(
query.offset(skip).limit(limit)
)
music_list = result.scalars().all()
return music_list
@router.get("/search", response_model=List[MusicSchema])
async def search_music(
q: str = Query(..., min_length=1),
db: AsyncSession = Depends(get_db)
):
"""Search music by name or artist"""
result = await db.execute(
select(Music).where(
or_(
Music.title.contains(q),
Music.artist.contains(q)
)
)
)
music_list = result.scalars().all()
return music_list
@router.get("/{music_id}", response_model=MusicSchema)
async def get_music(music_id: int, db: AsyncSession = Depends(get_db)):
"""Get specific music by ID"""
result = await db.execute(
select(Music).where(Music.id == music_id)
)
music = result.scalar_one_or_none()
if not music:
raise HTTPException(status_code=404, detail="Music not found")
return music
@router.get("/{music_id}/lyrics")
async def get_music_lyrics(music_id: int, db: AsyncSession = Depends(get_db)):
"""Get lyrics for a music track"""
import httpx
result = await db.execute(select(Music).where(Music.id == music_id))
music = result.scalar_one_or_none()
if not music:
raise HTTPException(status_code=404, detail="Music not found")
# Try to fetch from lrclib.net
try:
async with httpx.AsyncClient() as client:
params = {
"track_name": music.title,
"artist_name": music.artist or "",
"album_name": music.album or "",
"duration": int(music.duration) if music.duration else 0
}
response = await client.get("https://lrclib.net/api/get", params=params, timeout=10.0)
if response.status_code == 200:
data = response.json()
lyrics = data.get("plainLyrics") or data.get("syncedLyrics", "")
# Save lyrics to database
if lyrics:
music.lyrics = lyrics
await db.commit()
return {"lyrics": lyrics, "synced": bool(data.get("syncedLyrics"))}
except Exception as e:
logger.error(f"Error fetching lyrics: {e}")
# Return stored lyrics if available
if music.lyrics:
return {"lyrics": music.lyrics, "synced": False}
return {"lyrics": "", "synced": False}
@router.get("/{music_id}/info", response_model=MusicDetailInfo)
async def get_music_detail_info(music_id: int, db: AsyncSession = Depends(get_db)):
"""Get detailed information about a music file"""
result = await db.execute(
select(Music).where(Music.id == music_id)
)
music = result.scalar_one_or_none()
if not music:
raise HTTPException(status_code=404, detail="Music not found")
return music
@router.put("/{music_id}", response_model=MusicSchema)
async def update_music(
music_id: int,
music_update: MusicUpdate,
db: AsyncSession = Depends(get_db)
):
"""Update music metadata"""
result = await db.execute(
select(Music).where(Music.id == music_id)
)
music = result.scalar_one_or_none()
if not music:
raise HTTPException(status_code=404, detail="Music not found")
for field, value in music_update.model_dump(exclude_unset=True).items():
setattr(music, field, value)
await db.commit()
await db.refresh(music)
return music
@router.delete("/{music_id}")
async def delete_music(music_id: int, db: AsyncSession = Depends(get_db)):
"""Delete music file and database record"""
result = await db.execute(
select(Music).where(Music.id == music_id)
)
music = result.scalar_one_or_none()
if not music:
raise HTTPException(status_code=404, detail="Music not found")
# Delete physical file
file_path = os.path.join(settings.MUSIC_DIR, music.file_path)
if os.path.exists(file_path):
os.remove(file_path)
await db.delete(music)
await db.commit()
return {"message": "Music deleted successfully"}
@router.post("/upload", response_model=MusicSchema)
async def upload_music(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db)
):
"""Upload a music file"""
# Validate file extension
allowed_extensions = ['.mp3', '.m4a', '.flac', '.wav', '.ogg']
file_ext = os.path.splitext(file.filename)[1].lower()
if file_ext not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"File type not supported. Allowed: {allowed_extensions}"
)
# Save file
file_path = os.path.join(settings.UPLOAD_DIR, file.filename)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# Extract metadata
metadata = await music_downloader.get_music_metadata(file_path)
# Normalize artist name (take first artist if multiple)
raw_artist = metadata.get("artist", "Unknown")
normalized_artist = normalize_artist_name(raw_artist)
# Get file format
file_extension = os.path.splitext(file.filename)[1][1:]
# Create database record
db_music = Music(
title=metadata.get("title", file.filename),
artist=normalized_artist,
album=metadata.get("album", ""),
duration=metadata.get("duration", 0),
file_path=os.path.join("uploads", file.filename),
file_size=os.path.getsize(file_path),
file_format=file_extension,
file_location=file_path,
file_exists=True,
source_type="upload"
)
db.add(db_music)
await db.commit()
await db.refresh(db_music)
return db_music
@router.get("/artist/{artist_name}", response_model=List[MusicSchema])
async def get_music_by_artist(
artist_name: str,
db: AsyncSession = Depends(get_db)
):
"""Get all music by a specific artist"""
result = await db.execute(
select(Music).where(Music.artist == artist_name)
)
music_list = result.scalars().all()
return music_list
@router.post("/scan")
async def scan_music_directory(db: AsyncSession = Depends(get_db)):
"""Scan music directory and add new files to database"""
music_dir = Path(settings.MUSIC_DIR)
added_count = 0
updated_count = 0
for file_path in music_dir.rglob("*"):
if file_path.is_file() and file_path.suffix.lower() in ['.mp3', '.m4a', '.flac', '.wav']:
relative_path = str(file_path.relative_to(music_dir))
# Check if already in database
result = await db.execute(
select(Music).where(Music.file_path == relative_path)
)
existing = result.scalar_one_or_none()
# Get metadata
metadata = await music_downloader.get_music_metadata(str(file_path))
# Normalize artist name (take first artist if multiple)
raw_artist = metadata.get("artist", "Unknown")
normalized_artist = normalize_artist_name(raw_artist)
if not existing:
# Add to database
db_music = Music(
title=metadata.get("title", file_path.name),
artist=normalized_artist,
album=metadata.get("album", ""),
duration=metadata.get("duration", 0),
file_path=relative_path,
file_size=file_path.stat().st_size,
file_format=file_path.suffix[1:], # Extension without dot
file_location=str(file_path),
file_exists=True,
source_type="local"
)
db.add(db_music)
added_count += 1
else:
# Update existing record if artist needs normalization
# Check if the normalized version is different from current
if existing.artist != normalized_artist:
old_artist = existing.artist
existing.artist = normalized_artist
updated_count += 1
await db.commit()
return {
"message": f"Scan complete. Added {added_count} new files. Updated {updated_count} artists.",
"added": added_count,
"updated": updated_count
}
@router.get("/file/{music_id}")
async def serve_music_file(
music_id: int,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""Serve music file with Range request support for audio streaming"""
# Get music record
result = await db.execute(select(Music).where(Music.id == music_id))
music = result.scalar_one_or_none()
if not music:
logger.error(f"Music ID {music_id} not found in database")
raise HTTPException(status_code=404, detail="Music not found")
# Determine file path
if music.file_location and os.path.isabs(music.file_location):
# Use absolute path from file_location (local music dir)
file_path = music.file_location
else:
# Use relative path from MUSIC_DIR (downloaded music)
file_path = os.path.join(settings.MUSIC_DIR, music.file_path)
# Check if file exists
if not os.path.exists(file_path):
logger.error(f"File not found on disk: {file_path} (Music ID: {music_id})")
raise HTTPException(status_code=404, detail=f"File not found: {music.file_path}")
# Map file extensions to proper MIME types
mime_type_map = {
'mp3': 'audio/mpeg',
'mp4': 'audio/mp4',
'm4a': 'audio/mp4',
'wav': 'audio/wav',
'flac': 'audio/flac',
'ogg': 'audio/ogg',
'opus': 'audio/opus',
'webm': 'audio/webm',
'aac': 'audio/aac',
}
# Get MIME type based on file extension
file_ext = music.file_format or os.path.splitext(file_path)[1].lstrip('.')
media_type = mime_type_map.get(file_ext.lower(), 'audio/mpeg')
logger.info(f"Serving music file: {file_path} (ID: {music_id}, MIME: {media_type})")
# Return file with proper headers for audio streaming
return FileResponse(
path=file_path,
media_type=media_type,
filename=os.path.basename(file_path),
headers={
'Accept-Ranges': 'bytes',
'Cache-Control': 'public, max-age=3600',
}
)
@router.get("/file/{music_id}/check")
async def check_music_file(
music_id: int,
db: AsyncSession = Depends(get_db)
):
"""Check if music file exists and is accessible (diagnostic endpoint)"""
# Get music record
result = await db.execute(select(Music).where(Music.id == music_id))
music = result.scalar_one_or_none()
if not music:
return {
"exists": False,
"error": "Music record not found in database",
"music_id": music_id
}
# Determine file path
if music.file_location and os.path.isabs(music.file_location):
file_path = music.file_location
else:
file_path = os.path.join(settings.MUSIC_DIR, music.file_path)
file_exists = os.path.exists(file_path)
return {
"exists": file_exists,
"music_id": music_id,
"title": music.title,
"file_path": music.file_path,
"full_path": file_path,
"file_location": music.file_location,
"file_format": music.file_format,
"music_dir": settings.MUSIC_DIR,
"file_size": os.path.getsize(file_path) if file_exists else None,
"is_readable": os.access(file_path, os.R_OK) if file_exists else False,
}
@router.get("/share/{share_token}", response_model=MusicSchema)
async def get_music_by_share_token(share_token: str, db: AsyncSession = Depends(get_db)):
"""Get music by share token (public endpoint) - checks expiration"""
result = await db.execute(
select(Music).where(Music.share_token == share_token)
)
music = result.scalar_one_or_none()
if not music:
raise HTTPException(status_code=404, detail="Music not found")
# Check if share token is expired
if not music.is_share_token_valid():
raise HTTPException(status_code=410, detail="Share link has expired")
return music
@router.post("/{music_id}/generate-share-token", response_model=MusicSchema)
async def generate_share_token(music_id: int, db: AsyncSession = Depends(get_db)):
"""Generate or refresh share token for a music item"""
from app.core.config import settings
result = await db.execute(
select(Music).where(Music.id == music_id)
)
music = result.scalar_one_or_none()
if not music:
raise HTTPException(status_code=404, detail="Music not found")
# Generate token with configured expiration
music.generate_share_token(expiration_days=settings.SHARE_LINK_EXPIRATION_DAYS)
await db.commit()
await db.refresh(music)
return music