Compare commits
6 Commits
ae238d3d18
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1f9667edd | ||
|
|
4cfc2ec0a2 | ||
|
|
39b7e14ae9 | ||
|
|
d0bda6c930 | ||
|
|
010f18bff4 | ||
|
|
e874d0665f |
@@ -100,6 +100,7 @@ def create_app():
|
|||||||
auth,
|
auth,
|
||||||
main,
|
main,
|
||||||
player,
|
player,
|
||||||
|
referential,
|
||||||
soundboard,
|
soundboard,
|
||||||
sounds,
|
sounds,
|
||||||
stream,
|
stream,
|
||||||
@@ -109,6 +110,7 @@ def create_app():
|
|||||||
app.register_blueprint(auth.bp, url_prefix="/api/auth")
|
app.register_blueprint(auth.bp, url_prefix="/api/auth")
|
||||||
app.register_blueprint(admin.bp, url_prefix="/api/admin")
|
app.register_blueprint(admin.bp, url_prefix="/api/admin")
|
||||||
app.register_blueprint(admin_sounds.bp, url_prefix="/api/admin/sounds")
|
app.register_blueprint(admin_sounds.bp, url_prefix="/api/admin/sounds")
|
||||||
|
app.register_blueprint(referential.bp, url_prefix="/api/referential")
|
||||||
app.register_blueprint(soundboard.bp, url_prefix="/api/soundboard")
|
app.register_blueprint(soundboard.bp, url_prefix="/api/soundboard")
|
||||||
app.register_blueprint(sounds.bp, url_prefix="/api/sounds")
|
app.register_blueprint(sounds.bp, url_prefix="/api/sounds")
|
||||||
app.register_blueprint(stream.bp, url_prefix="/api/stream")
|
app.register_blueprint(stream.bp, url_prefix="/api/stream")
|
||||||
|
|||||||
@@ -37,3 +37,139 @@ def manual_credit_refill() -> dict:
|
|||||||
return scheduler_service.trigger_credit_refill_now()
|
return scheduler_service.trigger_credit_refill_now()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/users")
|
||||||
|
@require_auth
|
||||||
|
@require_role("admin")
|
||||||
|
def list_users() -> dict:
|
||||||
|
"""List all users (admin only)."""
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
users = User.query.order_by(User.created_at.desc()).all()
|
||||||
|
return {
|
||||||
|
"users": [user.to_dict() for user in users],
|
||||||
|
"total": len(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/users/<int:user_id>", methods=["PATCH"])
|
||||||
|
@require_auth
|
||||||
|
@require_role("admin")
|
||||||
|
def update_user(user_id: int) -> dict:
|
||||||
|
"""Update user information (admin only)."""
|
||||||
|
from flask import request
|
||||||
|
from app.database import db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.plan import Plan
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return {"error": "No data provided"}, 400
|
||||||
|
|
||||||
|
user = User.query.get(user_id)
|
||||||
|
if not user:
|
||||||
|
return {"error": "User not found"}, 404
|
||||||
|
|
||||||
|
# Validate and update fields
|
||||||
|
try:
|
||||||
|
if "name" in data:
|
||||||
|
name = data["name"].strip()
|
||||||
|
if not name:
|
||||||
|
return {"error": "Name cannot be empty"}, 400
|
||||||
|
if len(name) > 100:
|
||||||
|
return {"error": "Name too long (max 100 characters)"}, 400
|
||||||
|
user.name = name
|
||||||
|
|
||||||
|
if "credits" in data:
|
||||||
|
credits = data["credits"]
|
||||||
|
if not isinstance(credits, int) or credits < 0:
|
||||||
|
return {"error": "Credits must be a non-negative integer"}, 400
|
||||||
|
user.credits = credits
|
||||||
|
|
||||||
|
if "plan_id" in data:
|
||||||
|
plan_id = data["plan_id"]
|
||||||
|
if not isinstance(plan_id, int):
|
||||||
|
return {"error": "Plan ID must be an integer"}, 400
|
||||||
|
|
||||||
|
plan = Plan.query.get(plan_id)
|
||||||
|
if not plan:
|
||||||
|
return {"error": "Plan not found"}, 404
|
||||||
|
|
||||||
|
user.plan_id = plan_id
|
||||||
|
|
||||||
|
if "is_active" in data:
|
||||||
|
is_active = data["is_active"]
|
||||||
|
if not isinstance(is_active, bool):
|
||||||
|
return {"error": "is_active must be a boolean"}, 400
|
||||||
|
user.is_active = is_active
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "User updated successfully",
|
||||||
|
"user": user.to_dict()
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return {"error": f"Failed to update user: {str(e)}"}, 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/users/<int:user_id>/deactivate", methods=["POST"])
|
||||||
|
@require_auth
|
||||||
|
@require_role("admin")
|
||||||
|
def deactivate_user(user_id: int) -> dict:
|
||||||
|
"""Deactivate a user (admin only)."""
|
||||||
|
from app.database import db
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
user = User.query.get(user_id)
|
||||||
|
if not user:
|
||||||
|
return {"error": "User not found"}, 404
|
||||||
|
|
||||||
|
# Prevent admin from deactivating themselves
|
||||||
|
current_user = get_current_user()
|
||||||
|
if str(user.id) == current_user["id"]:
|
||||||
|
return {"error": "Cannot deactivate your own account"}, 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
user.deactivate()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "User deactivated successfully",
|
||||||
|
"user": user.to_dict()
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return {"error": f"Failed to deactivate user: {str(e)}"}, 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/users/<int:user_id>/activate", methods=["POST"])
|
||||||
|
@require_auth
|
||||||
|
@require_role("admin")
|
||||||
|
def activate_user(user_id: int) -> dict:
|
||||||
|
"""Activate a user (admin only)."""
|
||||||
|
from app.database import db
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
user = User.query.get(user_id)
|
||||||
|
if not user:
|
||||||
|
return {"error": "User not found"}, 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
user.activate()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "User activated successfully",
|
||||||
|
"user": user.to_dict()
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return {"error": f"Failed to activate user: {str(e)}"}, 500
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
"""Main routes for the application."""
|
"""Main routes for the application."""
|
||||||
|
|
||||||
from flask import Blueprint
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from flask import Blueprint, request
|
||||||
|
from sqlalchemy import desc, func
|
||||||
|
|
||||||
|
from app.database import db
|
||||||
|
from app.models.playlist import Playlist
|
||||||
|
from app.models.sound import Sound
|
||||||
|
from app.models.sound_played import SoundPlayed
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.decorators import require_auth
|
||||||
|
|
||||||
bp = Blueprint("main", __name__)
|
bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
@@ -9,3 +20,213 @@ bp = Blueprint("main", __name__)
|
|||||||
def health() -> dict[str, str]:
|
def health() -> dict[str, str]:
|
||||||
"""Health check endpoint."""
|
"""Health check endpoint."""
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_period_filter(period: str) -> datetime | None:
|
||||||
|
"""Get the start date for the specified period."""
|
||||||
|
now = datetime.now(tz=ZoneInfo("UTC"))
|
||||||
|
|
||||||
|
if period == "today":
|
||||||
|
return now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
if period == "week":
|
||||||
|
return now - timedelta(days=7)
|
||||||
|
if period == "month":
|
||||||
|
return now - timedelta(days=30)
|
||||||
|
if period == "year":
|
||||||
|
return now - timedelta(days=365)
|
||||||
|
if period == "all":
|
||||||
|
return None
|
||||||
|
# Default to all time
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/dashboard/stats")
|
||||||
|
@require_auth
|
||||||
|
def dashboard_stats() -> dict:
|
||||||
|
"""Get dashboard statistics."""
|
||||||
|
# Count soundboard sounds (type = SDB)
|
||||||
|
soundboard_count = Sound.query.filter_by(type="SDB").count()
|
||||||
|
|
||||||
|
# Count tracks (type = STR)
|
||||||
|
track_count = Sound.query.filter_by(type="STR").count()
|
||||||
|
|
||||||
|
# Count playlists
|
||||||
|
playlist_count = Playlist.query.count()
|
||||||
|
|
||||||
|
# Calculate total size of all sounds (original + normalized)
|
||||||
|
total_size_result = db.session.query(
|
||||||
|
func.sum(Sound.size).label("original_size"),
|
||||||
|
func.sum(Sound.normalized_size).label("normalized_size"),
|
||||||
|
).first()
|
||||||
|
|
||||||
|
original_size = getattr(total_size_result, "original_size", 0) or 0
|
||||||
|
normalized_size = getattr(total_size_result, "normalized_size", 0) or 0
|
||||||
|
total_size = original_size + normalized_size
|
||||||
|
|
||||||
|
return {
|
||||||
|
"soundboard_sounds": soundboard_count,
|
||||||
|
"tracks": track_count,
|
||||||
|
"playlists": playlist_count,
|
||||||
|
"total_size": total_size,
|
||||||
|
"original_size": original_size,
|
||||||
|
"normalized_size": normalized_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/dashboard/top-sounds")
|
||||||
|
@require_auth
|
||||||
|
def top_sounds() -> dict:
|
||||||
|
"""Get top played sounds for a specific period."""
|
||||||
|
period = request.args.get("period", "all")
|
||||||
|
limit = int(request.args.get("limit", 5))
|
||||||
|
|
||||||
|
period_start = get_period_filter(period)
|
||||||
|
|
||||||
|
# Base query for soundboard sounds with play counts
|
||||||
|
query = (
|
||||||
|
db.session.query(
|
||||||
|
Sound.id,
|
||||||
|
Sound.name,
|
||||||
|
Sound.filename,
|
||||||
|
Sound.thumbnail,
|
||||||
|
Sound.type,
|
||||||
|
func.count(SoundPlayed.id).label("play_count"),
|
||||||
|
)
|
||||||
|
.outerjoin(SoundPlayed, Sound.id == SoundPlayed.sound_id)
|
||||||
|
.filter(Sound.type == "SDB") # Only soundboard sounds
|
||||||
|
.group_by(
|
||||||
|
Sound.id,
|
||||||
|
Sound.name,
|
||||||
|
Sound.filename,
|
||||||
|
Sound.thumbnail,
|
||||||
|
Sound.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply period filter if specified
|
||||||
|
if period_start:
|
||||||
|
query = query.filter(SoundPlayed.played_at >= period_start)
|
||||||
|
|
||||||
|
# Order by play count and limit results
|
||||||
|
results = query.order_by(desc("play_count")).limit(limit).all()
|
||||||
|
|
||||||
|
# Convert to list of dictionaries
|
||||||
|
top_sounds_list = [
|
||||||
|
{
|
||||||
|
"id": result.id,
|
||||||
|
"name": result.name,
|
||||||
|
"filename": result.filename,
|
||||||
|
"thumbnail": result.thumbnail,
|
||||||
|
"type": result.type,
|
||||||
|
"play_count": result.play_count,
|
||||||
|
}
|
||||||
|
for result in results
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"period": period,
|
||||||
|
"sounds": top_sounds_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/dashboard/top-tracks")
|
||||||
|
@require_auth
|
||||||
|
def top_tracks() -> dict:
|
||||||
|
"""Get top played tracks for a specific period."""
|
||||||
|
period = request.args.get("period", "all")
|
||||||
|
limit = int(request.args.get("limit", 10))
|
||||||
|
|
||||||
|
period_start = get_period_filter(period)
|
||||||
|
|
||||||
|
# Base query for tracks with play counts
|
||||||
|
query = (
|
||||||
|
db.session.query(
|
||||||
|
Sound.id,
|
||||||
|
Sound.name,
|
||||||
|
Sound.filename,
|
||||||
|
Sound.thumbnail,
|
||||||
|
Sound.type,
|
||||||
|
func.count(SoundPlayed.id).label("play_count"),
|
||||||
|
)
|
||||||
|
.outerjoin(SoundPlayed, Sound.id == SoundPlayed.sound_id)
|
||||||
|
.filter(Sound.type == "STR") # Only tracks
|
||||||
|
.group_by(
|
||||||
|
Sound.id,
|
||||||
|
Sound.name,
|
||||||
|
Sound.filename,
|
||||||
|
Sound.thumbnail,
|
||||||
|
Sound.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply period filter if specified
|
||||||
|
if period_start:
|
||||||
|
query = query.filter(SoundPlayed.played_at >= period_start)
|
||||||
|
|
||||||
|
# Order by play count and limit results
|
||||||
|
results = query.order_by(desc("play_count")).limit(limit).all()
|
||||||
|
|
||||||
|
# Convert to list of dictionaries
|
||||||
|
top_tracks_list = [
|
||||||
|
{
|
||||||
|
"id": result.id,
|
||||||
|
"name": result.name,
|
||||||
|
"filename": result.filename,
|
||||||
|
"thumbnail": result.thumbnail,
|
||||||
|
"type": result.type,
|
||||||
|
"play_count": result.play_count,
|
||||||
|
}
|
||||||
|
for result in results
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"period": period,
|
||||||
|
"tracks": top_tracks_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/dashboard/top-users")
|
||||||
|
@require_auth
|
||||||
|
def top_users() -> dict:
|
||||||
|
"""Get top users by play count for a specific period."""
|
||||||
|
period = request.args.get("period", "all")
|
||||||
|
limit = int(request.args.get("limit", 10))
|
||||||
|
|
||||||
|
period_start = get_period_filter(period)
|
||||||
|
|
||||||
|
# Base query for users with play counts
|
||||||
|
query = (
|
||||||
|
db.session.query(
|
||||||
|
User.id,
|
||||||
|
User.name,
|
||||||
|
User.email,
|
||||||
|
User.picture,
|
||||||
|
func.count(SoundPlayed.id).label("play_count"),
|
||||||
|
)
|
||||||
|
.outerjoin(SoundPlayed, User.id == SoundPlayed.user_id)
|
||||||
|
.group_by(User.id, User.name, User.email, User.picture)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply period filter if specified
|
||||||
|
if period_start:
|
||||||
|
query = query.filter(SoundPlayed.played_at >= period_start)
|
||||||
|
|
||||||
|
# Order by play count and limit results
|
||||||
|
results = query.order_by(desc("play_count")).limit(limit).all()
|
||||||
|
|
||||||
|
# Convert to list of dictionaries
|
||||||
|
top_users_list = [
|
||||||
|
{
|
||||||
|
"id": result.id,
|
||||||
|
"name": result.name,
|
||||||
|
"email": result.email,
|
||||||
|
"picture": result.picture,
|
||||||
|
"play_count": result.play_count,
|
||||||
|
}
|
||||||
|
for result in results
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"period": period,
|
||||||
|
"users": top_users_list,
|
||||||
|
}
|
||||||
|
|||||||
17
app/routes/referential.py
Normal file
17
app/routes/referential.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""Referential routes for reference data."""
|
||||||
|
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
bp = Blueprint("referential", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/plans")
|
||||||
|
def list_plans() -> dict:
|
||||||
|
"""List all available plans."""
|
||||||
|
from app.models.plan import Plan
|
||||||
|
|
||||||
|
plans = Plan.query.order_by(Plan.id).all()
|
||||||
|
return {
|
||||||
|
"plans": [plan.to_dict() for plan in plans],
|
||||||
|
"total": len(plans)
|
||||||
|
}
|
||||||
@@ -29,6 +29,9 @@ def get_sounds():
|
|||||||
# Get sounds from database
|
# Get sounds from database
|
||||||
sounds = Sound.find_by_type(sound_type)
|
sounds = Sound.find_by_type(sound_type)
|
||||||
|
|
||||||
|
# Order by name
|
||||||
|
sounds = sorted(sounds, key=lambda s: s.name.lower())
|
||||||
|
|
||||||
# Convert to dict format
|
# Convert to dict format
|
||||||
sounds_data = [sound.to_dict() for sound in sounds]
|
sounds_data = [sound.to_dict() for sound in sounds]
|
||||||
|
|
||||||
@@ -65,13 +68,19 @@ def play_sound(sound_id: int):
|
|||||||
# Emit sound_changed event to all connected clients
|
# Emit sound_changed event to all connected clients
|
||||||
try:
|
try:
|
||||||
from app.services.socketio_service import SocketIOService
|
from app.services.socketio_service import SocketIOService
|
||||||
SocketIOService.emit_sound_play_count_changed(sound_id, sound.play_count)
|
|
||||||
|
SocketIOService.emit_sound_play_count_changed(
|
||||||
|
sound_id, sound.play_count
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Don't fail the request if socket emission fails
|
# Don't fail the request if socket emission fails
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.warning(f"Failed to emit sound_play_count_changed event: {e}")
|
logger.warning(
|
||||||
|
f"Failed to emit sound_play_count_changed event: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
return jsonify({"message": "Sound playing", "sound_id": sound_id})
|
return jsonify({"message": "Sound playing", "sound_id": sound_id})
|
||||||
return (
|
return (
|
||||||
jsonify({"error": "Sound not found or cannot be played"}),
|
jsonify({"error": "Sound not found or cannot be played"}),
|
||||||
|
|||||||
@@ -106,34 +106,3 @@ class CreditService:
|
|||||||
"error": str(e),
|
"error": str(e),
|
||||||
"message": "Credit refill failed",
|
"message": "Credit refill failed",
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_user_credit_info(user_id: int) -> dict:
|
|
||||||
"""Get detailed credit information for a specific user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: The user's ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: User's credit information
|
|
||||||
|
|
||||||
"""
|
|
||||||
user = User.query.get(user_id)
|
|
||||||
if not user:
|
|
||||||
return {"error": "User not found"}
|
|
||||||
|
|
||||||
if not user.plan:
|
|
||||||
return {"error": "User has no plan assigned"}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"user_id": user.id,
|
|
||||||
"email": user.email,
|
|
||||||
"current_credits": user.credits,
|
|
||||||
"plan": {
|
|
||||||
"code": user.plan.code,
|
|
||||||
"name": user.plan.name,
|
|
||||||
"daily_credits": user.plan.credits,
|
|
||||||
"max_credits": user.plan.max_credits,
|
|
||||||
},
|
|
||||||
"is_active": user.is_active,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -167,6 +167,20 @@ class MusicPlayerService:
|
|||||||
# Fallback if request context is not available
|
# Fallback if request context is not available
|
||||||
return f"http://localhost:5000/api/sounds/{sound_type.lower()}/thumbnails/{thumbnail_filename}"
|
return f"http://localhost:5000/api/sounds/{sound_type.lower()}/thumbnails/{thumbnail_filename}"
|
||||||
|
|
||||||
|
def _build_stream_url(self, sound_type: str, filename: str) -> str:
|
||||||
|
"""Build absolute stream URL."""
|
||||||
|
try:
|
||||||
|
# Try to get base URL from current request context
|
||||||
|
if request:
|
||||||
|
base_url = request.url_root.rstrip("/")
|
||||||
|
else:
|
||||||
|
# Fallback to localhost if no request context
|
||||||
|
base_url = "http://localhost:5000"
|
||||||
|
return f"{base_url}/api/sounds/{sound_type.lower()}/audio/{filename}"
|
||||||
|
except Exception:
|
||||||
|
# Fallback if request context is not available
|
||||||
|
return f"http://localhost:5000/api/sounds/{sound_type.lower()}/audio/{filename}"
|
||||||
|
|
||||||
def _load_playlist_with_context(
|
def _load_playlist_with_context(
|
||||||
self, playlist, reload: bool = False
|
self, playlist, reload: bool = False
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -499,6 +513,12 @@ class MusicPlayerService:
|
|||||||
sound = current_playlist_sound.sound
|
sound = current_playlist_sound.sound
|
||||||
|
|
||||||
if sound:
|
if sound:
|
||||||
|
# Get the service URL from the associated stream
|
||||||
|
service_url = None
|
||||||
|
if sound.streams:
|
||||||
|
# Get the first stream's URL if available
|
||||||
|
service_url = sound.streams[0].url
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": sound.id,
|
"id": sound.id,
|
||||||
"title": sound.name,
|
"title": sound.name,
|
||||||
@@ -511,6 +531,8 @@ class MusicPlayerService:
|
|||||||
if sound.thumbnail
|
if sound.thumbnail
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
"file_url": self._build_stream_url(sound.type, sound.filename),
|
||||||
|
"service_url": service_url,
|
||||||
"type": sound.type,
|
"type": sound.type,
|
||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
@@ -535,6 +557,12 @@ class MusicPlayerService:
|
|||||||
):
|
):
|
||||||
sound = playlist_sound.sound
|
sound = playlist_sound.sound
|
||||||
if sound:
|
if sound:
|
||||||
|
# Get the service URL from the associated stream
|
||||||
|
service_url = None
|
||||||
|
if sound.streams:
|
||||||
|
# Get the first stream's URL if available
|
||||||
|
service_url = sound.streams[0].url
|
||||||
|
|
||||||
tracks.append(
|
tracks.append(
|
||||||
{
|
{
|
||||||
"id": sound.id,
|
"id": sound.id,
|
||||||
@@ -548,6 +576,8 @@ class MusicPlayerService:
|
|||||||
if sound.thumbnail
|
if sound.thumbnail
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
"file_url": self._build_stream_url(sound.type, sound.filename),
|
||||||
|
"service_url": service_url,
|
||||||
"type": sound.type,
|
"type": sound.type,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import ffmpeg
|
import ffmpeg
|
||||||
from pydub import AudioSegment
|
|
||||||
|
|
||||||
from app.database import db
|
from app.database import db
|
||||||
from app.models.sound import Sound
|
from app.models.sound import Sound
|
||||||
@@ -632,9 +631,17 @@ class SoundNormalizerService:
|
|||||||
# Calculate file hash
|
# Calculate file hash
|
||||||
file_hash = SoundNormalizerService._calculate_file_hash(file_path)
|
file_hash = SoundNormalizerService._calculate_file_hash(file_path)
|
||||||
|
|
||||||
# Get duration using pydub
|
# Get duration using ffmpeg
|
||||||
audio = AudioSegment.from_wav(file_path)
|
probe = ffmpeg.probe(file_path)
|
||||||
duration = len(audio) # Duration in milliseconds
|
audio_stream = next(
|
||||||
|
(s for s in probe['streams'] if s['codec_type'] == 'audio'),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
|
if audio_stream and 'duration' in audio_stream:
|
||||||
|
duration = int(float(audio_stream['duration']) * 1000) # Convert to milliseconds
|
||||||
|
else:
|
||||||
|
duration = 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"duration": duration,
|
"duration": duration,
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import hashlib
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydub import AudioSegment
|
import ffmpeg
|
||||||
from pydub.utils import mediainfo
|
|
||||||
|
|
||||||
from app.database import db
|
from app.database import db
|
||||||
from app.models.sound import Sound
|
from app.models.sound import Sound
|
||||||
@@ -281,32 +280,31 @@ class SoundScannerService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_audio_metadata(file_path: str) -> dict:
|
def _extract_audio_metadata(file_path: str) -> dict:
|
||||||
"""Extract metadata from audio file using pydub and mediainfo."""
|
"""Extract metadata from audio file using ffmpeg-python."""
|
||||||
try:
|
try:
|
||||||
# Get file size
|
# Get file size
|
||||||
file_size = Path(file_path).stat().st_size
|
file_size = Path(file_path).stat().st_size
|
||||||
|
|
||||||
# Load audio file with pydub for basic info
|
# Use ffmpeg to probe audio metadata
|
||||||
audio = AudioSegment.from_file(file_path)
|
probe = ffmpeg.probe(file_path)
|
||||||
|
audio_stream = next(
|
||||||
|
(s for s in probe['streams'] if s['codec_type'] == 'audio'),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
|
if not audio_stream:
|
||||||
|
raise ValueError("No audio stream found in file")
|
||||||
|
|
||||||
# Extract basic metadata from AudioSegment
|
# Extract metadata from ffmpeg probe
|
||||||
duration = len(audio)
|
duration = int(float(audio_stream.get('duration', 0)) * 1000) # Convert to milliseconds
|
||||||
channels = audio.channels
|
channels = int(audio_stream.get('channels', 0))
|
||||||
sample_rate = audio.frame_rate
|
sample_rate = int(audio_stream.get('sample_rate', 0))
|
||||||
|
bitrate = int(audio_stream.get('bit_rate', 0)) if audio_stream.get('bit_rate') else None
|
||||||
# Use mediainfo for more accurate bitrate information
|
|
||||||
bitrate = None
|
# Fallback bitrate calculation if not available
|
||||||
try:
|
if not bitrate and duration > 0:
|
||||||
info = mediainfo(file_path)
|
file_size_bits = file_size * 8
|
||||||
if info and "bit_rate" in info:
|
bitrate = int(file_size_bits / (duration / 1000))
|
||||||
bitrate = int(info["bit_rate"])
|
|
||||||
elif info and "bitrate" in info:
|
|
||||||
bitrate = int(info["bitrate"])
|
|
||||||
except (ValueError, KeyError, TypeError):
|
|
||||||
# Fallback to calculated bitrate if mediainfo fails
|
|
||||||
if duration > 0:
|
|
||||||
file_size_bits = file_size * 8
|
|
||||||
bitrate = int(file_size_bits / duration / 1000)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"duration": duration,
|
"duration": duration,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ dependencies = [
|
|||||||
"flask-migrate==4.1.0",
|
"flask-migrate==4.1.0",
|
||||||
"flask-socketio==5.5.1",
|
"flask-socketio==5.5.1",
|
||||||
"flask-sqlalchemy==3.1.1",
|
"flask-sqlalchemy==3.1.1",
|
||||||
"pydub==0.25.1",
|
|
||||||
"python-dotenv==1.1.1",
|
"python-dotenv==1.1.1",
|
||||||
"python-vlc>=3.0.21203",
|
"python-vlc>=3.0.21203",
|
||||||
"requests==2.32.4",
|
"requests==2.32.4",
|
||||||
|
|||||||
11
uv.lock
generated
11
uv.lock
generated
@@ -505,15 +505,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 },
|
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pydub"
|
|
||||||
version = "0.25.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327 },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.19.2"
|
version = "2.19.2"
|
||||||
@@ -645,7 +636,6 @@ dependencies = [
|
|||||||
{ name = "flask-migrate" },
|
{ name = "flask-migrate" },
|
||||||
{ name = "flask-socketio" },
|
{ name = "flask-socketio" },
|
||||||
{ name = "flask-sqlalchemy" },
|
{ name = "flask-sqlalchemy" },
|
||||||
{ name = "pydub" },
|
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "python-vlc" },
|
{ name = "python-vlc" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
@@ -671,7 +661,6 @@ requires-dist = [
|
|||||||
{ name = "flask-migrate", specifier = "==4.1.0" },
|
{ name = "flask-migrate", specifier = "==4.1.0" },
|
||||||
{ name = "flask-socketio", specifier = "==5.5.1" },
|
{ name = "flask-socketio", specifier = "==5.5.1" },
|
||||||
{ name = "flask-sqlalchemy", specifier = "==3.1.1" },
|
{ name = "flask-sqlalchemy", specifier = "==3.1.1" },
|
||||||
{ name = "pydub", specifier = "==0.25.1" },
|
|
||||||
{ name = "python-dotenv", specifier = "==1.1.1" },
|
{ name = "python-dotenv", specifier = "==1.1.1" },
|
||||||
{ name = "python-vlc", specifier = ">=3.0.21203" },
|
{ name = "python-vlc", specifier = ">=3.0.21203" },
|
||||||
{ name = "requests", specifier = "==2.32.4" },
|
{ name = "requests", specifier = "==2.32.4" },
|
||||||
|
|||||||
Reference in New Issue
Block a user