Compare commits
5 Commits
842e1dff13
...
688b95b6af
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
688b95b6af | ||
|
|
627b95c961 | ||
|
|
fc734e2581 | ||
|
|
4e96c3538c | ||
|
|
6bbf3dce66 |
@@ -100,7 +100,6 @@ def create_app():
|
|||||||
auth,
|
auth,
|
||||||
main,
|
main,
|
||||||
player,
|
player,
|
||||||
playlist,
|
|
||||||
soundboard,
|
soundboard,
|
||||||
sounds,
|
sounds,
|
||||||
stream,
|
stream,
|
||||||
@@ -114,7 +113,6 @@ def create_app():
|
|||||||
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")
|
||||||
app.register_blueprint(player.bp, url_prefix="/api/player")
|
app.register_blueprint(player.bp, url_prefix="/api/player")
|
||||||
app.register_blueprint(playlist.bp, url_prefix="/api/playlists")
|
|
||||||
|
|
||||||
# Shutdown services when app is torn down
|
# Shutdown services when app is torn down
|
||||||
@app.teardown_appcontext
|
@app.teardown_appcontext
|
||||||
|
|||||||
@@ -81,19 +81,6 @@ class Playlist(db.Model):
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def to_detailed_dict(self) -> dict:
|
|
||||||
"""Convert playlist to detailed dictionary with sounds."""
|
|
||||||
playlist_dict = self.to_dict()
|
|
||||||
playlist_dict["sounds"] = [
|
|
||||||
{
|
|
||||||
"sound": ps.sound.to_dict() if ps.sound else None,
|
|
||||||
"order": ps.order,
|
|
||||||
"added_at": ps.added_at.isoformat() if ps.added_at else None,
|
|
||||||
}
|
|
||||||
for ps in sorted(self.playlist_sounds, key=lambda x: x.order)
|
|
||||||
]
|
|
||||||
return playlist_dict
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_playlist(
|
def create_playlist(
|
||||||
cls,
|
cls,
|
||||||
@@ -123,26 +110,6 @@ class Playlist(db.Model):
|
|||||||
|
|
||||||
return playlist
|
return playlist
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def find_by_name(
|
|
||||||
cls, name: str, user_id: Optional[int] = None
|
|
||||||
) -> Optional["Playlist"]:
|
|
||||||
"""Find playlist by name, optionally filtered by user."""
|
|
||||||
query = cls.query.filter_by(name=name)
|
|
||||||
if user_id is not None:
|
|
||||||
query = query.filter_by(user_id=user_id)
|
|
||||||
return query.first()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def find_by_user(cls, user_id: int) -> list["Playlist"]:
|
|
||||||
"""Find all playlists for a user."""
|
|
||||||
return cls.query.filter_by(user_id=user_id).order_by(cls.name).all()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def find_system_playlists(cls) -> list["Playlist"]:
|
|
||||||
"""Find all system playlists (user_id is None)."""
|
|
||||||
return cls.query.filter_by(user_id=None).order_by(cls.name).all()
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_current_playlist(
|
def find_current_playlist(
|
||||||
cls, user_id: Optional[int] = None
|
cls, user_id: Optional[int] = None
|
||||||
@@ -163,22 +130,6 @@ class Playlist(db.Model):
|
|||||||
query = query.filter_by(user_id=user_id)
|
query = query.filter_by(user_id=user_id)
|
||||||
return query.first()
|
return query.first()
|
||||||
|
|
||||||
def set_as_current(self, commit: bool = True) -> None:
|
|
||||||
"""Set this playlist as the current one and unset others."""
|
|
||||||
# Unset other current playlists for the same user/system
|
|
||||||
if self.user_id is not None:
|
|
||||||
Playlist.query.filter_by(
|
|
||||||
user_id=self.user_id, is_current=True
|
|
||||||
).update({"is_current": False})
|
|
||||||
else:
|
|
||||||
Playlist.query.filter_by(user_id=None, is_current=True).update(
|
|
||||||
{"is_current": False}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.is_current = True
|
|
||||||
if commit:
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
def add_sound(
|
def add_sound(
|
||||||
self, sound_id: int, order: Optional[int] = None, commit: bool = True
|
self, sound_id: int, order: Optional[int] = None, commit: bool = True
|
||||||
) -> "PlaylistSound":
|
) -> "PlaylistSound":
|
||||||
@@ -203,87 +154,3 @@ class Playlist(db.Model):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return playlist_sound
|
return playlist_sound
|
||||||
|
|
||||||
def remove_sound(self, sound_id: int, commit: bool = True) -> bool:
|
|
||||||
"""Remove a sound from the playlist."""
|
|
||||||
from app.models.playlist_sound import PlaylistSound
|
|
||||||
|
|
||||||
playlist_sound = PlaylistSound.query.filter_by(
|
|
||||||
playlist_id=self.id, sound_id=sound_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if playlist_sound:
|
|
||||||
db.session.delete(playlist_sound)
|
|
||||||
if commit:
|
|
||||||
db.session.commit()
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def reorder_sounds(
|
|
||||||
self, sound_orders: list[dict], commit: bool = True
|
|
||||||
) -> None:
|
|
||||||
"""Reorder sounds in the playlist.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
sound_orders: List of dicts with 'sound_id' and 'order' keys
|
|
||||||
"""
|
|
||||||
from app.models.playlist_sound import PlaylistSound
|
|
||||||
|
|
||||||
for item in sound_orders:
|
|
||||||
playlist_sound = PlaylistSound.query.filter_by(
|
|
||||||
playlist_id=self.id, sound_id=item["sound_id"]
|
|
||||||
).first()
|
|
||||||
if playlist_sound:
|
|
||||||
playlist_sound.order = item["order"]
|
|
||||||
|
|
||||||
if commit:
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
def get_total_duration(self) -> int:
|
|
||||||
"""Get total duration of all sounds in the playlist in milliseconds."""
|
|
||||||
from app.models.sound import Sound
|
|
||||||
|
|
||||||
total = (
|
|
||||||
db.session.query(db.func.sum(Sound.duration))
|
|
||||||
.join(self.playlist_sounds)
|
|
||||||
.filter(Sound.id.in_([ps.sound_id for ps in self.playlist_sounds]))
|
|
||||||
.scalar()
|
|
||||||
)
|
|
||||||
|
|
||||||
return total or 0
|
|
||||||
|
|
||||||
def duplicate(
|
|
||||||
self, new_name: str, user_id: Optional[int] = None, commit: bool = True
|
|
||||||
) -> "Playlist":
|
|
||||||
"""Create a duplicate of this playlist."""
|
|
||||||
new_playlist = Playlist.create_playlist(
|
|
||||||
name=new_name,
|
|
||||||
description=self.description,
|
|
||||||
genre=self.genre,
|
|
||||||
user_id=user_id,
|
|
||||||
is_main=False,
|
|
||||||
is_deletable=True,
|
|
||||||
is_current=False,
|
|
||||||
commit=commit,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Copy all sounds with their order
|
|
||||||
for ps in self.playlist_sounds:
|
|
||||||
new_playlist.add_sound(ps.sound_id, ps.order, commit=False)
|
|
||||||
|
|
||||||
if commit:
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return new_playlist
|
|
||||||
|
|
||||||
def save(self, commit: bool = True) -> None:
|
|
||||||
"""Save changes to the playlist."""
|
|
||||||
if commit:
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
def delete(self, commit: bool = True) -> None:
|
|
||||||
"""Delete the playlist."""
|
|
||||||
db.session.delete(self)
|
|
||||||
if commit:
|
|
||||||
db.session.commit()
|
|
||||||
|
|||||||
@@ -63,126 +63,3 @@ class PlaylistSound(db.Model):
|
|||||||
"added_at": self.added_at.isoformat() if self.added_at else None,
|
"added_at": self.added_at.isoformat() if self.added_at else None,
|
||||||
"sound": self.sound.to_dict() if self.sound else None,
|
"sound": self.sound.to_dict() if self.sound else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create_playlist_sound(
|
|
||||||
cls,
|
|
||||||
playlist_id: int,
|
|
||||||
sound_id: int,
|
|
||||||
order: int,
|
|
||||||
commit: bool = True,
|
|
||||||
) -> "PlaylistSound":
|
|
||||||
"""Create a new playlist-sound relationship."""
|
|
||||||
playlist_sound = cls(
|
|
||||||
playlist_id=playlist_id,
|
|
||||||
sound_id=sound_id,
|
|
||||||
order=order,
|
|
||||||
)
|
|
||||||
|
|
||||||
db.session.add(playlist_sound)
|
|
||||||
if commit:
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return playlist_sound
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def find_by_playlist(cls, playlist_id: int) -> list["PlaylistSound"]:
|
|
||||||
"""Find all sounds in a playlist ordered by their position."""
|
|
||||||
return (
|
|
||||||
cls.query.filter_by(playlist_id=playlist_id)
|
|
||||||
.order_by(cls.order)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def find_by_sound(cls, sound_id: int) -> list["PlaylistSound"]:
|
|
||||||
"""Find all playlists containing a specific sound."""
|
|
||||||
return cls.query.filter_by(sound_id=sound_id).all()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def find_by_playlist_and_sound(
|
|
||||||
cls, playlist_id: int, sound_id: int
|
|
||||||
) -> Optional["PlaylistSound"]:
|
|
||||||
"""Find a specific playlist-sound relationship."""
|
|
||||||
return cls.query.filter_by(
|
|
||||||
playlist_id=playlist_id, sound_id=sound_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_next_order(cls, playlist_id: int) -> int:
|
|
||||||
"""Get the next order number for a playlist."""
|
|
||||||
max_order = (
|
|
||||||
db.session.query(db.func.max(cls.order))
|
|
||||||
.filter_by(playlist_id=playlist_id)
|
|
||||||
.scalar()
|
|
||||||
)
|
|
||||||
return (max_order or 0) + 1
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def reorder_playlist(
|
|
||||||
cls, playlist_id: int, sound_orders: list[dict], commit: bool = True
|
|
||||||
) -> None:
|
|
||||||
"""Reorder all sounds in a playlist.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
playlist_id: ID of the playlist
|
|
||||||
sound_orders: List of dicts with 'sound_id' and 'order' keys
|
|
||||||
"""
|
|
||||||
for item in sound_orders:
|
|
||||||
playlist_sound = cls.query.filter_by(
|
|
||||||
playlist_id=playlist_id, sound_id=item["sound_id"]
|
|
||||||
).first()
|
|
||||||
if playlist_sound:
|
|
||||||
playlist_sound.order = item["order"]
|
|
||||||
|
|
||||||
if commit:
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
def move_to_position(self, new_order: int, commit: bool = True) -> None:
|
|
||||||
"""Move this sound to a new position in the playlist."""
|
|
||||||
old_order = self.order
|
|
||||||
|
|
||||||
if new_order == old_order:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get all other sounds in the playlist
|
|
||||||
other_sounds = (
|
|
||||||
PlaylistSound.query.filter_by(playlist_id=self.playlist_id)
|
|
||||||
.filter(PlaylistSound.id != self.id)
|
|
||||||
.order_by(PlaylistSound.order)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remove this sound from its current position
|
|
||||||
remaining_sounds = [ps for ps in other_sounds if ps.order != old_order]
|
|
||||||
|
|
||||||
# Insert at new position
|
|
||||||
if new_order <= len(remaining_sounds):
|
|
||||||
remaining_sounds.insert(new_order - 1, self)
|
|
||||||
else:
|
|
||||||
remaining_sounds.append(self)
|
|
||||||
|
|
||||||
# Update all order values
|
|
||||||
for i, ps in enumerate(remaining_sounds, 1):
|
|
||||||
ps.order = i
|
|
||||||
|
|
||||||
if commit:
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
def get_previous_sound(self) -> Optional["PlaylistSound"]:
|
|
||||||
"""Get the previous sound in the playlist."""
|
|
||||||
return (
|
|
||||||
PlaylistSound.query.filter_by(playlist_id=self.playlist_id)
|
|
||||||
.filter(PlaylistSound.order < self.order)
|
|
||||||
.order_by(PlaylistSound.order.desc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_next_sound(self) -> Optional["PlaylistSound"]:
|
|
||||||
"""Get the next sound in the playlist."""
|
|
||||||
return (
|
|
||||||
PlaylistSound.query.filter_by(playlist_id=self.playlist_id)
|
|
||||||
.filter(PlaylistSound.order > self.order)
|
|
||||||
.order_by(PlaylistSound.order.asc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -197,21 +197,6 @@ class Sound(db.Model):
|
|||||||
"""Find all sounds by type."""
|
"""Find all sounds by type."""
|
||||||
return cls.query.filter_by(type=sound_type).all()
|
return cls.query.filter_by(type=sound_type).all()
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_most_played(cls, limit: int = 10) -> list["Sound"]:
|
|
||||||
"""Get the most played sounds."""
|
|
||||||
return cls.query.order_by(cls.play_count.desc()).limit(limit).all()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_music_sounds(cls) -> list["Sound"]:
|
|
||||||
"""Get all music sounds."""
|
|
||||||
return cls.query.filter_by(is_music=True).all()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_deletable_sounds(cls) -> list["Sound"]:
|
|
||||||
"""Get all deletable sounds."""
|
|
||||||
return cls.query.filter_by(is_deletable=True).all()
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_sound(
|
def create_sound(
|
||||||
cls,
|
cls,
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class SoundPlayed(db.Model):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def create_play_record(
|
def create_play_record(
|
||||||
cls,
|
cls,
|
||||||
user_id: int,
|
user_id: int | None,
|
||||||
sound_id: int,
|
sound_id: int,
|
||||||
*,
|
*,
|
||||||
commit: bool = True,
|
commit: bool = True,
|
||||||
@@ -92,173 +92,3 @@ class SoundPlayed(db.Model):
|
|||||||
if commit:
|
if commit:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return play_record
|
return play_record
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_user_plays(
|
|
||||||
cls,
|
|
||||||
user_id: int,
|
|
||||||
limit: int = 50,
|
|
||||||
offset: int = 0,
|
|
||||||
) -> list["SoundPlayed"]:
|
|
||||||
"""Get recent plays for a specific user."""
|
|
||||||
return (
|
|
||||||
cls.query.filter_by(user_id=user_id)
|
|
||||||
.order_by(cls.played_at.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(limit)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_sound_plays(
|
|
||||||
cls,
|
|
||||||
sound_id: int,
|
|
||||||
limit: int = 50,
|
|
||||||
offset: int = 0,
|
|
||||||
) -> list["SoundPlayed"]:
|
|
||||||
"""Get recent plays for a specific sound."""
|
|
||||||
return (
|
|
||||||
cls.query.filter_by(sound_id=sound_id)
|
|
||||||
.order_by(cls.played_at.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(limit)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_recent_plays(
|
|
||||||
cls,
|
|
||||||
limit: int = 100,
|
|
||||||
offset: int = 0,
|
|
||||||
) -> list["SoundPlayed"]:
|
|
||||||
"""Get recent plays across all users and sounds."""
|
|
||||||
return (
|
|
||||||
cls.query.order_by(cls.played_at.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(limit)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_user_play_count(cls, user_id: int) -> int:
|
|
||||||
"""Get total play count for a user."""
|
|
||||||
return cls.query.filter_by(user_id=user_id).count()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_sound_play_count(cls, sound_id: int) -> int:
|
|
||||||
"""Get total play count for a sound."""
|
|
||||||
return cls.query.filter_by(sound_id=sound_id).count()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_popular_sounds(
|
|
||||||
cls,
|
|
||||||
limit: int = 10,
|
|
||||||
days: int | None = None,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Get most popular sounds with play counts."""
|
|
||||||
from app.models.sound import Sound
|
|
||||||
|
|
||||||
query = (
|
|
||||||
db.session.query(
|
|
||||||
cls.sound_id,
|
|
||||||
func.count(cls.id).label("play_count"),
|
|
||||||
func.max(cls.played_at).label("last_played"),
|
|
||||||
)
|
|
||||||
.group_by(cls.sound_id)
|
|
||||||
.order_by(func.count(cls.id).desc())
|
|
||||||
)
|
|
||||||
|
|
||||||
if days:
|
|
||||||
query = query.filter(
|
|
||||||
cls.played_at >= text(f"datetime('now', '-{days} days')"),
|
|
||||||
)
|
|
||||||
|
|
||||||
results = query.limit(limit).all()
|
|
||||||
|
|
||||||
# Get sound details
|
|
||||||
popular_sounds = []
|
|
||||||
for result in results:
|
|
||||||
sound = Sound.query.get(result.sound_id)
|
|
||||||
if sound:
|
|
||||||
popular_sounds.append(
|
|
||||||
{
|
|
||||||
"sound": sound.to_dict(),
|
|
||||||
"play_count": result.play_count,
|
|
||||||
"last_played": (
|
|
||||||
result.last_played.isoformat()
|
|
||||||
if result.last_played
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return popular_sounds
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_user_stats(cls, user_id: int) -> dict:
|
|
||||||
"""Get comprehensive stats for a user."""
|
|
||||||
from app.models.sound import Sound
|
|
||||||
|
|
||||||
total_plays = cls.query.filter_by(user_id=user_id).count()
|
|
||||||
|
|
||||||
if total_plays == 0:
|
|
||||||
return {
|
|
||||||
"total_plays": 0,
|
|
||||||
"unique_sounds": 0,
|
|
||||||
"favorite_sound": None,
|
|
||||||
"first_play": None,
|
|
||||||
"last_play": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get unique sounds count
|
|
||||||
unique_sounds = (
|
|
||||||
db.session.query(cls.sound_id)
|
|
||||||
.filter_by(user_id=user_id)
|
|
||||||
.distinct()
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get favorite sound
|
|
||||||
favorite_query = (
|
|
||||||
db.session.query(
|
|
||||||
cls.sound_id,
|
|
||||||
func.count(cls.id).label("play_count"),
|
|
||||||
)
|
|
||||||
.filter_by(user_id=user_id)
|
|
||||||
.group_by(cls.sound_id)
|
|
||||||
.order_by(func.count(cls.id).desc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
favorite_sound = None
|
|
||||||
if favorite_query:
|
|
||||||
sound = Sound.query.get(favorite_query.sound_id)
|
|
||||||
if sound:
|
|
||||||
favorite_sound = {
|
|
||||||
"sound": sound.to_dict(),
|
|
||||||
"play_count": favorite_query.play_count,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get first and last play dates
|
|
||||||
first_play = (
|
|
||||||
cls.query.filter_by(user_id=user_id)
|
|
||||||
.order_by(cls.played_at.asc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
last_play = (
|
|
||||||
cls.query.filter_by(user_id=user_id)
|
|
||||||
.order_by(cls.played_at.desc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total_plays": total_plays,
|
|
||||||
"unique_sounds": unique_sounds,
|
|
||||||
"favorite_sound": favorite_sound,
|
|
||||||
"first_play": (
|
|
||||||
first_play.played_at.isoformat() if first_play else None
|
|
||||||
),
|
|
||||||
"last_play": (
|
|
||||||
last_play.played_at.isoformat() if last_play else None
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ from datetime import datetime
|
|||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
from sqlalchemy import (
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database import db
|
from app.database import db
|
||||||
@@ -51,9 +58,7 @@ class Stream(db.Model):
|
|||||||
|
|
||||||
# Constraints
|
# Constraints
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint(
|
UniqueConstraint("service", "service_id", name="unique_service_stream"),
|
||||||
"service", "service_id", name="unique_service_stream"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
@@ -117,70 +122,3 @@ class Stream(db.Model):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return stream
|
return stream
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def find_by_service_and_id(
|
|
||||||
cls, service: str, service_id: str
|
|
||||||
) -> Optional["Stream"]:
|
|
||||||
"""Find stream by service and service_id."""
|
|
||||||
return cls.query.filter_by(
|
|
||||||
service=service, service_id=service_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def find_by_sound(cls, sound_id: int) -> list["Stream"]:
|
|
||||||
"""Find all streams for a specific sound."""
|
|
||||||
return cls.query.filter_by(sound_id=sound_id).all()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def find_by_service(cls, service: str) -> list["Stream"]:
|
|
||||||
"""Find all streams for a specific service."""
|
|
||||||
return cls.query.filter_by(service=service).all()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def find_by_status(cls, status: str) -> list["Stream"]:
|
|
||||||
"""Find all streams with a specific status."""
|
|
||||||
return cls.query.filter_by(status=status).all()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def find_active_streams(cls) -> list["Stream"]:
|
|
||||||
"""Find all active streams."""
|
|
||||||
return cls.query.filter_by(status="active").all()
|
|
||||||
|
|
||||||
def update_metadata(
|
|
||||||
self,
|
|
||||||
title: Optional[str] = None,
|
|
||||||
track: Optional[str] = None,
|
|
||||||
artist: Optional[str] = None,
|
|
||||||
album: Optional[str] = None,
|
|
||||||
genre: Optional[str] = None,
|
|
||||||
commit: bool = True,
|
|
||||||
) -> None:
|
|
||||||
"""Update stream metadata."""
|
|
||||||
if title is not None:
|
|
||||||
self.title = title
|
|
||||||
if track is not None:
|
|
||||||
self.track = track
|
|
||||||
if artist is not None:
|
|
||||||
self.artist = artist
|
|
||||||
if album is not None:
|
|
||||||
self.album = album
|
|
||||||
if genre is not None:
|
|
||||||
self.genre = genre
|
|
||||||
|
|
||||||
if commit:
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
def set_status(self, status: str, commit: bool = True) -> None:
|
|
||||||
"""Update stream status."""
|
|
||||||
self.status = status
|
|
||||||
if commit:
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
def is_active(self) -> bool:
|
|
||||||
"""Check if stream is active."""
|
|
||||||
return self.status == "active"
|
|
||||||
|
|
||||||
def get_display_name(self) -> str:
|
|
||||||
"""Get a display name for the stream (title or track or service_id)."""
|
|
||||||
return self.title or self.track or self.service_id
|
|
||||||
|
|||||||
@@ -82,53 +82,3 @@ def check_ffmpeg():
|
|||||||
return jsonify(ffmpeg_status), 200
|
return jsonify(ffmpeg_status), 200
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/list", methods=["GET"])
|
|
||||||
@require_admin
|
|
||||||
def list_sounds():
|
|
||||||
"""Get detailed list of all sounds with normalization status."""
|
|
||||||
from app.services.sound_management_service import SoundManagementService
|
|
||||||
|
|
||||||
return ErrorHandlingService.wrap_service_call(
|
|
||||||
SoundManagementService.get_sounds_with_file_status,
|
|
||||||
request.args.get("type", "SDB"),
|
|
||||||
int(request.args.get("page", 1)),
|
|
||||||
int(request.args.get("per_page", 50)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<int:sound_id>", methods=["DELETE"])
|
|
||||||
@require_admin
|
|
||||||
def delete_sound(sound_id: int):
|
|
||||||
"""Delete a sound and its files."""
|
|
||||||
from app.services.sound_management_service import SoundManagementService
|
|
||||||
|
|
||||||
return ErrorHandlingService.wrap_service_call(
|
|
||||||
SoundManagementService.delete_sound_with_files,
|
|
||||||
sound_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<int:sound_id>/normalize", methods=["POST"])
|
|
||||||
@require_admin
|
|
||||||
def normalize_single_sound(sound_id: int):
|
|
||||||
"""Normalize a specific sound."""
|
|
||||||
try:
|
|
||||||
from app.services.sound_management_service import SoundManagementService
|
|
||||||
|
|
||||||
data = request.get_json() or {}
|
|
||||||
overwrite = data.get("overwrite", False)
|
|
||||||
two_pass = data.get("two_pass", True)
|
|
||||||
|
|
||||||
result = SoundManagementService.normalize_sound(
|
|
||||||
sound_id,
|
|
||||||
overwrite,
|
|
||||||
two_pass,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result["success"]:
|
|
||||||
return jsonify(result), 200
|
|
||||||
return jsonify(result), 400
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"error": str(e)}), 500
|
|
||||||
|
|||||||
@@ -2,71 +2,10 @@
|
|||||||
|
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
|
|
||||||
from app.services.decorators import (
|
|
||||||
get_current_user,
|
|
||||||
require_auth,
|
|
||||||
require_credits,
|
|
||||||
)
|
|
||||||
|
|
||||||
bp = Blueprint("main", __name__)
|
bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/")
|
|
||||||
def index() -> dict[str, str]:
|
|
||||||
"""Root endpoint that returns API status."""
|
|
||||||
return {"message": "API is running", "status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/protected")
|
|
||||||
@require_auth
|
|
||||||
def protected() -> dict[str, str]:
|
|
||||||
"""Protected endpoint that requires authentication."""
|
|
||||||
user = get_current_user()
|
|
||||||
return {
|
|
||||||
"message": f"Hello {user['name']}, this is a protected endpoint!",
|
|
||||||
"user": user,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api-protected")
|
|
||||||
@require_auth
|
|
||||||
def api_protected() -> dict[str, str]:
|
|
||||||
"""Protected endpoint that accepts JWT or API token authentication."""
|
|
||||||
user = get_current_user()
|
|
||||||
return {
|
|
||||||
"message": f"Hello {user['name']}, you accessed this via {user['provider']}!",
|
|
||||||
"user": user,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/health")
|
@bp.route("/health")
|
||||||
def health() -> dict[str, str]:
|
def health() -> dict[str, str]:
|
||||||
"""Health check endpoint."""
|
"""Health check endpoint."""
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/use-credits/<int:amount>")
|
|
||||||
@require_auth
|
|
||||||
@require_credits(5)
|
|
||||||
def use_credits(amount: int) -> dict[str, str]:
|
|
||||||
"""Test endpoint that costs 5 credits to use."""
|
|
||||||
user = get_current_user()
|
|
||||||
return {
|
|
||||||
"message": f"Successfully used endpoint! You requested amount: {amount}",
|
|
||||||
"user": user["email"],
|
|
||||||
"remaining_credits": user["credits"]
|
|
||||||
- 5, # Note: credits already deducted by decorator
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/expensive-operation")
|
|
||||||
@require_auth
|
|
||||||
@require_credits(10)
|
|
||||||
def expensive_operation() -> dict[str, str]:
|
|
||||||
"""Test endpoint that costs 10 credits to use."""
|
|
||||||
user = get_current_user()
|
|
||||||
return {
|
|
||||||
"message": "Expensive operation completed successfully!",
|
|
||||||
"user": user["email"],
|
|
||||||
"operation_cost": 10,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -93,11 +93,14 @@ def seek():
|
|||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data or "position" not in data:
|
if not data or "position" not in data:
|
||||||
return jsonify({"error": "Position required"}), 400
|
return jsonify({"error": "Position required"}), 400
|
||||||
|
|
||||||
position = float(data["position"])
|
position = float(data["position"])
|
||||||
if not 0.0 <= position <= 1.0:
|
if not 0.0 <= position <= 1.0:
|
||||||
return jsonify({"error": "Position must be between 0.0 and 1.0"}), 400
|
return (
|
||||||
|
jsonify({"error": "Position must be between 0.0 and 1.0"}),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
|
||||||
success = music_player_service.seek(position)
|
success = music_player_service.seek(position)
|
||||||
if success:
|
if success:
|
||||||
return jsonify({"message": "Seek successful"}), 200
|
return jsonify({"message": "Seek successful"}), 200
|
||||||
@@ -116,11 +119,11 @@ def set_volume():
|
|||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data or "volume" not in data:
|
if not data or "volume" not in data:
|
||||||
return jsonify({"error": "Volume required"}), 400
|
return jsonify({"error": "Volume required"}), 400
|
||||||
|
|
||||||
volume = int(data["volume"])
|
volume = int(data["volume"])
|
||||||
if not 0 <= volume <= 100:
|
if not 0 <= volume <= 100:
|
||||||
return jsonify({"error": "Volume must be between 0 and 100"}), 400
|
return jsonify({"error": "Volume must be between 0 and 100"}), 400
|
||||||
|
|
||||||
success = music_player_service.set_volume(volume)
|
success = music_player_service.set_volume(volume)
|
||||||
if success:
|
if success:
|
||||||
return jsonify({"message": "Volume set successfully"}), 200
|
return jsonify({"message": "Volume set successfully"}), 200
|
||||||
@@ -139,12 +142,23 @@ def set_play_mode():
|
|||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data or "mode" not in data:
|
if not data or "mode" not in data:
|
||||||
return jsonify({"error": "Mode required"}), 400
|
return jsonify({"error": "Mode required"}), 400
|
||||||
|
|
||||||
mode = data["mode"]
|
mode = data["mode"]
|
||||||
valid_modes = ["continuous", "loop-playlist", "loop-one", "random"]
|
valid_modes = [
|
||||||
|
"continuous",
|
||||||
|
"loop-playlist",
|
||||||
|
"loop-one",
|
||||||
|
"random",
|
||||||
|
"single",
|
||||||
|
]
|
||||||
if mode not in valid_modes:
|
if mode not in valid_modes:
|
||||||
return jsonify({"error": f"Mode must be one of: {', '.join(valid_modes)}"}), 400
|
return (
|
||||||
|
jsonify(
|
||||||
|
{"error": f"Mode must be one of: {', '.join(valid_modes)}"}
|
||||||
|
),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
|
||||||
success = music_player_service.set_play_mode(mode)
|
success = music_player_service.set_play_mode(mode)
|
||||||
if success:
|
if success:
|
||||||
return jsonify({"message": "Play mode set successfully"}), 200
|
return jsonify({"message": "Play mode set successfully"}), 200
|
||||||
@@ -161,7 +175,7 @@ def load_playlist():
|
|||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data or "playlist_id" not in data:
|
if not data or "playlist_id" not in data:
|
||||||
return jsonify({"error": "Playlist ID required"}), 400
|
return jsonify({"error": "Playlist ID required"}), 400
|
||||||
|
|
||||||
playlist_id = int(data["playlist_id"])
|
playlist_id = int(data["playlist_id"])
|
||||||
success = music_player_service.load_playlist(playlist_id)
|
success = music_player_service.load_playlist(playlist_id)
|
||||||
if success:
|
if success:
|
||||||
@@ -181,7 +195,7 @@ def play_track():
|
|||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data or "index" not in data:
|
if not data or "index" not in data:
|
||||||
return jsonify({"error": "Track index required"}), 400
|
return jsonify({"error": "Track index required"}), 400
|
||||||
|
|
||||||
index = int(data["index"])
|
index = int(data["index"])
|
||||||
success = music_player_service.play_track_at_index(index)
|
success = music_player_service.play_track_at_index(index)
|
||||||
if success:
|
if success:
|
||||||
@@ -191,42 +205,3 @@ def play_track():
|
|||||||
return jsonify({"error": "Invalid track index"}), 400
|
return jsonify({"error": "Invalid track index"}), 400
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ErrorHandlingService.handle_generic_error(e)
|
return ErrorHandlingService.handle_generic_error(e)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/start-instance", methods=["POST"])
|
|
||||||
@require_auth
|
|
||||||
def start_vlc_instance():
|
|
||||||
"""Start the VLC player instance."""
|
|
||||||
try:
|
|
||||||
success = music_player_service.start_vlc_instance()
|
|
||||||
if success:
|
|
||||||
return jsonify({"message": "VLC instance started successfully"}), 200
|
|
||||||
return jsonify({"error": "Failed to start VLC instance"}), 500
|
|
||||||
except Exception as e:
|
|
||||||
return ErrorHandlingService.handle_generic_error(e)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/stop-instance", methods=["POST"])
|
|
||||||
@require_auth
|
|
||||||
def stop_vlc_instance():
|
|
||||||
"""Stop the VLC player instance."""
|
|
||||||
try:
|
|
||||||
success = music_player_service.stop_vlc_instance()
|
|
||||||
if success:
|
|
||||||
return jsonify({"message": "VLC instance stopped successfully"}), 200
|
|
||||||
return jsonify({"error": "Failed to stop VLC instance"}), 500
|
|
||||||
except Exception as e:
|
|
||||||
return ErrorHandlingService.handle_generic_error(e)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/test-emit", methods=["POST"])
|
|
||||||
@require_auth
|
|
||||||
def test_emit():
|
|
||||||
"""Test SocketIO emission manually."""
|
|
||||||
try:
|
|
||||||
# Force emit player state
|
|
||||||
music_player_service._emit_player_state()
|
|
||||||
return jsonify({"message": "Test emission sent"}), 200
|
|
||||||
except Exception as e:
|
|
||||||
return ErrorHandlingService.handle_generic_error(e)
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
"""Playlist management routes."""
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
|
||||||
from app.models.playlist import Playlist
|
|
||||||
from app.models.sound import Sound
|
|
||||||
from app.services.decorators import require_auth
|
|
||||||
from app.services.music_player_service import music_player_service
|
|
||||||
from app.services.logging_service import LoggingService
|
|
||||||
|
|
||||||
logger = LoggingService.get_logger(__name__)
|
|
||||||
|
|
||||||
bp = Blueprint("playlist", __name__)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/", methods=["GET"])
|
|
||||||
@require_auth
|
|
||||||
def get_playlists():
|
|
||||||
"""Get all playlists."""
|
|
||||||
try:
|
|
||||||
# Get system playlists and user playlists
|
|
||||||
system_playlists = Playlist.find_system_playlists()
|
|
||||||
user_playlists = [] # TODO: Add user-specific playlists when user auth is implemented
|
|
||||||
|
|
||||||
playlists = system_playlists + user_playlists
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"playlists": [playlist.to_dict() for playlist in playlists]
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting playlists: {e}")
|
|
||||||
return jsonify({"error": "Failed to get playlists"}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<int:playlist_id>", methods=["GET"])
|
|
||||||
@require_auth
|
|
||||||
def get_playlist(playlist_id):
|
|
||||||
"""Get a specific playlist with sounds."""
|
|
||||||
try:
|
|
||||||
playlist = Playlist.query.get_or_404(playlist_id)
|
|
||||||
return jsonify({"playlist": playlist.to_detailed_dict()})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting playlist {playlist_id}: {e}")
|
|
||||||
return jsonify({"error": "Failed to get playlist"}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/", methods=["POST"])
|
|
||||||
@require_auth
|
|
||||||
def create_playlist():
|
|
||||||
"""Create a new playlist."""
|
|
||||||
try:
|
|
||||||
data = request.get_json()
|
|
||||||
name = data.get("name")
|
|
||||||
description = data.get("description")
|
|
||||||
genre = data.get("genre")
|
|
||||||
|
|
||||||
if not name:
|
|
||||||
return jsonify({"error": "Playlist name is required"}), 400
|
|
||||||
|
|
||||||
# Check if playlist with same name already exists
|
|
||||||
existing = Playlist.find_by_name(name)
|
|
||||||
if existing:
|
|
||||||
return jsonify({"error": "Playlist with this name already exists"}), 400
|
|
||||||
|
|
||||||
playlist = Playlist.create_playlist(
|
|
||||||
name=name,
|
|
||||||
description=description,
|
|
||||||
genre=genre,
|
|
||||||
user_id=None, # System playlist for now
|
|
||||||
is_deletable=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify({"playlist": playlist.to_dict()}), 201
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error creating playlist: {e}")
|
|
||||||
return jsonify({"error": "Failed to create playlist"}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<int:playlist_id>", methods=["PUT"])
|
|
||||||
@require_auth
|
|
||||||
def update_playlist(playlist_id):
|
|
||||||
"""Update a playlist."""
|
|
||||||
try:
|
|
||||||
playlist = Playlist.query.get_or_404(playlist_id)
|
|
||||||
data = request.get_json()
|
|
||||||
|
|
||||||
if "name" in data:
|
|
||||||
playlist.name = data["name"]
|
|
||||||
if "description" in data:
|
|
||||||
playlist.description = data["description"]
|
|
||||||
if "genre" in data:
|
|
||||||
playlist.genre = data["genre"]
|
|
||||||
|
|
||||||
playlist.save()
|
|
||||||
|
|
||||||
return jsonify({"playlist": playlist.to_dict()})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating playlist {playlist_id}: {e}")
|
|
||||||
return jsonify({"error": "Failed to update playlist"}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<int:playlist_id>", methods=["DELETE"])
|
|
||||||
@require_auth
|
|
||||||
def delete_playlist(playlist_id):
|
|
||||||
"""Delete a playlist."""
|
|
||||||
try:
|
|
||||||
playlist = Playlist.query.get_or_404(playlist_id)
|
|
||||||
|
|
||||||
if not playlist.is_deletable:
|
|
||||||
return jsonify({"error": "This playlist cannot be deleted"}), 400
|
|
||||||
|
|
||||||
# If this is the current playlist, clear it from the player
|
|
||||||
current_playlist = Playlist.find_current_playlist()
|
|
||||||
if current_playlist and current_playlist.id == playlist_id:
|
|
||||||
# Set main playlist as current if it exists
|
|
||||||
main_playlist = Playlist.find_main_playlist()
|
|
||||||
if main_playlist:
|
|
||||||
main_playlist.set_as_current()
|
|
||||||
music_player_service.reload_current_playlist_if_modified(main_playlist.id)
|
|
||||||
|
|
||||||
playlist.delete()
|
|
||||||
|
|
||||||
return jsonify({"message": "Playlist deleted successfully"})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error deleting playlist {playlist_id}: {e}")
|
|
||||||
return jsonify({"error": "Failed to delete playlist"}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<int:playlist_id>/set-current", methods=["POST"])
|
|
||||||
@require_auth
|
|
||||||
def set_current_playlist(playlist_id):
|
|
||||||
"""Set a playlist as the current one."""
|
|
||||||
try:
|
|
||||||
playlist = Playlist.query.get_or_404(playlist_id)
|
|
||||||
playlist.set_as_current()
|
|
||||||
|
|
||||||
# Reload the playlist in the music player
|
|
||||||
music_player_service.reload_current_playlist_if_modified(playlist_id)
|
|
||||||
|
|
||||||
return jsonify({"message": "Playlist set as current"})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error setting current playlist {playlist_id}: {e}")
|
|
||||||
return jsonify({"error": "Failed to set current playlist"}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<int:playlist_id>/sounds", methods=["POST"])
|
|
||||||
@require_auth
|
|
||||||
def add_sound_to_playlist(playlist_id):
|
|
||||||
"""Add a sound to a playlist."""
|
|
||||||
try:
|
|
||||||
playlist = Playlist.query.get_or_404(playlist_id)
|
|
||||||
data = request.get_json()
|
|
||||||
sound_id = data.get("sound_id")
|
|
||||||
order = data.get("order")
|
|
||||||
|
|
||||||
if not sound_id:
|
|
||||||
return jsonify({"error": "Sound ID is required"}), 400
|
|
||||||
|
|
||||||
# Verify sound exists
|
|
||||||
sound = Sound.query.get_or_404(sound_id)
|
|
||||||
|
|
||||||
# Add sound to playlist
|
|
||||||
playlist_sound = playlist.add_sound(sound_id, order)
|
|
||||||
|
|
||||||
# Reload playlist in music player if it's the current one
|
|
||||||
music_player_service.reload_current_playlist_if_modified(playlist_id)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"message": "Sound added to playlist",
|
|
||||||
"playlist_sound": {
|
|
||||||
"sound_id": playlist_sound.sound_id,
|
|
||||||
"order": playlist_sound.order
|
|
||||||
}
|
|
||||||
}), 201
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error adding sound to playlist {playlist_id}: {e}")
|
|
||||||
return jsonify({"error": "Failed to add sound to playlist"}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<int:playlist_id>/sounds/<int:sound_id>", methods=["DELETE"])
|
|
||||||
@require_auth
|
|
||||||
def remove_sound_from_playlist(playlist_id, sound_id):
|
|
||||||
"""Remove a sound from a playlist."""
|
|
||||||
try:
|
|
||||||
playlist = Playlist.query.get_or_404(playlist_id)
|
|
||||||
|
|
||||||
success = playlist.remove_sound(sound_id)
|
|
||||||
if not success:
|
|
||||||
return jsonify({"error": "Sound not found in playlist"}), 404
|
|
||||||
|
|
||||||
# Reload playlist in music player if it's the current one
|
|
||||||
music_player_service.reload_current_playlist_if_modified(playlist_id)
|
|
||||||
|
|
||||||
return jsonify({"message": "Sound removed from playlist"})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error removing sound from playlist {playlist_id}: {e}")
|
|
||||||
return jsonify({"error": "Failed to remove sound from playlist"}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<int:playlist_id>/sounds/reorder", methods=["PUT"])
|
|
||||||
@require_auth
|
|
||||||
def reorder_playlist_sounds(playlist_id):
|
|
||||||
"""Reorder sounds in a playlist."""
|
|
||||||
try:
|
|
||||||
playlist = Playlist.query.get_or_404(playlist_id)
|
|
||||||
data = request.get_json()
|
|
||||||
sound_orders = data.get("sound_orders", [])
|
|
||||||
|
|
||||||
if not sound_orders:
|
|
||||||
return jsonify({"error": "Sound orders are required"}), 400
|
|
||||||
|
|
||||||
# Validate sound_orders format
|
|
||||||
for item in sound_orders:
|
|
||||||
if not isinstance(item, dict) or "sound_id" not in item or "order" not in item:
|
|
||||||
return jsonify({"error": "Invalid sound_orders format"}), 400
|
|
||||||
|
|
||||||
# Reorder sounds
|
|
||||||
playlist.reorder_sounds(sound_orders)
|
|
||||||
|
|
||||||
# Reload playlist in music player if it's the current one
|
|
||||||
music_player_service.reload_current_playlist_if_modified(playlist_id)
|
|
||||||
|
|
||||||
return jsonify({"message": "Playlist sounds reordered"})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error reordering playlist sounds {playlist_id}: {e}")
|
|
||||||
return jsonify({"error": "Failed to reorder playlist sounds"}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<int:playlist_id>/duplicate", methods=["POST"])
|
|
||||||
@require_auth
|
|
||||||
def duplicate_playlist(playlist_id):
|
|
||||||
"""Duplicate a playlist."""
|
|
||||||
try:
|
|
||||||
playlist = Playlist.query.get_or_404(playlist_id)
|
|
||||||
data = request.get_json()
|
|
||||||
new_name = data.get("name")
|
|
||||||
|
|
||||||
if not new_name:
|
|
||||||
return jsonify({"error": "New playlist name is required"}), 400
|
|
||||||
|
|
||||||
# Check if playlist with same name already exists
|
|
||||||
existing = Playlist.find_by_name(new_name)
|
|
||||||
if existing:
|
|
||||||
return jsonify({"error": "Playlist with this name already exists"}), 400
|
|
||||||
|
|
||||||
new_playlist = playlist.duplicate(new_name)
|
|
||||||
|
|
||||||
return jsonify({"playlist": new_playlist.to_dict()}), 201
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error duplicating playlist {playlist_id}: {e}")
|
|
||||||
return jsonify({"error": "Failed to duplicate playlist"}), 500
|
|
||||||
@@ -140,99 +140,3 @@ def get_status():
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/history", methods=["GET"])
|
|
||||||
@require_auth
|
|
||||||
def get_play_history():
|
|
||||||
"""Get recent play history."""
|
|
||||||
try:
|
|
||||||
page = int(request.args.get("page", 1))
|
|
||||||
per_page = min(int(request.args.get("per_page", 50)), 100)
|
|
||||||
offset = (page - 1) * per_page
|
|
||||||
|
|
||||||
recent_plays = SoundPlayed.get_recent_plays(
|
|
||||||
limit=per_page,
|
|
||||||
offset=offset,
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"plays": [play.to_dict() for play in recent_plays],
|
|
||||||
"page": page,
|
|
||||||
"per_page": per_page,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"error": str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/my-history", methods=["GET"])
|
|
||||||
@require_auth
|
|
||||||
def get_my_play_history():
|
|
||||||
"""Get current user's play history."""
|
|
||||||
try:
|
|
||||||
user = get_current_user()
|
|
||||||
if not user:
|
|
||||||
return jsonify({"error": "User not found"}), 404
|
|
||||||
|
|
||||||
user_id = int(user["id"])
|
|
||||||
page = int(request.args.get("page", 1))
|
|
||||||
per_page = min(int(request.args.get("per_page", 50)), 100)
|
|
||||||
offset = (page - 1) * per_page
|
|
||||||
|
|
||||||
user_plays = SoundPlayed.get_user_plays(
|
|
||||||
user_id=user_id,
|
|
||||||
limit=per_page,
|
|
||||||
offset=offset,
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"plays": [play.to_dict() for play in user_plays],
|
|
||||||
"page": page,
|
|
||||||
"per_page": per_page,
|
|
||||||
"user_id": user_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"error": str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/my-stats", methods=["GET"])
|
|
||||||
@require_auth
|
|
||||||
def get_my_stats():
|
|
||||||
"""Get current user's play statistics."""
|
|
||||||
try:
|
|
||||||
user = get_current_user()
|
|
||||||
if not user:
|
|
||||||
return jsonify({"error": "User not found"}), 404
|
|
||||||
|
|
||||||
user_id = int(user["id"])
|
|
||||||
stats = SoundPlayed.get_user_stats(user_id)
|
|
||||||
|
|
||||||
return jsonify(stats)
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"error": str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/popular", methods=["GET"])
|
|
||||||
@require_auth
|
|
||||||
def get_popular_sounds():
|
|
||||||
"""Get most popular sounds."""
|
|
||||||
try:
|
|
||||||
limit = min(int(request.args.get("limit", 10)), 50)
|
|
||||||
days = request.args.get("days")
|
|
||||||
days = int(days) if days and days.isdigit() else None
|
|
||||||
|
|
||||||
popular_sounds = SoundPlayed.get_popular_sounds(limit=limit, days=days)
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"popular_sounds": popular_sounds,
|
|
||||||
"limit": limit,
|
|
||||||
"days": days,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"error": str(e)}), 500
|
|
||||||
|
|||||||
@@ -90,121 +90,3 @@ def add_url():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/", methods=["GET"])
|
|
||||||
@require_auth
|
|
||||||
def list_streams():
|
|
||||||
"""List all streams with optional filtering."""
|
|
||||||
try:
|
|
||||||
status = request.args.get("status")
|
|
||||||
service = request.args.get("service")
|
|
||||||
|
|
||||||
query = Stream.query
|
|
||||||
|
|
||||||
if status:
|
|
||||||
query = query.filter_by(status=status)
|
|
||||||
if service:
|
|
||||||
query = query.filter_by(service=service)
|
|
||||||
|
|
||||||
streams = query.order_by(Stream.created_at.desc()).all()
|
|
||||||
|
|
||||||
return (
|
|
||||||
jsonify({"streams": [stream.to_dict() for stream in streams]}),
|
|
||||||
200,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"error": str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<int:stream_id>", methods=["GET"])
|
|
||||||
@require_auth
|
|
||||||
def get_stream(stream_id):
|
|
||||||
"""Get a specific stream by ID."""
|
|
||||||
try:
|
|
||||||
stream = Stream.query.get_or_404(stream_id)
|
|
||||||
return jsonify({"stream": stream.to_dict()}), 200
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"error": str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<int:stream_id>", methods=["PUT"])
|
|
||||||
@require_auth
|
|
||||||
def update_stream(stream_id):
|
|
||||||
"""Update stream metadata."""
|
|
||||||
try:
|
|
||||||
stream = Stream.query.get_or_404(stream_id)
|
|
||||||
data = request.get_json()
|
|
||||||
|
|
||||||
if not data:
|
|
||||||
return jsonify({"error": "No data provided"}), 400
|
|
||||||
|
|
||||||
# Update allowed fields
|
|
||||||
updatable_fields = [
|
|
||||||
"title",
|
|
||||||
"track",
|
|
||||||
"artist",
|
|
||||||
"album",
|
|
||||||
"genre",
|
|
||||||
"status",
|
|
||||||
]
|
|
||||||
for field in updatable_fields:
|
|
||||||
if field in data:
|
|
||||||
setattr(stream, field, data[field])
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return (
|
|
||||||
jsonify(
|
|
||||||
{
|
|
||||||
"message": "Stream updated successfully",
|
|
||||||
"stream": stream.to_dict(),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
200,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
return jsonify({"error": str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<int:stream_id>", methods=["DELETE"])
|
|
||||||
@require_auth
|
|
||||||
def delete_stream(stream_id):
|
|
||||||
"""Delete a stream."""
|
|
||||||
try:
|
|
||||||
stream = Stream.query.get_or_404(stream_id)
|
|
||||||
|
|
||||||
# If stream is being processed, mark for deletion instead
|
|
||||||
if stream.status == "processing":
|
|
||||||
stream.status = "cancelled"
|
|
||||||
db.session.commit()
|
|
||||||
return jsonify({"message": "Stream marked for cancellation"}), 200
|
|
||||||
|
|
||||||
db.session.delete(stream)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return jsonify({"message": "Stream deleted successfully"}), 200
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
return jsonify({"error": str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/queue/status", methods=["GET"])
|
|
||||||
@require_auth
|
|
||||||
def queue_status():
|
|
||||||
"""Get the current processing queue status."""
|
|
||||||
try:
|
|
||||||
from app.services.stream_processing_service import (
|
|
||||||
StreamProcessingService,
|
|
||||||
)
|
|
||||||
|
|
||||||
status = StreamProcessingService.get_queue_status()
|
|
||||||
return jsonify(status), 200
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"error": str(e)}), 500
|
|
||||||
|
|||||||
@@ -3,18 +3,28 @@
|
|||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import vlc
|
import vlc
|
||||||
from flask import current_app, request
|
from flask import current_app, request
|
||||||
|
|
||||||
from app.models.playlist import Playlist
|
from app.models.playlist import Playlist
|
||||||
from app.models.sound import Sound
|
from app.models.sound import Sound
|
||||||
|
from app.models.sound_played import SoundPlayed
|
||||||
from app.services.logging_service import LoggingService
|
from app.services.logging_service import LoggingService
|
||||||
from app.services.socketio_service import socketio_service
|
from app.services.socketio_service import socketio_service
|
||||||
|
|
||||||
logger = LoggingService.get_logger(__name__)
|
logger = LoggingService.get_logger(__name__)
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
TRACK_START_THRESHOLD_MS = 500 # 500 milliseconds - threshold for considering a track as "starting fresh"
|
||||||
|
STATE_CHANGE_THRESHOLD_MS = (
|
||||||
|
1000 # 1 second threshold for state change detection
|
||||||
|
)
|
||||||
|
PLAY_COMPLETION_THRESHOLD = 0.20 # 20% completion threshold to count as a play
|
||||||
|
|
||||||
|
|
||||||
class MusicPlayerService:
|
class MusicPlayerService:
|
||||||
"""Service for managing a VLC music player with playlist support."""
|
"""Service for managing a VLC music player with playlist support."""
|
||||||
@@ -32,7 +42,7 @@ class MusicPlayerService:
|
|||||||
) # Store file paths for manual playlist management
|
) # Store file paths for manual playlist management
|
||||||
self.volume = 80
|
self.volume = 80
|
||||||
self.play_mode = (
|
self.play_mode = (
|
||||||
"continuous" # continuous, loop-playlist, loop-one, random
|
"continuous" # single, continuous, loop-playlist, loop-one, random
|
||||||
)
|
)
|
||||||
self.is_playing = False
|
self.is_playing = False
|
||||||
self.current_time = 0
|
self.current_time = 0
|
||||||
@@ -47,6 +57,15 @@ class MusicPlayerService:
|
|||||||
self._track_ending_handled = (
|
self._track_ending_handled = (
|
||||||
False # Flag to prevent duplicate ending triggers
|
False # Flag to prevent duplicate ending triggers
|
||||||
)
|
)
|
||||||
|
self._track_play_tracked = (
|
||||||
|
False # Flag to track if current track play has been logged
|
||||||
|
)
|
||||||
|
self._cumulative_play_time = (
|
||||||
|
0 # Cumulative time actually played for current track
|
||||||
|
)
|
||||||
|
self._last_position_update = (
|
||||||
|
0 # Last position for calculating continuous play time
|
||||||
|
)
|
||||||
|
|
||||||
def start_vlc_instance(self) -> bool:
|
def start_vlc_instance(self) -> bool:
|
||||||
"""Start a VLC instance with Python bindings."""
|
"""Start a VLC instance with Python bindings."""
|
||||||
@@ -216,12 +235,44 @@ class MusicPlayerService:
|
|||||||
self.current_track_index = index
|
self.current_track_index = index
|
||||||
# Reset track ending flag when loading a new track
|
# Reset track ending flag when loading a new track
|
||||||
self._track_ending_handled = False
|
self._track_ending_handled = False
|
||||||
|
self._track_play_tracked = (
|
||||||
|
False # Reset play tracking for new track
|
||||||
|
)
|
||||||
|
# Reset cumulative play time tracking for new track
|
||||||
|
self._cumulative_play_time = 0
|
||||||
|
self._last_position_update = 0
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error loading track at index {index}: {e}")
|
logger.error(f"Error loading track at index {index}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _track_sound_play(self, sound_id: int) -> None:
|
||||||
|
"""Track that a sound has been played."""
|
||||||
|
try:
|
||||||
|
# Use stored app instance or current_app
|
||||||
|
app_to_use = self.app or current_app
|
||||||
|
if app_to_use:
|
||||||
|
with app_to_use.app_context():
|
||||||
|
# Get the sound and increment its play count
|
||||||
|
sound = Sound.query.get(sound_id)
|
||||||
|
if sound:
|
||||||
|
sound.play_count += 1
|
||||||
|
sound.updated_at = datetime.now(tz=ZoneInfo("UTC"))
|
||||||
|
logger.info(
|
||||||
|
f"Incremented play count for sound '{sound.name}' (ID: {sound_id})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a sound played record without user_id (anonymous play)
|
||||||
|
SoundPlayed.create_play_record(
|
||||||
|
user_id=None, sound_id=sound_id, commit=True
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Created anonymous play record for sound ID: {sound_id}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error tracking sound play for sound {sound_id}: {e}")
|
||||||
|
|
||||||
def _get_sound_file_path(self, sound: Sound) -> Optional[str]:
|
def _get_sound_file_path(self, sound: Sound) -> Optional[str]:
|
||||||
"""Get the file path for a sound, preferring normalized version."""
|
"""Get the file path for a sound, preferring normalized version."""
|
||||||
try:
|
try:
|
||||||
@@ -268,6 +319,9 @@ class MusicPlayerService:
|
|||||||
result = self.player.play()
|
result = self.player.play()
|
||||||
if result == 0: # Success
|
if result == 0: # Success
|
||||||
self.is_playing = True
|
self.is_playing = True
|
||||||
|
self._track_play_tracked = (
|
||||||
|
False # Track when we first start playing
|
||||||
|
)
|
||||||
self._emit_player_state()
|
self._emit_player_state()
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -399,7 +453,13 @@ class MusicPlayerService:
|
|||||||
def set_play_mode(self, mode: str) -> bool:
|
def set_play_mode(self, mode: str) -> bool:
|
||||||
"""Set play mode."""
|
"""Set play mode."""
|
||||||
try:
|
try:
|
||||||
if mode in ["continuous", "loop-playlist", "loop-one", "random"]:
|
if mode in [
|
||||||
|
"continuous",
|
||||||
|
"loop-playlist",
|
||||||
|
"loop-one",
|
||||||
|
"random",
|
||||||
|
"single",
|
||||||
|
]:
|
||||||
self.play_mode = mode
|
self.play_mode = mode
|
||||||
self._emit_player_state()
|
self._emit_player_state()
|
||||||
return True
|
return True
|
||||||
@@ -430,9 +490,10 @@ class MusicPlayerService:
|
|||||||
if not self.current_playlist_id:
|
if not self.current_playlist_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Ensure we have Flask app context
|
# Use stored app instance or current_app
|
||||||
if current_app:
|
app_to_use = self.app or current_app
|
||||||
with current_app.app_context():
|
if app_to_use:
|
||||||
|
with app_to_use.app_context():
|
||||||
playlist = Playlist.query.get(self.current_playlist_id)
|
playlist = Playlist.query.get(self.current_playlist_id)
|
||||||
if playlist and 0 <= self.current_track_index < len(
|
if playlist and 0 <= self.current_track_index < len(
|
||||||
playlist.playlist_sounds
|
playlist.playlist_sounds
|
||||||
@@ -593,6 +654,11 @@ class MusicPlayerService:
|
|||||||
if self.play_mode == "loop-one":
|
if self.play_mode == "loop-one":
|
||||||
logger.info("Restarting track for loop-one mode")
|
logger.info("Restarting track for loop-one mode")
|
||||||
self.play_track_at_index(self.current_track_index)
|
self.play_track_at_index(self.current_track_index)
|
||||||
|
elif self.play_mode == "single":
|
||||||
|
logger.info(
|
||||||
|
"Track ended in single mode - stopping playback"
|
||||||
|
)
|
||||||
|
self.stop()
|
||||||
elif self.play_mode in [
|
elif self.play_mode in [
|
||||||
"continuous",
|
"continuous",
|
||||||
"loop-playlist",
|
"loop-playlist",
|
||||||
@@ -610,11 +676,46 @@ class MusicPlayerService:
|
|||||||
elif self.is_playing and not old_playing:
|
elif self.is_playing and not old_playing:
|
||||||
self._track_ending_handled = False
|
self._track_ending_handled = False
|
||||||
|
|
||||||
|
# Update cumulative play time for continuous listening tracking
|
||||||
|
if self.is_playing and old_playing and self.current_time > 0:
|
||||||
|
# Calculate time elapsed since last update (but cap it to prevent huge jumps from seeking)
|
||||||
|
if self._last_position_update > 0:
|
||||||
|
time_diff = self.current_time - self._last_position_update
|
||||||
|
# Only add time if it's a reasonable progression (not a big jump from seeking)
|
||||||
|
if (
|
||||||
|
0 <= time_diff <= (self.sync_interval * 1000 * 2)
|
||||||
|
): # Max 2x sync interval
|
||||||
|
self._cumulative_play_time += time_diff
|
||||||
|
|
||||||
|
self._last_position_update = self.current_time
|
||||||
|
elif self.is_playing and not old_playing:
|
||||||
|
# Just started playing, initialize position tracking
|
||||||
|
self._last_position_update = (
|
||||||
|
self.current_time if self.current_time > 0 else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track play event when cumulative listening reaches 20% of track duration
|
||||||
|
if (
|
||||||
|
self.is_playing
|
||||||
|
and not self._track_play_tracked
|
||||||
|
and self.duration > 0
|
||||||
|
and self._cumulative_play_time
|
||||||
|
>= (self.duration * PLAY_COMPLETION_THRESHOLD)
|
||||||
|
):
|
||||||
|
current_track = self.get_current_track()
|
||||||
|
if current_track:
|
||||||
|
self._track_sound_play(current_track["id"])
|
||||||
|
self._track_play_tracked = True
|
||||||
|
logger.info(
|
||||||
|
f"Tracked play for '{current_track['title']}' after {self._cumulative_play_time}ms "
|
||||||
|
f"cumulative listening ({(self._cumulative_play_time/self.duration)*100:.1f}% of track)"
|
||||||
|
)
|
||||||
|
|
||||||
# Emit updates if state changed significantly or periodically
|
# Emit updates if state changed significantly or periodically
|
||||||
state_changed = (
|
state_changed = (
|
||||||
old_playing != self.is_playing
|
old_playing != self.is_playing
|
||||||
or abs(old_time - self.current_time)
|
or abs(old_time - self.current_time)
|
||||||
> 1000 # More than 1 second difference
|
> STATE_CHANGE_THRESHOLD_MS # More than 1 second difference
|
||||||
)
|
)
|
||||||
|
|
||||||
# Always emit if playing to keep frontend updated
|
# Always emit if playing to keep frontend updated
|
||||||
|
|||||||
Reference in New Issue
Block a user