Compare commits
8 Commits
b17e0db2b0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1f9667edd | ||
|
|
4cfc2ec0a2 | ||
|
|
39b7e14ae9 | ||
|
|
d0bda6c930 | ||
|
|
010f18bff4 | ||
|
|
e874d0665f | ||
|
|
ae238d3d18 | ||
|
|
7226d87a77 |
@@ -100,6 +100,7 @@ def create_app():
|
||||
auth,
|
||||
main,
|
||||
player,
|
||||
referential,
|
||||
soundboard,
|
||||
sounds,
|
||||
stream,
|
||||
@@ -109,6 +110,7 @@ def create_app():
|
||||
app.register_blueprint(auth.bp, url_prefix="/api/auth")
|
||||
app.register_blueprint(admin.bp, url_prefix="/api/admin")
|
||||
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(sounds.bp, url_prefix="/api/sounds")
|
||||
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()
|
||||
|
||||
|
||||
@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."""
|
||||
|
||||
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__)
|
||||
|
||||
@@ -9,3 +20,213 @@ bp = Blueprint("main", __name__)
|
||||
def health() -> dict[str, str]:
|
||||
"""Health check endpoint."""
|
||||
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
|
||||
sounds = Sound.find_by_type(sound_type)
|
||||
|
||||
# Order by name
|
||||
sounds = sorted(sounds, key=lambda s: s.name.lower())
|
||||
|
||||
# Convert to dict format
|
||||
sounds_data = [sound.to_dict() for sound in sounds]
|
||||
|
||||
@@ -59,6 +62,25 @@ def play_sound(sound_id: int):
|
||||
)
|
||||
|
||||
if success:
|
||||
# Get updated sound data to emit the new play count
|
||||
sound = Sound.query.get(sound_id)
|
||||
if sound:
|
||||
# Emit sound_changed event to all connected clients
|
||||
try:
|
||||
from app.services.socketio_service import SocketIOService
|
||||
|
||||
SocketIOService.emit_sound_play_count_changed(
|
||||
sound_id, sound.play_count
|
||||
)
|
||||
except Exception as e:
|
||||
# Don't fail the request if socket emission fails
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(
|
||||
f"Failed to emit sound_play_count_changed event: {e}"
|
||||
)
|
||||
|
||||
return jsonify({"message": "Sound playing", "sound_id": sound_id})
|
||||
return (
|
||||
jsonify({"error": "Sound not found or cannot be played"}),
|
||||
|
||||
@@ -106,34 +106,3 @@ class CreditService:
|
||||
"error": str(e),
|
||||
"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
|
||||
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(
|
||||
self, playlist, reload: bool = False
|
||||
) -> bool:
|
||||
@@ -499,6 +513,12 @@ class MusicPlayerService:
|
||||
sound = current_playlist_sound.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 {
|
||||
"id": sound.id,
|
||||
"title": sound.name,
|
||||
@@ -511,6 +531,8 @@ class MusicPlayerService:
|
||||
if sound.thumbnail
|
||||
else None
|
||||
),
|
||||
"file_url": self._build_stream_url(sound.type, sound.filename),
|
||||
"service_url": service_url,
|
||||
"type": sound.type,
|
||||
}
|
||||
return None
|
||||
@@ -535,6 +557,12 @@ class MusicPlayerService:
|
||||
):
|
||||
sound = playlist_sound.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(
|
||||
{
|
||||
"id": sound.id,
|
||||
@@ -548,6 +576,8 @@ class MusicPlayerService:
|
||||
if sound.thumbnail
|
||||
else None
|
||||
),
|
||||
"file_url": self._build_stream_url(sound.type, sound.filename),
|
||||
"service_url": service_url,
|
||||
"type": sound.type,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -46,6 +46,14 @@ class SocketIOService:
|
||||
{"credits": new_credits},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def emit_sound_play_count_changed(sound_id: int, new_play_count: int) -> None:
|
||||
"""Emit sound_play_count_changed event to all connected clients."""
|
||||
SocketIOService.emit_to_all(
|
||||
"sound_play_count_changed",
|
||||
{"sound_id": sound_id, "play_count": new_play_count},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def emit_credits_required(user_id: int, credits_needed: int) -> None:
|
||||
"""Emit an event when credits are required."""
|
||||
@@ -155,45 +163,45 @@ def handle_authenticate(data):
|
||||
disconnect()
|
||||
|
||||
|
||||
@socketio.on("play_sound")
|
||||
@require_credits(1)
|
||||
def handle_play_sound(data):
|
||||
"""Handle play_sound event from client."""
|
||||
try:
|
||||
user = SocketIOService.get_user_from_socketio()
|
||||
# @socketio.on("play_sound")
|
||||
# @require_credits(1)
|
||||
# def handle_play_sound(data):
|
||||
# """Handle play_sound event from client."""
|
||||
# try:
|
||||
# user = SocketIOService.get_user_from_socketio()
|
||||
|
||||
if not user:
|
||||
logger.warning("SocketIO play_sound failed - no authenticated user")
|
||||
# emit("error", {"message": "Authentication required"})
|
||||
return
|
||||
# if not user:
|
||||
# logger.warning("SocketIO play_sound failed - no authenticated user")
|
||||
# # emit("error", {"message": "Authentication required"})
|
||||
# return
|
||||
|
||||
user_id = int(user["id"])
|
||||
sound_id = data.get("soundId")
|
||||
if not sound_id:
|
||||
logger.warning("SocketIO play_sound failed - no soundId provided")
|
||||
SocketIOService.emit_to_user(
|
||||
user_id, "error", {"message": "Sound ID required"}
|
||||
)
|
||||
return
|
||||
# user_id = int(user["id"])
|
||||
# sound_id = data.get("soundId")
|
||||
# if not sound_id:
|
||||
# logger.warning("SocketIO play_sound failed - no soundId provided")
|
||||
# SocketIOService.emit_to_user(
|
||||
# user_id, "error", {"message": "Sound ID required"}
|
||||
# )
|
||||
# return
|
||||
|
||||
# Import and use the VLC service to play the sound
|
||||
from app.services.vlc_service import vlc_service
|
||||
# # Import and use the VLC service to play the sound
|
||||
# from app.services.vlc_service import vlc_service
|
||||
|
||||
logger.info(f"User {user_id} playing sound {sound_id} via SocketIO")
|
||||
# logger.info(f"User {user_id} playing sound {sound_id} via SocketIO")
|
||||
|
||||
# Play the sound using the VLC service
|
||||
success = vlc_service.play_sound(sound_id, user_id)
|
||||
# # Play the sound using the VLC service
|
||||
# success = vlc_service.play_sound(sound_id, user_id)
|
||||
|
||||
if not success:
|
||||
SocketIOService.emit_to_user(
|
||||
user_id,
|
||||
"error",
|
||||
{"message": f"Failed to play sound {sound_id}"},
|
||||
)
|
||||
# if not success:
|
||||
# SocketIOService.emit_to_user(
|
||||
# user_id,
|
||||
# "error",
|
||||
# {"message": f"Failed to play sound {sound_id}"},
|
||||
# )
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error handling play_sound event: {e}")
|
||||
# emit("error", {"message": "Failed to play sound"})
|
||||
# except Exception as e:
|
||||
# logger.exception(f"Error handling play_sound event: {e}")
|
||||
# # emit("error", {"message": "Failed to play sound"})
|
||||
|
||||
|
||||
@socketio.on("disconnect")
|
||||
|
||||
@@ -7,7 +7,6 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
import ffmpeg
|
||||
from pydub import AudioSegment
|
||||
|
||||
from app.database import db
|
||||
from app.models.sound import Sound
|
||||
@@ -632,9 +631,17 @@ class SoundNormalizerService:
|
||||
# Calculate file hash
|
||||
file_hash = SoundNormalizerService._calculate_file_hash(file_path)
|
||||
|
||||
# Get duration using pydub
|
||||
audio = AudioSegment.from_wav(file_path)
|
||||
duration = len(audio) # Duration in milliseconds
|
||||
# Get duration using ffmpeg
|
||||
probe = ffmpeg.probe(file_path)
|
||||
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 {
|
||||
"duration": duration,
|
||||
|
||||
@@ -4,8 +4,7 @@ import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from pydub import AudioSegment
|
||||
from pydub.utils import mediainfo
|
||||
import ffmpeg
|
||||
|
||||
from app.database import db
|
||||
from app.models.sound import Sound
|
||||
@@ -281,32 +280,31 @@ class SoundScannerService:
|
||||
|
||||
@staticmethod
|
||||
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:
|
||||
# Get file size
|
||||
file_size = Path(file_path).stat().st_size
|
||||
|
||||
# Load audio file with pydub for basic info
|
||||
audio = AudioSegment.from_file(file_path)
|
||||
# Use ffmpeg to probe audio metadata
|
||||
probe = ffmpeg.probe(file_path)
|
||||
audio_stream = next(
|
||||
(s for s in probe['streams'] if s['codec_type'] == 'audio'),
|
||||
None
|
||||
)
|
||||
|
||||
# Extract basic metadata from AudioSegment
|
||||
duration = len(audio)
|
||||
channels = audio.channels
|
||||
sample_rate = audio.frame_rate
|
||||
if not audio_stream:
|
||||
raise ValueError("No audio stream found in file")
|
||||
|
||||
# Use mediainfo for more accurate bitrate information
|
||||
bitrate = None
|
||||
try:
|
||||
info = mediainfo(file_path)
|
||||
if info and "bit_rate" in info:
|
||||
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)
|
||||
# Extract metadata from ffmpeg probe
|
||||
duration = int(float(audio_stream.get('duration', 0)) * 1000) # Convert to milliseconds
|
||||
channels = int(audio_stream.get('channels', 0))
|
||||
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
|
||||
|
||||
# Fallback bitrate calculation if not available
|
||||
if not bitrate and duration > 0:
|
||||
file_size_bits = file_size * 8
|
||||
bitrate = int(file_size_bits / (duration / 1000))
|
||||
|
||||
return {
|
||||
"duration": duration,
|
||||
|
||||
@@ -15,7 +15,6 @@ dependencies = [
|
||||
"flask-migrate==4.1.0",
|
||||
"flask-socketio==5.5.1",
|
||||
"flask-sqlalchemy==3.1.1",
|
||||
"pydub==0.25.1",
|
||||
"python-dotenv==1.1.1",
|
||||
"python-vlc>=3.0.21203",
|
||||
"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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
@@ -645,7 +636,6 @@ dependencies = [
|
||||
{ name = "flask-migrate" },
|
||||
{ name = "flask-socketio" },
|
||||
{ name = "flask-sqlalchemy" },
|
||||
{ name = "pydub" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-vlc" },
|
||||
{ name = "requests" },
|
||||
@@ -671,7 +661,6 @@ requires-dist = [
|
||||
{ name = "flask-migrate", specifier = "==4.1.0" },
|
||||
{ name = "flask-socketio", specifier = "==5.5.1" },
|
||||
{ name = "flask-sqlalchemy", specifier = "==3.1.1" },
|
||||
{ name = "pydub", specifier = "==0.25.1" },
|
||||
{ name = "python-dotenv", specifier = "==1.1.1" },
|
||||
{ name = "python-vlc", specifier = ">=3.0.21203" },
|
||||
{ name = "requests", specifier = "==2.32.4" },
|
||||
|
||||
Reference in New Issue
Block a user