mirror of
https://github.com/wahyd4/you-music.git
synced 2026-08-08 20:59:47 +10:00
371 lines
12 KiB
Python
371 lines
12 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
|
from fastapi.responses import FileResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, or_
|
|
from typing import List, Optional
|
|
import os
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
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()
|
|
|
|
|
|
@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)
|
|
|
|
# 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=metadata.get("artist", "Unknown"),
|
|
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
|
|
|
|
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()
|
|
|
|
if not existing:
|
|
# Add to database
|
|
metadata = await music_downloader.get_music_metadata(str(file_path))
|
|
|
|
db_music = Music(
|
|
title=metadata.get("title", file_path.name),
|
|
artist=metadata.get("artist", "Unknown"),
|
|
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
|
|
|
|
await db.commit()
|
|
|
|
return {"message": f"Scan complete. Added {added_count} new files."}
|
|
|
|
|
|
@router.get("/file/{music_id}")
|
|
async def serve_music_file(
|
|
music_id: int,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Serve music file - handles both regular and local music directory files"""
|
|
# 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")
|
|
|
|
# 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):
|
|
raise HTTPException(status_code=404, detail="File not found on disk")
|
|
|
|
# Return file
|
|
return FileResponse(
|
|
path=file_path,
|
|
media_type=f"audio/{music.file_format or 'mpeg'}",
|
|
filename=os.path.basename(file_path)
|
|
)
|
|
|
|
|
|
@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
|