mirror of
https://github.com/wahyd4/you-music.git
synced 2026-08-08 20:59:47 +10:00
533 lines
17 KiB
Python
533 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"""
|
|
from sqlalchemy import asc, desc
|
|
|
|
query = select(Music)
|
|
|
|
# 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))
|
|
|
|
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
|