Reorganize repository structure for better organization

- Move backend code to swingmusic/ folder
- Move client applications to root level (swingmusic-android, swingmusic-desktop, swingmusic-webclient)
- Remove intermediate backend/ and clients/ folders
- Update README with new folder structure and setup instructions
- Clean and organized repository layout
This commit is contained in:
Tomas Dvorak
2026-03-17 22:34:34 +01:00
parent 17e859dd2f
commit 4c04287800
206 changed files with 14 additions and 7 deletions
-88
View File
@@ -1,88 +0,0 @@
import sys
import pathlib
import argparse
import multiprocessing
from swingmusic import settings
from swingmusic.logger import setup_logger
from swingmusic import tools as swing_tools
from swingmusic.settings import AssetHandler, Metadata
from swingmusic.start_swingmusic import start_swingmusic
parser = argparse.ArgumentParser(
prog="swingmusic",
description="Awesome Music",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"-v", "--version", action="version", version=f"swingmusic v{Metadata.version}"
)
parser.add_argument("--host", default="0.0.0.0", help="Host to run the app on.")
parser.add_argument(
"--port", default=1970, help="HTTP port to run the app on.", type=int
)
parser.add_argument(
"--debug",
default=False,
action="store_true",
help="If swingmusic should start in debug mode",
)
parser.add_argument(
"--config",
default=settings.Paths.get_default_config_parent_dir(),
help="The directory to setup the config folder.",
type=pathlib.Path,
)
parser.add_argument("--client", help="Path to the Web UI folder.", type=pathlib.Path)
tools = parser.add_argument_group(title="Tools")
tools.add_argument("--password-reset", help="Reset the password.", action="store_true")
def run(*args, **kwargs):
"""
Swing Music entry point
"""
args = parser.parse_args()
args = vars(args)
config_parent = args["config"]
client_path = args["client"]
# INFO: Validate client path
if client_path is not None:
client_path = pathlib.Path(client_path).resolve()
if not client_path.exists():
print(
f"Client path {client_path} does not exist. Please provide a valid path"
)
sys.exit(1)
else:
# INFO: check if client path has index.html
if not (client_path / "index.html").exists():
print(
f"Client path {client_path} does not contain an index.html file. Please provide a valid path"
)
sys.exit(1)
settings.Paths(config_parent=config_parent, client_dir=client_path)
AssetHandler.copy_assets_dir()
AssetHandler.setup_default_client()
setup_logger(debug=args["debug"], app_dir=settings.Paths().config_dir)
# handle tools
if args["password_reset"]:
swing_tools.handle_password_reset(config_parent)
sys.exit(0)
# start swingmusic
start_swingmusic(host=args["host"], port=args["port"])
if __name__ == "__main__":
multiprocessing.freeze_support()
multiprocessing.set_start_method("spawn")
run()
-43
View File
@@ -1,43 +0,0 @@
"""
This module combines all API blueprints into a single Flask app instance.
"""
from swingmusic.api import (
album,
artist,
collections,
colors,
favorites,
folder,
imgserver,
playlist,
search,
settings,
lyrics,
plugins,
scrobble,
home,
getall,
auth,
stream,
backup_and_restore,
spotify,
spotify_settings,
enhanced_search,
universal_downloader,
music_catalog,
update_tracking,
audio_quality,
upload,
)
from swingmusic.api.plugins import lyrics as lyrics_plugin
from swingmusic.api.plugins import mixes as mixes_plugin
__all__ = [
"album", "artist", "collections", "colors", "favorites", "folder", "imgserver", "playlist", "search", "settings",
"lyrics", "plugins", "scrobble", "home", "getall", "auth", "stream", "backup_and_restore", "spotify", "spotify_settings", "enhanced_search", "universal_downloader", "music_catalog", "update_tracking", "audio_quality", "upload",
"lyrics_plugin",
"mixes_plugin"
]
-624
View File
@@ -1,624 +0,0 @@
"""
Advanced UX API Endpoints
This module provides REST API endpoints for enhanced user experience features,
including intelligent search suggestions, recommendations, and personalization.
"""
import logging
from datetime import datetime
from typing import Dict, List, Optional, Any
from flask import Blueprint, request, jsonify
from flask_login import login_required, current_user
from swingmusic.db import db
from swingmusic.services.advanced_ux_service import advanced_ux_service, SuggestionType, SearchContext
from swingmusic.utils.request import APIError, success_response, error_response
from swingmusic.utils.validators import validate_search_query, validate_context
logger = logging.getLogger(__name__)
advanced_ux_bp = Blueprint('advanced_ux', __name__, url_prefix='/api/ux')
def get_current_user_id() -> int:
"""Get current user ID from Flask-Login"""
return current_user.id if current_user.is_authenticated else None
@advanced_ux_bp.route('/search/suggestions', methods=['GET'])
@login_required
async def get_search_suggestions():
"""
Get intelligent search suggestions
Query Parameters:
- q: Search query
- context: Search context (general, discovery, download, playlist, offline, social)
- limit: Maximum suggestions to return (default: 10)
"""
try:
user_id = get_current_user_id()
query = request.args.get('q', '').strip()
context_str = request.args.get('context', 'general')
limit = min(request.args.get('limit', 10, type=int), 50)
# Validate inputs
validate_search_query(query)
context = validate_context(context_str)
# Get suggestions
suggestions = await advanced_ux_service.get_search_suggestions(user_id, query, context, limit)
# Format response
formatted_suggestions = []
for suggestion in suggestions:
formatted_suggestion = {
'id': suggestion.id,
'type': suggestion.type.value,
'title': suggestion.title,
'subtitle': suggestion.subtitle,
'image_url': suggestion.image_url,
'url': suggestion.url,
'metadata': suggestion.metadata,
'relevance_score': suggestion.relevance_score,
'context': suggestion.context.value
}
formatted_suggestions.append(formatted_suggestion)
return success_response({
'suggestions': formatted_suggestions,
'query': query,
'context': context.value,
'total_count': len(formatted_suggestions)
})
except Exception as e:
logger.error(f"Error getting search suggestions: {e}")
return error_response("Internal server error", 500)
@advanced_ux_bp.route('/discovery/recommendations', methods=['GET'])
@login_required
async def get_discovery_recommendations():
"""
Get personalized discovery recommendations
Query Parameters:
- type: Recommendation type (tracks, artists, albums, mixed)
- limit: Maximum recommendations to return (default: 20)
"""
try:
user_id = get_current_user_id()
recommendation_type = request.args.get('type', 'mixed')
limit = min(request.args.get('limit', 20, type=int), 100)
# Validate recommendation type
valid_types = ['tracks', 'artists', 'albums', 'mixed']
if recommendation_type not in valid_types:
return error_response(f"Invalid type. Must be one of: {valid_types}", 400)
# Get recommendations
recommendations = await advanced_ux_service.get_discovery_recommendations(user_id, recommendation_type, limit)
# Format response
formatted_recommendations = []
for recommendation in recommendations:
formatted_recommendation = {
'id': recommendation.id,
'type': recommendation.type.value,
'title': recommendation.title,
'subtitle': recommendation.subtitle,
'image_url': recommendation.image_url,
'url': recommendation.url,
'metadata': recommendation.metadata,
'relevance_score': recommendation.relevance_score
}
formatted_recommendations.append(formatted_recommendation)
return success_response({
'recommendations': formatted_recommendations,
'type': recommendation_type,
'total_count': len(formatted_recommendations)
})
except Exception as e:
logger.error(f"Error getting discovery recommendations: {e}")
return error_response("Internal server error", 500)
@advanced_ux_bp.route('/contextual/suggestions', methods=['GET'])
@login_required
async def get_contextual_suggestions():
"""
Get contextual suggestions based on current track
Query Parameters:
- track_id: Currently playing track ID
- context_type: Context type (similar, same_artist, same_genre, popular)
"""
try:
user_id = get_current_user_id()
track_id = request.args.get('track_id')
context_type = request.args.get('context_type', 'similar')
if not track_id:
return error_response("track_id is required", 400)
# Validate context type
valid_contexts = ['similar', 'same_artist', 'same_genre', 'popular']
if context_type not in valid_contexts:
return error_response(f"Invalid context_type. Must be one of: {valid_contexts}", 400)
# Get contextual suggestions
suggestions = await advanced_ux_service.get_contextual_suggestions(user_id, track_id, context_type)
# Format response
formatted_suggestions = []
for suggestion in suggestions:
formatted_suggestion = {
'id': suggestion.id,
'type': suggestion.type.value,
'title': suggestion.title,
'subtitle': suggestion.subtitle,
'image_url': suggestion.image_url,
'url': suggestion.url,
'metadata': suggestion.metadata,
'relevance_score': suggestion.relevance_score
}
formatted_suggestions.append(formatted_suggestion)
return success_response({
'suggestions': formatted_suggestions,
'track_id': track_id,
'context_type': context_type,
'total_count': len(formatted_suggestions)
})
except Exception as e:
logger.error(f"Error getting contextual suggestions: {e}")
return error_response("Internal server error", 500)
@advanced_ux_bp.route('/download/suggestions', methods=['GET'])
@login_required
async def get_download_suggestions():
"""
Get download-specific suggestions with universal downloader integration
Query Parameters:
- q: Search query (optional)
- limit: Maximum suggestions to return (default: 15)
"""
try:
user_id = get_current_user_id()
query = request.args.get('q', '').strip()
limit = min(request.args.get('limit', 15, type=int), 50)
# Get download suggestions
suggestions = await advanced_ux_service.get_download_suggestions(user_id, query, limit)
# Format response
formatted_suggestions = []
for suggestion in suggestions:
formatted_suggestion = {
'id': suggestion.id,
'type': suggestion.type.value,
'title': suggestion.title,
'subtitle': suggestion.subtitle,
'image_url': suggestion.image_url,
'url': suggestion.url,
'metadata': suggestion.metadata,
'relevance_score': suggestion.relevance_score
}
formatted_suggestions.append(formatted_suggestion)
return success_response({
'suggestions': formatted_suggestions,
'query': query,
'total_count': len(formatted_suggestions)
})
except Exception as e:
logger.error(f"Error getting download suggestions: {e}")
return error_response("Internal server error", 500)
@advanced_ux_bp.route('/search/filters', methods=['GET'])
@login_required
async def get_enhanced_search_filters():
"""
Get enhanced search filters with user personalization
"""
try:
user_id = get_current_user_id()
# Get enhanced filters
filters = await advanced_ux_service.get_enhanced_search_filters(user_id)
# Format response
formatted_filters = []
for filter_item in filters:
formatted_filter = {
'filter_id': filter_item.filter_id,
'name': filter_item.name,
'type': filter_item.type,
'options': filter_item.options,
'is_active': filter_item.is_active,
'is_multi_select': filter_item.is_multi_select
}
formatted_filters.append(formatted_filter)
return success_response({
'filters': formatted_filters,
'total_count': len(formatted_filters)
})
except Exception as e:
logger.error(f"Error getting enhanced search filters: {e}")
return error_response("Internal server error", 500)
@advanced_ux_bp.route('/behavior/track', methods=['POST'])
@login_required
async def track_user_behavior():
"""
Track user behavior for personalization
Request Body:
{
"type": "search|play|download|like",
"data": {
"query": "search query",
"track_id": "track_id",
"artist": "artist_name",
"timestamp": "ISO timestamp",
"context": "context information"
}
}
"""
try:
user_id = get_current_user_id()
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
interaction_type = data.get('type')
interaction_data = data.get('data', {})
# Validate interaction type
valid_types = ['search', 'play', 'download', 'like']
if interaction_type not in valid_types:
return error_response(f"Invalid type. Must be one of: {valid_types}", 400)
# Add user ID and timestamp to interaction data
interaction_data['user_id'] = user_id
if 'timestamp' not in interaction_data:
interaction_data['timestamp'] = datetime.utcnow().isoformat()
# Update user behavior
await advanced_ux_service.update_user_behavior(user_id, interaction_data)
return success_response({
'message': 'User behavior tracked successfully'
})
except Exception as e:
logger.error(f"Error tracking user behavior: {e}")
return error_response("Internal server error", 500)
@advanced_ux_bp.route('/behavior/profile', methods=['GET'])
@login_required
async def get_user_behavior_profile():
"""
Get user behavior profile for personalization insights
"""
try:
user_id = get_current_user_id()
# Get user behavior
behavior = await advanced_ux_service._get_user_behavior(user_id)
# Format response
profile = {
'user_id': behavior.user_id,
'favorite_genres': behavior.favorite_genres,
'favorite_artists': behavior.favorite_artists,
'listening_patterns': behavior.listening_patterns,
'download_preferences': behavior.download_preferences,
'interaction_patterns': behavior.interaction_patterns,
'last_updated': behavior.last_updated.isoformat(),
'search_history_count': len(behavior.search_history),
'recent_searches': behavior.search_history[-5:] if behavior.search_history else []
}
return success_response({
'profile': profile
})
except Exception as e:
logger.error(f"Error getting user behavior profile: {e}")
return error_response("Internal server error", 500)
@advanced_ux_bp.route('/trending/content', methods=['GET'])
@login_required
async def get_trending_content():
"""
Get trending content based on user preferences and global trends
Query Parameters:
- type: Content type (tracks, artists, albums, mixed)
- limit: Maximum items to return (default: 20)
- timeframe: Timeframe for trends (day, week, month, all)
"""
try:
user_id = get_current_user_id()
content_type = request.args.get('type', 'mixed')
limit = min(request.args.get('limit', 20, type=int), 100)
timeframe = request.args.get('timeframe', 'week')
# Validate inputs
valid_types = ['tracks', 'artists', 'albums', 'mixed']
if content_type not in valid_types:
return error_response(f"Invalid type. Must be one of: {valid_types}", 400)
valid_timeframes = ['day', 'week', 'month', 'all']
if timeframe not in valid_timeframes:
return error_response(f"Invalid timeframe. Must be one of: {valid_timeframes}", 400)
# Get trending content (this would integrate with analytics)
# For now, return discovery recommendations as trending
trending = await advanced_ux_service.get_discovery_recommendations(user_id, content_type, limit)
# Format response
formatted_trending = []
for item in trending:
formatted_item = {
'id': item.id,
'type': item.type.value,
'title': item.title,
'subtitle': item.subtitle,
'image_url': item.image_url,
'url': item.url,
'metadata': item.metadata,
'relevance_score': item.relevance_score,
'trend_score': item.relevance_score # Would calculate actual trend score
}
formatted_trending.append(formatted_item)
return success_response({
'trending': formatted_trending,
'type': content_type,
'timeframe': timeframe,
'total_count': len(formatted_trending)
})
except Exception as e:
logger.error(f"Error getting trending content: {e}")
return error_response("Internal server error", 500)
@advanced_ux_bp.route('/search/advanced', methods=['POST'])
@login_required
async def advanced_search():
"""
Perform advanced search with filters and personalization
Request Body:
{
"query": "search query",
"filters": {
"genre": ["rock", "pop"],
"mood": "energetic",
"year": ["2020", "2021"],
"quality": "high",
"duration": "medium"
},
"sort_by": "relevance|popularity|date",
"sort_order": "asc|desc",
"limit": 20,
"offset": 0
}
"""
try:
user_id = get_current_user_id()
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
query = data.get('query', '').strip()
filters = data.get('filters', {})
sort_by = data.get('sort_by', 'relevance')
sort_order = data.get('sort_order', 'desc')
limit = min(data.get('limit', 20, type=int), 100)
offset = max(data.get('offset', 0, type=int), 0)
# Validate inputs
validate_search_query(query)
valid_sort_by = ['relevance', 'popularity', 'date', 'title', 'artist']
if sort_by not in valid_sort_by:
return error_response(f"Invalid sort_by. Must be one of: {valid_sort_by}", 400)
valid_sort_order = ['asc', 'desc']
if sort_order not in valid_sort_order:
return error_response(f"Invalid sort_order. Must be one of: {valid_sort_order}", 400)
# Perform advanced search
# This would implement complex search logic with filters
# For now, use basic search suggestions as placeholder
context = SearchContext.GENERAL
if filters.get('quality') == 'lossless' or 'download' in query.lower():
context = SearchContext.DOWNLOAD
suggestions = await advanced_ux_service.get_search_suggestions(user_id, query, context, limit + offset)
# Apply filters (simplified)
filtered_suggestions = []
for suggestion in suggestions:
include = True
# Genre filter
if 'genre' in filters and filters['genre']:
if not any(genre.lower() in (suggestion.subtitle or '').lower() for genre in filters['genre']):
include = False
# Quality filter
if 'quality' in filters and filters['quality']:
if filters['quality'] not in (suggestion.subtitle or '').lower():
include = False
if include:
filtered_suggestions.append(suggestion)
# Sort results
if sort_by == 'relevance':
filtered_suggestions.sort(key=lambda x: x.relevance_score, reverse=(sort_order == 'desc'))
elif sort_by == 'title':
filtered_suggestions.sort(key=lambda x: x.title.lower(), reverse=(sort_order == 'desc'))
elif sort_by == 'artist':
filtered_suggestions.sort(key=lambda x: (x.subtitle or '').lower(), reverse=(sort_order == 'desc'))
# Apply pagination
paginated_suggestions = filtered_suggestions[offset:offset + limit]
# Format response
formatted_results = []
for suggestion in paginated_suggestions:
formatted_result = {
'id': suggestion.id,
'type': suggestion.type.value,
'title': suggestion.title,
'subtitle': suggestion.subtitle,
'image_url': suggestion.image_url,
'url': suggestion.url,
'metadata': suggestion.metadata,
'relevance_score': suggestion.relevance_score
}
formatted_results.append(formatted_result)
return success_response({
'results': formatted_results,
'query': query,
'filters': filters,
'sort_by': sort_by,
'sort_order': sort_order,
'total_count': len(filtered_suggestions),
'limit': limit,
'offset': offset
})
except Exception as e:
logger.error(f"Error performing advanced search: {e}")
return error_response("Internal server error", 500)
@advanced_ux_bp.route('/suggestions/quick', methods=['GET'])
@login_required
async def get_quick_suggestions():
"""
Get quick suggestions for UI components (autocomplete, etc.)
Query Parameters:
- type: Suggestion type (search, discovery, download)
- limit: Maximum suggestions (default: 5)
"""
try:
user_id = get_current_user_id()
suggestion_type = request.args.get('type', 'search')
limit = min(request.args.get('limit', 5, type=int), 20)
# Validate suggestion type
valid_types = ['search', 'discovery', 'download']
if suggestion_type not in valid_types:
return error_response(f"Invalid type. Must be one of: {valid_types}", 400)
suggestions = []
if suggestion_type == 'search':
# Get default search suggestions
suggestions = await advanced_ux_service._get_default_suggestions(user_id, SearchContext.GENERAL, limit)
elif suggestion_type == 'discovery':
# Get discovery recommendations
suggestions = await advanced_ux_service.get_discovery_recommendations(user_id, 'mixed', limit)
elif suggestion_type == 'download':
# Get download suggestions
suggestions = await advanced_ux_service.get_download_suggestions(user_id, '', limit)
# Format response for quick UI
formatted_suggestions = []
for suggestion in suggestions:
formatted_suggestion = {
'id': suggestion.id,
'type': suggestion.type.value,
'title': suggestion.title,
'subtitle': suggestion.subtitle,
'image_url': suggestion.image_url,
'url': suggestion.url
}
formatted_suggestions.append(formatted_suggestion)
return success_response({
'suggestions': formatted_suggestions,
'type': suggestion_type,
'total_count': len(formatted_suggestions)
})
except Exception as e:
logger.error(f"Error getting quick suggestions: {e}")
return error_response("Internal server error", 500)
@advanced_ux_bp.route('/personalization/preferences', methods=['GET', 'PUT'])
@login_required
async def personalization_preferences():
"""
Get or update personalization preferences
GET: Returns current preferences
PUT: Updates preferences
"""
try:
user_id = get_current_user_id()
if request.method == 'GET':
# Get user behavior profile
behavior = await advanced_ux_service._get_user_behavior(user_id)
preferences = {
'favorite_genres': behavior.favorite_genres,
'favorite_artists': behavior.favorite_artists,
'download_preferences': behavior.download_preferences,
'interaction_patterns': behavior.interaction_patterns
}
return success_response({
'preferences': preferences
})
elif request.method == 'PUT':
# Update preferences
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
# Update user behavior with preferences
interaction_data = {
'type': 'preferences_update',
'data': data
}
await advanced_ux_service.update_user_behavior(user_id, interaction_data)
return success_response({
'message': 'Preferences updated successfully'
})
except Exception as e:
logger.error(f"Error handling personalization preferences: {e}")
return error_response("Internal server error", 500)
-220
View File
@@ -1,220 +0,0 @@
"""
Contains all the album routes.
"""
import random
from dataclasses import asdict
from flask_openapi3 import Tag
from pydantic import BaseModel, Field
from flask_openapi3 import APIBlueprint
from swingmusic.api.apischemas import AlbumHashSchema, AlbumLimitSchema, ArtistHashSchema
from swingmusic.config import UserConfig
from swingmusic.db.userdata import SimilarArtistTable
from swingmusic.models.album import Album
from swingmusic.store.albums import AlbumStore
from swingmusic.store.artists import ArtistStore
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.hashing import create_hash
from swingmusic.lib.albumslib import sort_by_track_no
from swingmusic.serializers.album import serialize_for_card_many
from swingmusic.serializers.track import serialize_tracks
from swingmusic.utils.stats import get_track_group_stats
bp_tag = Tag(name="Album", description="Single album")
api = APIBlueprint("album", __name__, url_prefix="/album", abp_tags=[bp_tag])
class GetAlbumVersionsBody(BaseModel):
og_album_title: str = Field(
description="The original album title (album.og_title)",
)
albumhash: str = Field(
description="The album hash of the album to exclude from the results.",
)
class GetMoreFromArtistsBody(AlbumLimitSchema):
albumartists: list = Field(
description="The artist hashes to get more albums from",
)
base_title: str = Field(
description="The base title of the album to exclude from the results.",
)
class GetAlbumInfoBody(AlbumHashSchema, AlbumLimitSchema):
pass
# NOTE: Don't use "/" as it will cause redirects (failure)
@api.post("")
def get_album_tracks_and_info(body: GetAlbumInfoBody):
"""
Get album and tracks
Returns album info and tracks for the given albumhash.
"""
albumhash = body.albumhash
albumentry = AlbumStore.albummap.get(albumhash)
if albumentry is None:
return {"error": "Album not found"}, 404
album = albumentry.album
tracks = TrackStore.get_tracks_by_trackhashes(albumentry.trackhashes)
album.trackcount = len(tracks)
album.duration = sum(t.duration for t in tracks)
album.check_type(
tracks=tracks, singleTrackAsSingle=UserConfig().showAlbumsAsSingles
)
track_total = sum({int(t.extra.get("track_total", 1) or 1) for t in tracks})
avg_bitrate = sum(t.bitrate for t in tracks) // (len(tracks) or 1)
more_from_data = GetMoreFromArtistsBody(
albumartists=[a["artisthash"] for a in album.albumartists],
albumlimit=body.limit,
base_title=album.base_title,
)
other_versions_data = GetAlbumVersionsBody(
albumhash=albumhash,
og_album_title=album.og_title,
)
more_from_albums = get_more_from_artist(more_from_data)
other_versions = get_album_versions(other_versions_data)
return {
"stats": get_track_group_stats(tracks, is_album=True),
"info": {
**asdict(album),
"is_favorite": album.is_favorite,
},
"extra": {
# INFO: track_total is the sum of a set of track_total values from each track
# ASSUMPTIONS
# 1. All the tracks have the correct track totals
# 2. Tracks with the same track total are from the same disc
"track_total": track_total,
"avg_bitrate": avg_bitrate,
},
"copyright": tracks[0].copyright,
"tracks": serialize_tracks(tracks, remove_disc=False),
"more_from": more_from_albums,
"other_versions": other_versions,
}
@api.get("/<albumhash>/tracks")
def get_album_tracks(path: AlbumHashSchema):
"""
Get album tracks
Returns all the tracks in the given album, sorted by disc and track number.
NOTE: No album info is returned.
"""
tracks = AlbumStore.get_album_tracks(path.albumhash)
tracks = sort_by_track_no(tracks)
return serialize_tracks(tracks)
@api.post("/from-artist")
def get_more_from_artist(body: GetMoreFromArtistsBody):
"""
Get more from artist
Returns more albums from the given artist hashes.
"""
albumartists = body.albumartists
limit = body.limit
base_title = body.base_title
all_albums: dict[str, list[Album]] = {}
for artisthash in albumartists:
all_albums[artisthash] = AlbumStore.get_albums_by_artisthash(artisthash)
seen_hashes = set()
for artisthash, albums in all_albums.items():
albums = [
a
for a in albums
# INFO: filter out albums added to other artists
if a.albumhash not in seen_hashes and artisthash in a.artisthashes
# INFO: filter out albums with the same base title
and create_hash(a.base_title) != create_hash(base_title)
]
all_albums[artisthash] = serialize_for_card_many(
[a for a in albums if create_hash(a.base_title) != create_hash(base_title)][
:limit
]
)
# INFO: record albums added to other artists
seen_hashes.update([a.albumhash for a in albums][:limit])
return all_albums
@api.post("/other-versions")
def get_album_versions(body: GetAlbumVersionsBody):
"""
Get other versions
Returns other versions of the given album.
"""
albumhash = body.albumhash
album = AlbumStore.albummap.get(albumhash)
if not album:
return []
artisthash = album.album.artisthashes[0]
albums = AlbumStore.get_albums_by_artisthash(artisthash)
basetitle = album.basetitle
albums = [
a
for a in albums
if a.og_title != album.album.og_title
if a.base_title == basetitle
and artisthash in {a["artisthash"] for a in a.albumartists}
]
return serialize_for_card_many(albums)
class GetSimilarAlbumsQuery(ArtistHashSchema, AlbumLimitSchema):
pass
@api.get("/similar")
def get_similar_albums(query: GetSimilarAlbumsQuery):
"""
Get similar albums
Returns similar albums to the given album.
"""
artisthash = query.artisthash
limit = query.limit
similar_artists = SimilarArtistTable.get_by_hash(artisthash)
if similar_artists is None:
return []
artisthashes = similar_artists.get_artist_hash_set()
del similar_artists
artists = ArtistStore.get_artists_by_hashes(artisthashes)
albums = AlbumStore.get_albums_by_artisthashes([a.artisthash for a in artists])
sample = random.sample(albums, min(len(albums), limit))
return serialize_for_card_many(sample[:limit])
-111
View File
@@ -1,111 +0,0 @@
"""
Reusable Pydantic basic schemas for the API
"""
from pydantic import BaseModel, Field
from swingmusic.settings import Defaults
class AlbumHashSchema(BaseModel):
"""
Extending this class will give you a model with the `albumhash` field
"""
albumhash: str = Field(
description="The album hash",
json_schema_extra={
"example": Defaults.API_ALBUMHASH,
},
min_length=Defaults.HASH_LENGTH,
max_length=Defaults.HASH_LENGTH,
)
class ArtistHashSchema(BaseModel):
"""
Extending this class will give you a model with the `artisthash` field
"""
artisthash: str = Field(
description="The artist hash",
json_schema_extra={
"example": Defaults.API_ARTISTHASH,
},
min_length=Defaults.HASH_LENGTH,
max_length=Defaults.HASH_LENGTH,
)
class TrackHashSchema(BaseModel):
"""
Extending this class will give you a model with the `trackhash` field
"""
trackhash: str = Field(
description="The track hash",
json_schema_extra={
"example": Defaults.API_TRACKHASH,
},
min_length=Defaults.HASH_LENGTH,
max_length=Defaults.HASH_LENGTH,
)
class GenericLimitSchema(BaseModel):
"""
Extending this class will give you a model with the `limit` field
"""
limit: int = Field(
description="The number of items to return",
json_schema_extra={
"example": Defaults.API_CARD_LIMIT,
},
default=Defaults.API_CARD_LIMIT,
)
# INFO: The following 3 classes are duplicated to specify the type of items
class TrackLimitSchema(BaseModel):
"""
Extending this class will give you a model with the `limit` field
"""
limit: int = Field(
description="The number of tracks to return",
json_schema_extra={
"example": Defaults.API_CARD_LIMIT,
},
default=5,
alias="tracklimit",
)
class AlbumLimitSchema(BaseModel):
"""
Extending this class will give you a model with the `limit` field
"""
limit: int = Field(
description="The number of albums to return",
json_schema_extra={
"example": Defaults.API_CARD_LIMIT,
},
default=Defaults.API_CARD_LIMIT,
alias="albumlimit",
)
class ArtistLimitSchema(BaseModel):
"""
Extending this class will give you a model with the `limit` field
"""
limit: int = Field(
description="The number of artists to return",
json_schema_extra={
"example": Defaults.API_CARD_LIMIT,
},
default=Defaults.API_CARD_LIMIT,
alias="artistlimit",
)
-226
View File
@@ -1,226 +0,0 @@
"""
Contains all the artist(s) routes.
"""
import math
from pprint import pprint
import random
from datetime import datetime
from itertools import groupby
from typing import Any
from flask_openapi3 import APIBlueprint, Tag
from pydantic import Field
from swingmusic.api.apischemas import (
AlbumLimitSchema,
ArtistHashSchema,
ArtistLimitSchema,
TrackLimitSchema,
)
from swingmusic.config import UserConfig
from swingmusic.db.userdata import SimilarArtistTable
from swingmusic.lib.sortlib import sort_tracks
from swingmusic.serializers.album import serialize_for_card_many
from swingmusic.serializers.artist import serialize_for_cards, serialize_for_card
from swingmusic.serializers.track import serialize_track
from swingmusic.store.albums import AlbumStore
from swingmusic.store.artists import ArtistStore
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.stats import get_track_group_stats
bp_tag = Tag(name="Artist", description="Single artist")
api = APIBlueprint("artist", __name__, url_prefix="/artist", abp_tags=[bp_tag])
class GetArtistAlbumsQuery(AlbumLimitSchema):
all: bool = Field(
description="Whether to ignore albumlimit and return all albums", default=False
)
class GetArtistQuery(TrackLimitSchema, GetArtistAlbumsQuery):
albumlimit: int = Field(7, description="The number of albums to return")
@api.get("/<string:artisthash>")
def get_artist(path: ArtistHashSchema, query: GetArtistQuery):
"""
Get artist
Returns artist data, tracks and genres for the given artisthash.
"""
artisthash = path.artisthash
limit = query.limit
entry = ArtistStore.artistmap.get(artisthash)
if entry is None:
return {"error": "Artist not found"}, 404
tracks = TrackStore.get_tracks_by_trackhashes(entry.trackhashes)
tracks = sort_tracks(tracks, key="playcount", reverse=True)
tcount = len(tracks)
artist = entry.artist
if artist.albumcount == 0 and tcount < 10:
limit = tcount
try:
year = datetime.fromtimestamp(artist.date).year
except ValueError:
year = 0
genres = [*artist.genres]
decade = None
if year:
decade = math.floor(year / 10) * 10
decade = str(decade)[2:] + "s"
if decade:
genres.insert(0, {"name": decade, "genrehash": decade})
stats = get_track_group_stats(tracks)
duration = sum(t.duration for t in tracks) if tracks else 0
tracks = tracks[:limit] if (limit and limit != -1) else tracks
tracks = [
{
**serialize_track(t),
"help_text": (
"unplayed"
if t.playcount == 0
else f"{t.playcount} play{'' if t.playcount == 1 else 's'}"
),
}
for t in tracks
]
query.limit = query.albumlimit
albums = get_artist_albums(path, query)
return {
"artist": {
**serialize_for_card(artist),
"duration": duration,
"trackcount": tcount,
"albumcount": artist.albumcount,
"genres": genres,
"is_favorite": artist.is_favorite,
},
"tracks": tracks,
"albums": albums,
"stats": stats,
}
@api.get("/<artisthash>/albums")
def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery):
"""
Get artist albums.
"""
return_all = query.all
artisthash = path.artisthash
limit = query.limit
entry = ArtistStore.artistmap.get(artisthash)
if entry is None:
return {"error": "Artist not found"}, 404
albums = AlbumStore.get_albums_by_hashes(entry.albumhashes)
tracks = TrackStore.get_tracks_by_trackhashes(entry.trackhashes)
missing_albumhashes = {
t.albumhash for t in tracks if t.albumhash not in {a.albumhash for a in albums}
}
albums.extend(AlbumStore.get_albums_by_hashes(missing_albumhashes))
albumdict = {a.albumhash: a for a in albums}
config = UserConfig()
albumgroups = groupby(tracks, key=lambda t: t.albumhash)
for albumhash, tracks in albumgroups:
album = albumdict.get(albumhash)
if album:
album.check_type(list(tracks), config.showAlbumsAsSingles)
albums = [a for a in albumdict.values()]
all_albums = sorted(albums, key=lambda a: a.date, reverse=True)
res: dict[str, Any] = {
"albums": [],
"appearances": [],
"compilations": [],
"singles_and_eps": [],
}
for album in all_albums:
if album.type == "single" or album.type == "ep":
res["singles_and_eps"].append(album)
elif album.type == "compilation":
res["compilations"].append(album)
elif (
album.albumhash in missing_albumhashes
or artisthash not in album.artisthashes
):
res["appearances"].append(album)
else:
res["albums"].append(album)
if return_all:
limit = len(all_albums)
# loop through the res dict and serialize the albums
for key, value in res.items():
res[key] = serialize_for_card_many(value[:limit])
res["artistname"] = entry.artist.name
return res
@api.get("/<artisthash>/tracks")
def get_all_artist_tracks(path: ArtistHashSchema):
"""
Get artist tracks
Returns all artists by a given artist.
"""
tracks = ArtistStore.get_artist_tracks(path.artisthash)
tracks = sort_tracks(tracks, key="playcount", reverse=True)
tracks = [
{
**serialize_track(t),
"help_text": (
"unplayed"
if t.playcount == 0
else f"{t.playcount} play{'' if t.playcount == 1 else 's'}"
),
}
for t in tracks
]
return tracks
@api.get("/<artisthash>/similar")
def get_similar_artists(path: ArtistHashSchema, query: ArtistLimitSchema):
"""
Get similar artists.
"""
limit = query.limit
result = SimilarArtistTable.get_by_hash(path.artisthash)
if result is None:
return []
similar = ArtistStore.get_artists_by_hashes(result.get_artist_hash_set())
if len(similar) > limit:
similar = random.sample(similar, min(limit, len(similar)))
return serialize_for_cards(similar[:limit])
-805
View File
@@ -1,805 +0,0 @@
"""
Audio Quality Management API Endpoints
This module provides REST API endpoints for the advanced audio quality control system,
including adaptive streaming, audio enhancement, quality analysis, and user preferences.
"""
import logging
from typing import Dict, List, Optional, Any
from flask import Blueprint, request, jsonify, send_file
from flask_login import login_required, current_user
from swingmusic.db import db
from swingmusic.services.audio_quality_manager import (
audio_quality_manager, AudioQualitySettings, AudioFormat, QualityLevel,
SampleRate, BitDepth, SpatialAudioFormat
)
from swingmusic.utils.request import APIError, success_response, error_response
from swingmusic.utils.validators import validate_audio_file
logger = logging.getLogger(__name__)
audio_quality_bp = Blueprint('audio_quality', __name__, url_prefix='/api/audio-quality')
def get_current_user_id() -> int:
"""Get current user ID from Flask-Login"""
return current_user.id if current_user.is_authenticated else None
@audio_quality_bp.route('/settings', methods=['GET'])
@login_required
async def get_quality_settings():
"""
Get user's audio quality settings
"""
try:
settings = await audio_quality_manager._get_user_settings(get_current_user_id())
return success_response({
'settings': {
'streaming_quality': settings.streaming_quality.value,
'adaptive_quality': settings.adaptive_quality,
'network_aware_quality': settings.network_aware_quality,
'device_specific_quality': settings.device_specific_quality,
'download_format': settings.download_format.value,
'download_bitrate': settings.download_bitrate,
'download_sample_rate': settings.download_sample_rate.value,
'download_bit_depth': settings.download_bit_depth.value,
'enable_dolby_atmos': settings.enable_dolby_atmos,
'enable_360_audio': settings.enable_360_audio,
'spatial_audio_format': settings.spatial_audio_format.value,
'enable_adaptive_eq': settings.enable_adaptive_eq,
'enable_spatial_audio_processing': settings.enable_spatial_audio_processing,
'enable_loudness_normalization': settings.enable_loudness_normalization,
'target_loudness': settings.target_loudness,
'enable_crossfade': settings.enable_crossfade,
'crossfade_duration': settings.crossfade_duration,
'enable_gapless_playback': settings.enable_gapless_playback,
'enable_replaygain': settings.enable_replaygain,
'prioritize_fidelity': settings.prioritize_fidelity,
'prioritize_file_size': settings.prioritize_file_size,
'prioritize_compatibility': settings.prioritize_compatibility,
'custom_ffmpeg_params': settings.custom_ffmpeg_params or {},
'enable_experimental_codecs': settings.enable_experimental_codecs,
'cache_transcoded_files': settings.cache_transcoded_files
}
})
except Exception as e:
logger.error(f"Error getting quality settings: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/settings', methods=['POST'])
@login_required
async def update_quality_settings():
"""
Update user's audio quality settings
Request Body:
{
"streaming_quality": "lossless|high|medium|low|data_saver",
"adaptive_quality": true,
"network_aware_quality": true,
"device_specific_quality": true,
"download_format": "flac|mp3_320|mp3_256|aac_256|...",
"download_bitrate": 320,
"download_sample_rate": "44.1kHz|48kHz|96kHz|192kHz",
"download_bit_depth": "16bit|24bit|32bit",
"enable_dolby_atmos": false,
"enable_360_audio": false,
"spatial_audio_format": "stereo|binaural|dolby_atmos|...",
"enable_adaptive_eq": true,
"enable_spatial_audio_processing": false,
"enable_loudness_normalization": true,
"target_loudness": -14.0,
"enable_crossfade": false,
"crossfade_duration": 2.0,
"enable_gapless_playback": true,
"enable_replaygain": true,
"prioritize_fidelity": true,
"prioritize_file_size": false,
"prioritize_compatibility": false,
"custom_ffmpeg_params": {},
"enable_experimental_codecs": false,
"cache_transcoded_files": true
}
"""
try:
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
# Validate and convert settings
settings = AudioQualitySettings()
# Streaming quality
if 'streaming_quality' in data:
try:
settings.streaming_quality = QualityLevel(data['streaming_quality'])
except ValueError:
return error_response("Invalid streaming quality", 400)
# Boolean settings
for key in ['adaptive_quality', 'network_aware_quality', 'device_specific_quality',
'enable_dolby_atmos', 'enable_360_audio', 'enable_adaptive_eq',
'enable_spatial_audio_processing', 'enable_loudness_normalization',
'enable_crossfade', 'enable_gapless_playback', 'enable_replaygain',
'prioritize_fidelity', 'prioritize_file_size', 'prioritize_compatibility',
'enable_experimental_codecs', 'cache_transcoded_files']:
if key in data:
setattr(settings, key, bool(data[key]))
# Download format
if 'download_format' in data:
try:
settings.download_format = AudioFormat(data['download_format'])
except ValueError:
return error_response("Invalid download format", 400)
# Numeric settings
if 'download_bitrate' in data:
bitrate = data['download_bitrate']
if bitrate is not None and (not isinstance(bitrate, int) or bitrate < 0 or bitrate > 1000):
return error_response("Invalid download bitrate", 400)
settings.download_bitrate = bitrate
if 'target_loudness' in data:
loudness = data['target_loudness']
if not isinstance(loudness, (int, float)) or loudness < -70 or loudness > 0:
return error_response("Invalid target loudness", 400)
settings.target_loudness = float(loudness)
if 'crossfade_duration' in data:
duration = data['crossfade_duration']
if not isinstance(duration, (int, float)) or duration < 0 or duration > 10:
return error_response("Invalid crossfade duration", 400)
settings.crossfade_duration = float(duration)
# Enum settings
if 'download_sample_rate' in data:
try:
settings.download_sample_rate = SampleRate(data['download_sample_rate'])
except ValueError:
return error_response("Invalid download sample rate", 400)
if 'download_bit_depth' in data:
try:
settings.download_bit_depth = BitDepth(data['download_bit_depth'])
except ValueError:
return error_response("Invalid download bit depth", 400)
if 'spatial_audio_format' in data:
try:
settings.spatial_audio_format = SpatialAudioFormat(data['spatial_audio_format'])
except ValueError:
return error_response("Invalid spatial audio format", 400)
# Custom FFmpeg params
if 'custom_ffmpeg_params' in data:
if not isinstance(data['custom_ffmpeg_params'], dict):
return error_response("Custom FFmpeg params must be an object", 400)
settings.custom_ffmpeg_params = data['custom_ffmpeg_params']
# Update settings
success = await audio_quality_manager.update_user_settings(get_current_user_id(), settings)
if success:
return success_response({
'message': 'Audio quality settings updated successfully',
'settings': data
})
else:
return error_response("Failed to update settings", 500)
except Exception as e:
logger.error(f"Error updating quality settings: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/optimal-streaming', methods=['GET'])
@login_required
async def get_optimal_streaming_quality():
"""
Get optimal streaming quality based on current conditions
Query Parameters:
- context: JSON string with additional context (battery, network, etc.)
"""
try:
context_str = request.args.get('context', '{}')
try:
context = json.loads(context_str) if context_str else {}
except json.JSONDecodeError:
context = {}
optimal = await audio_quality_manager.get_optimal_streaming_quality(
get_current_user_id(), context
)
return success_response({
'optimal_quality': optimal,
'context': context
})
except Exception as e:
logger.error(f"Error getting optimal streaming quality: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/transcode', methods=['POST'])
@login_required
async def transcode_for_streaming():
"""
Transcode audio file for optimal streaming
Request Body:
{
"file_path": "/path/to/audio/file",
"context": {}
}
"""
try:
data = request.get_json()
if not data or not data.get('file_path'):
return error_response("file_path is required", 400)
file_path = data['file_path']
context = data.get('context', {})
# Validate file
if not validate_audio_file(file_path):
return error_response("Invalid audio file", 400)
# Transcode for streaming
transcoded_path = await audio_quality_manager.transcode_for_streaming(
file_path, get_current_user_id(), context
)
if transcoded_path:
return success_response({
'transcoded_path': transcoded_path,
'original_path': file_path
})
else:
return error_response("Transcoding failed", 500)
except Exception as e:
logger.error(f"Error transcoding for streaming: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/analyze', methods=['POST'])
@login_required
async def analyze_audio_file():
"""
Analyze audio file for quality metrics
Request Body:
{
"file_path": "/path/to/audio/file"
}
"""
try:
data = request.get_json()
if not data or not data.get('file_path'):
return error_response("file_path is required", 400)
file_path = data['file_path']
# Validate file
if not validate_audio_file(file_path):
return error_response("Invalid audio file", 400)
# Analyze file
analysis = await audio_quality_manager.analyze_audio_file(file_path)
return success_response({
'analysis': {
'file_path': analysis.file_path,
'format': analysis.format,
'duration': analysis.duration,
'sample_rate': analysis.sample_rate,
'bit_depth': analysis.bit_depth,
'bitrate': analysis.bitrate,
'channels': analysis.channels,
'codec': analysis.codec,
'dynamic_range': analysis.dynamic_range,
'peak_level': analysis.peak_level,
'rms_level': analysis.rms_level,
'loudness': analysis.loudness,
'frequency_response': analysis.frequency_response,
'spectral_centroid': analysis.spectral_centroid,
'spectral_rolloff': analysis.spectral_rolloff,
'signal_to_noise_ratio': analysis.signal_to_noise_ratio,
'total_harmonic_distortion': analysis.total_harmonic_distortion,
'detected_genre': analysis.detected_genre,
'acoustic_features': analysis.acoustic_features or {}
}
})
except Exception as e:
logger.error(f"Error analyzing audio file: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/compare', methods=['POST'])
@login_required
async def compare_quality_formats():
"""
Compare quality across different audio formats
Request Body:
{
"file_path": "/path/to/audio/file",
"formats": ["flac", "mp3_320", "mp3_256", "aac_256"]
}
"""
try:
data = request.get_json()
if not data or not data.get('file_path'):
return error_response("file_path is required", 400)
file_path = data['file_path']
formats = data.get('formats', ['flac', 'mp3_320'])
# Validate file
if not validate_audio_file(file_path):
return error_response("Invalid audio file", 400)
# Convert format strings to enum
format_enums = []
for format_str in formats:
try:
format_enums.append(AudioFormat(format_str))
except ValueError:
return error_response(f"Invalid format: {format_str}", 400)
# Compare formats
comparison = await audio_quality_manager.compare_quality_formats(
file_path, format_enums
)
return success_response({
'comparison': {
'original_file': comparison.original_file,
'formats': comparison.formats,
'size_difference': comparison.size_difference,
'quality_score': comparison.quality_score,
'transparency_score': comparison.transparency_score,
'recommended_format': comparison.recommended_format,
'recommended_reason': comparison.recommended_reason
}
})
except Exception as e:
logger.error(f"Error comparing quality formats: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/enhance', methods=['POST'])
@login_required
async def enhance_audio():
"""
Apply audio enhancements to a file
Request Body:
{
"input_path": "/path/to/input/file",
"output_path": "/path/to/output/file",
"enhancements": {
"enable_loudness_normalization": true,
"target_loudness": -14.0,
"enable_adaptive_eq": true,
"enable_spatial_audio_processing": false,
"spatial_audio_format": "stereo"
}
}
"""
try:
data = request.get_json()
if not data or not data.get('input_path') or not data.get('output_path'):
return error_response("input_path and output_path are required", 400)
input_path = data['input_path']
output_path = data['output_path']
enhancements = data.get('enhancements', {})
# Validate files
if not validate_audio_file(input_path):
return error_response("Invalid input audio file", 400)
# Build settings
settings = AudioQualitySettings()
# Apply enhancement settings
for key, value in enhancements.items():
if hasattr(settings, key):
setattr(settings, key, value)
# Apply enhancements
success = await audio_quality_manager.enhancement_service.apply_enhancements(
input_path, output_path, settings
)
if success:
return success_response({
'message': 'Audio enhancements applied successfully',
'input_path': input_path,
'output_path': output_path,
'enhancements': enhancements
})
else:
return error_response("Audio enhancement failed", 500)
except Exception as e:
logger.error(f"Error enhancing audio: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/formats', methods=['GET'])
@login_required
async def get_supported_formats():
"""
Get list of supported audio formats and their capabilities
"""
try:
formats = {
'lossless': {
'flac': {
'name': 'FLAC',
'description': 'Free Lossless Audio Codec',
'extension': '.flac',
'max_bitrate': None,
'sample_rates': ['44.1kHz', '48kHz', '96kHz', '192kHz'],
'bit_depths': ['16bit', '24bit'],
'channels': ['mono', 'stereo', '5.1', '7.1'],
'compression': 'lossless',
'compatibility': 'high'
},
'alac': {
'name': 'ALAC',
'description': 'Apple Lossless Audio Codec',
'extension': '.m4a',
'max_bitrate': None,
'sample_rates': ['44.1kHz', '48kHz', '96kHz'],
'bit_depths': ['16bit', '24bit'],
'channels': ['mono', 'stereo', '5.1'],
'compression': 'lossless',
'compatibility': 'medium' # Apple ecosystem
},
'wav': {
'name': 'WAV',
'description': 'Waveform Audio File Format',
'extension': '.wav',
'max_bitrate': None,
'sample_rates': ['44.1kHz', '48kHz', '96kHz', '192kHz'],
'bit_depths': ['16bit', '24bit', '32bit'],
'channels': ['mono', 'stereo', '5.1', '7.1'],
'compression': 'none',
'compatibility': 'high'
}
},
'lossy': {
'mp3_320': {
'name': 'MP3 320kbps',
'description': 'MPEG Audio Layer 3 at 320kbps',
'extension': '.mp3',
'max_bitrate': 320,
'sample_rates': ['44.1kHz', '48kHz'],
'bit_depths': ['16bit'],
'channels': ['stereo'],
'compression': 'lossy',
'compatibility': 'very_high'
},
'mp3_256': {
'name': 'MP3 256kbps',
'description': 'MPEG Audio Layer 3 at 256kbps',
'extension': '.mp3',
'max_bitrate': 256,
'sample_rates': ['44.1kHz', '48kHz'],
'bit_depths': ['16bit'],
'channels': ['stereo'],
'compression': 'lossy',
'compatibility': 'very_high'
},
'mp3_192': {
'name': 'MP3 192kbps',
'description': 'MPEG Audio Layer 3 at 192kbps',
'extension': '.mp3',
'max_bitrate': 192,
'sample_rates': ['44.1kHz', '48kHz'],
'bit_depths': ['16bit'],
'channels': ['stereo'],
'compression': 'lossy',
'compatibility': 'very_high'
},
'mp3_128': {
'name': 'MP3 128kbps',
'description': 'MPEG Audio Layer 3 at 128kbps',
'extension': '.mp3',
'max_bitrate': 128,
'sample_rates': ['44.1kHz', '48kHz'],
'bit_depths': ['16bit'],
'channels': ['stereo'],
'compression': 'lossy',
'compatibility': 'very_high'
},
'aac_256': {
'name': 'AAC 256kbps',
'description': 'Advanced Audio Coding at 256kbps',
'extension': '.m4a',
'max_bitrate': 256,
'sample_rates': ['44.1kHz', '48kHz'],
'bit_depths': ['16bit'],
'channels': ['stereo'],
'compression': 'lossy',
'compatibility': 'high'
},
'aac_192': {
'name': 'AAC 192kbps',
'description': 'Advanced Audio Coding at 192kbps',
'extension': '.m4a',
'max_bitrate': 192,
'sample_rates': ['44.1kHz', '48kHz'],
'bit_depths': ['16bit'],
'channels': ['stereo'],
'compression': 'lossy',
'compatibility': 'high'
},
'aac_128': {
'name': 'AAC 128kbps',
'description': 'Advanced Audio Coding at 128kbps',
'extension': '.m4a',
'max_bitrate': 128,
'sample_rates': ['44.1kHz', '48kHz'],
'bit_depths': ['16bit'],
'channels': ['stereo'],
'compression': 'lossy',
'compatibility': 'high'
},
'ogg_vorbis': {
'name': 'Ogg Vorbis',
'description': 'Ogg Vorbis compressed audio',
'extension': '.ogg',
'max_bitrate': 500,
'sample_rates': ['44.1kHz', '48kHz', '96kHz'],
'bit_depths': ['16bit', '24bit'],
'channels': ['mono', 'stereo', '5.1'],
'compression': 'lossy',
'compatibility': 'medium'
},
'ogg_opus': {
'name': 'Opus',
'description': 'Opus audio codec',
'extension': '.opus',
'max_bitrate': 510,
'sample_rates': ['48kHz'],
'bit_depths': ['16bit'],
'channels': ['mono', 'stereo'],
'compression': 'lossy',
'compatibility': 'medium'
}
}
}
return success_response({'formats': formats})
except Exception as e:
logger.error(f"Error getting supported formats: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/quality-presets', methods=['GET'])
@login_required
async def get_quality_presets():
"""
Get predefined quality presets for different use cases
"""
try:
presets = {
'audiophile': {
'name': 'Audiophile',
'description': 'Maximum quality for critical listening',
'settings': {
'streaming_quality': 'lossless',
'download_format': 'flac',
'download_sample_rate': '96kHz',
'download_bit_depth': '24bit',
'enable_loudness_normalization': false,
'prioritize_fidelity': true
}
},
'portable': {
'name': 'Portable',
'description': 'Balanced quality for mobile devices',
'settings': {
'streaming_quality': 'high',
'download_format': 'aac_256',
'adaptive_quality': true,
'network_aware_quality': true,
'device_specific_quality': true,
'enable_loudness_normalization': true,
'prioritize_compatibility': true
}
},
'data_saver': {
'name': 'Data Saver',
'description': 'Minimal bandwidth usage',
'settings': {
'streaming_quality': 'data_saver',
'download_format': 'mp3_128',
'adaptive_quality': true,
'network_aware_quality': true,
'enable_loudness_normalization': true,
'prioritize_file_size': true
}
},
'studio': {
'name': 'Studio',
'description': 'Professional quality for production',
'settings': {
'streaming_quality': 'lossless',
'download_format': 'wav',
'download_sample_rate': '192kHz',
'download_bit_depth': '32bit',
'enable_loudness_normalization': false,
'prioritize_fidelity': true,
'cache_transcoded_files': false
}
},
'gaming': {
'name': 'Gaming',
'description': 'Low latency with good quality',
'settings': {
'streaming_quality': 'medium',
'download_format': 'mp3_256',
'enable_crossfade': false,
'enable_gapless_playback': true,
'cache_transcoded_files': true
}
},
'podcast': {
'name': 'Podcast',
'description': 'Optimized for speech content',
'settings': {
'streaming_quality': 'medium',
'download_format': 'aac_128',
'enable_loudness_normalization': true,
'target_loudness': -16.0,
'enable_adaptive_eq': true,
'prioritize_file_size': true
}
}
}
return success_response({'presets': presets})
except Exception as e:
logger.error(f"Error getting quality presets: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/apply-preset', methods=['POST'])
@login_required
async def apply_quality_preset():
"""
Apply a quality preset to user settings
Request Body:
{
"preset_name": "audiophile|portable|data_saver|studio|gaming|podcast"
}
"""
try:
data = request.get_json()
if not data or not data.get('preset_name'):
return error_response("preset_name is required", 400)
preset_name = data['preset_name']
# Get presets
presets_response = await get_quality_presets()
presets = presets_response[1].get_json()['presets']
if preset_name not in presets:
return error_response(f"Unknown preset: {preset_name}", 400)
preset = presets[preset_name]
# Apply preset settings
success = await audio_quality_manager.update_user_settings(
get_current_user_id(),
AudioQualitySettings(**preset['settings'])
)
if success:
return success_response({
'message': f'Applied {preset["name"]} preset successfully',
'preset': preset,
'settings': preset['settings']
})
else:
return error_response("Failed to apply preset", 500)
except Exception as e:
logger.error(f"Error applying quality preset: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/cache/clear', methods=['POST'])
@login_required
async def clear_quality_cache():
"""
Clear audio quality analysis and transcoding cache
"""
try:
audio_quality_manager.clear_cache()
return success_response({
'message': 'Audio quality cache cleared successfully'
})
except Exception as e:
logger.error(f"Error clearing quality cache: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/network/status', methods=['GET'])
@login_required
async def get_network_status():
"""
Get current network status for quality optimization
"""
try:
from swingmusic.services.audio_quality_manager import NetworkMonitor
network_monitor = NetworkMonitor()
status = await network_monitor.get_network_status()
return success_response({
'network_status': status
})
except Exception as e:
logger.error(f"Error getting network status: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/device/info', methods=['GET'])
@login_required
async def get_device_info():
"""
Get device information for quality optimization
"""
try:
from swingmusic.services.audio_quality_manager import DeviceDetector
device_detector = DeviceDetector()
device_info = device_detector.get_device_info()
return success_response({
'device_info': device_info
})
except Exception as e:
logger.error(f"Error getting device info: {e}")
return error_response("Internal server error", 500)
# Error handlers
@audio_quality_bp.errorhandler(404)
def not_found(error):
return error_response("Endpoint not found", 404)
@audio_quality_bp.errorhandler(500)
def internal_error(error):
return error_response("Internal server error", 500)
-382
View File
@@ -1,382 +0,0 @@
import json
from functools import wraps
import sqlite3
from flask import current_app, jsonify
from flask_jwt_extended import (
create_access_token,
create_refresh_token,
current_user,
get_jwt_identity,
jwt_required,
set_access_cookies,
)
from pydantic import BaseModel, Field
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from swingmusic.db.userdata import UserTable
from swingmusic.store.homepage import HomepageStore
from swingmusic.utils.auth import check_password, hash_password
from swingmusic.config import UserConfig
bp_tag = Tag(name="Auth", description="Authentication stuff")
api = APIBlueprint("auth", __name__, url_prefix="/auth", abp_tags=[bp_tag])
def admin_required():
"""
Decorator to require admin role
"""
def wrapper(fn):
@wraps(fn)
def decorator(*args, **kwargs):
if "admin" not in current_user["roles"]:
return {"msg": "Only admins can do that!"}, 403
return fn(*args, **kwargs)
return decorator
return wrapper
def create_new_token(user: dict):
"""
Create a new token response
"""
access_token = create_access_token(identity=user)
max_age: int = current_app.config.get("JWT_ACCESS_TOKEN_EXPIRES")
return {
"msg": f"Logged in as {user['username']}",
"accesstoken": access_token,
"refreshtoken": create_refresh_token(identity=user),
"maxage": max_age,
}
class LoginBody(BaseModel):
username: str = Field(description="The username", example="user0")
password: str = Field(description="The password", example="password0")
@api.post("/login")
def login(body: LoginBody):
"""
Authenticate using username and password
"""
user = UserTable.get_by_username(body.username)
if user is None:
return {"msg": "User not found"}, 404
password_ok = check_password(body.password, user.password)
if not password_ok:
return {"msg": "Hehe! invalid password"}, 401
res = create_new_token(user.todict())
token = res["accesstoken"]
age = res["maxage"]
res = jsonify(res)
set_access_cookies(res, token, max_age=age)
return res
pair_token = dict()
@api.get("/getpaircode")
def get_pair():
"""
Get a new pair code to log in to thee Swing Music mobile app
"""
# INFO: if user is already logged in, create a new pair code
token = create_new_token(get_jwt_identity())
key = token["accesstoken"][-6:]
global pair_token
pair_token = {
key: token,
}
return {"code": key}
class PairDeviceQuery(BaseModel):
code: str = Field("", description="The code")
@api.get("/pair")
@jwt_required(optional=True)
def pair_with_code(query: PairDeviceQuery):
"""
Get an access token by sending a pair code. NOTE: A code can only be used once!
"""
global pair_token
token = pair_token.get(query.code, None)
if token:
pair_token = {}
return token
return {"msg": "Invalid code"}, 400
@api.post("/refresh")
@jwt_required(refresh=True)
def refresh():
"""
Refresh an access token by sending a refresh token in the Authorization header
>>> Headers:
>>> Authorization: Bearer <refresh_token>
Won't work with cookies!!!
"""
user = get_jwt_identity()
return create_new_token(user)
class UpdateProfileBody(BaseModel):
id: int = Field(0, description="The user id")
email: str = Field("", description="The email")
username: str = Field("", description="The username", example="user0")
password: str = Field("", description="The password", example="password0")
roles: list[str] = Field(None, description="The roles")
@api.put("/profile/update")
def update_profile(body: UpdateProfileBody):
"""
Update user profile
"""
user = {
"id": body.id,
"username": body.username,
"password": body.password,
"roles": body.roles,
}
# prevent updating guest
if current_user["username"] == "guest" or user["username"] == "guest":
return {"msg": "Cannot update guest user"}, 400
# if not id, update self
if not user["id"]:
user["id"] = current_user["id"]
if body.roles is not None:
# only admins can update roles
if "admin" not in current_user["roles"]:
return {"msg": "Only admins can update roles"}, 403
all_users = list(UserTable.get_all())
if "admin" not in body.roles:
# check if we're removing the last admin
admins = [user for user in all_users if "admin" in user.roles]
if len(admins) == 1 and admins[0].id == user["id"]:
return {"msg": "Cannot remove the only admin"}, 400
# guest roles cannot be updated
_user = [u for u in all_users if u.id == user["id"]][0]
if "guest" in _user.roles:
return {"msg": "Cannot update guest user"}, 400
if user["password"]:
user["password"] = hash_password(user["password"])
# remove empty values
clean_user = {k: v for k, v in user.items() if v}
# finally, convert roles to json string
# doing it here to prevent deleting roles from clean user
# when body.roles is an empty list
if body.roles is not None:
clean_user["roles"] = body.roles
try:
# return authdb.update_user(clean_user)
UserTable.update_one(clean_user)
return UserTable.get_by_id(user["id"]).todict()
except sqlite3.IntegrityError:
return {"msg": "Username already exists"}, 400
@api.post("/profile/create")
@admin_required()
def create_user(body: UpdateProfileBody):
"""
Create a new user
"""
if not body.username or not body.password:
return {"msg": "Username and password are required"}, 400
user = {
"username": body.username,
"password": hash_password(body.password),
"roles": [],
}
# check if user already exists
if UserTable.get_by_username(user["username"]):
return {"msg": "Username already exists"}, 400
UserTable.insert_one(user)
user = UserTable.get_by_username(user["username"])
if user:
HomepageStore.entries["recently_played"].add_new_user(user.id)
return user.todict()
return {
"msg": "Failed to create user",
}, 500
@api.post("/profile/guest/create")
@admin_required()
def create_guest_user():
"""
Create a guest user
"""
# check if guest user already exists
guest_user = UserTable.get_by_username("guest")
if guest_user:
return {
"msg": "Guest user already exists",
}, 400
UserTable.insert_guest_user()
user = UserTable.get_by_username("guest")
if user:
HomepageStore.entries["recently_played"].add_new_user(user.id)
return {
"msg": "Guest user created",
}
return {
"msg": "Failed to create guest user",
}, 500
class DeleteUseBody(BaseModel):
username: str = Field("", description="The username")
@api.delete("/profile/delete")
@admin_required()
def delete_user(body: DeleteUseBody):
"""
Delete a user by username
"""
# prevent admin from deleting themselves
if body.username == current_user["username"]:
return {"msg": "Sorry! you cannot delete yourselfu"}, 400
# prevent deleting the only admin
users = UserTable.get_all()
admins = [user for user in users if "admin" in user.roles]
if len(admins) == 1 and admins[0].username == body.username:
return {"msg": "Cannot delete the only admin"}, 400
UserTable.remove_by_username(body.username)
return {"msg": f"User {body.username} deleted"}
@api.get("/logout")
def logout():
"""
Log out and clear the access token cookie
"""
res = jsonify({"msg": "Logged out"})
res.delete_cookie("access_token_cookie")
return res
class GetAllUsersQuery(BaseModel):
simplified: bool = Field(
False, description="Whether to return simplified user data"
)
@api.get("/users")
@jwt_required(optional=True)
def get_all_users(query: GetAllUsersQuery):
"""
Get all users (if you're an admin, you will also receive accounts settings)
"""
config = UserConfig()
settings = {
"enableGuest": False,
"usersOnLogin": config.usersOnLogin,
}
res = {
"settings": {},
"users": [],
}
users = [u for u in UserTable.get_all()]
is_admin = current_user and "admin" in current_user["roles"]
settings["enableGuest"] = [
user for user in users if user.username == "guest"
].__len__() > 0
# if user is admin, also return settings
if is_admin:
res = {
"settings": settings,
}
# if is normal user, return empty response
elif current_user:
return res
# if not logged in and showing users on login is disabled, return empty response
elif (
not current_user
and not settings["usersOnLogin"]
and not settings["enableGuest"]
):
return res
# remove guest user
# if not settings["enableGuest"]:
# users = [user for user in users if user.username != "guest"]
if not settings["usersOnLogin"]:
users = [user for user in users if user.username == "guest"]
# reverse list to show latest users first
users = reversed(users)
# bring admins to the front
users = sorted(users, key=lambda x: "admin" in x.roles, reverse=True)
# bring current user to index 0
if current_user:
users = sorted(
users,
key=lambda x: x.username == current_user["username"],
reverse=True,
)
if query.simplified:
res["users"] = [user.todict_simplified() for user in users]
else:
res["users"] = [user.todict() for user in users]
return res
@api.get("/user")
def get_logged_in_user():
"""
Get logged in user
"""
return dict(current_user)
-314
View File
@@ -1,314 +0,0 @@
from dataclasses import asdict
import json
import os
from pathlib import Path
from pprint import pprint
import shutil
from time import time
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
import sqlalchemy.exc
from swingmusic.api.auth import admin_required
from swingmusic.db.userdata import FavoritesTable, PlaylistTable, ScrobbleTable, CollectionTable
from swingmusic.lib.index import index_everything
from swingmusic.settings import Paths
from datetime import datetime
from swingmusic.utils.dates import timestamp_to_time_passed
from pydantic import BaseModel, Field
from typing import Optional
bp_tag = Tag(name="Backup and Restore", description="Backup and Restore")
api = APIBlueprint(
"backup_and_restore", __name__, url_prefix="/backup", abp_tags=[bp_tag]
)
@api.post("/create")
@admin_required()
def backup():
"""
Create a backup file of your favorites, playlists, scrobble data, and collections.
"""
backup_name = f"backup.{int(time())}"
backup_dir = Path("~").expanduser() / "swingmusic.backup" / backup_name
backup_dir.mkdir(parents=True, exist_ok=True)
backup_file = backup_dir / "data.json"
img_folder = backup_dir / "images"
img_folder_created = img_folder.exists()
favorites = FavoritesTable.get_all()
favorites = [asdict(entry) for entry in favorites]
scrobbles = ScrobbleTable.get_all(start=0)
scrobbles = [asdict(entry) for entry in scrobbles]
for scrobble in scrobbles:
del scrobble["id"]
# SECTION: Playlists
playlists = PlaylistTable.get_all()
playlist_dicts = []
for entry in playlists:
playlist = asdict(entry)
for key in [
"id",
"_last_updated",
"has_image",
"images",
"duration",
"count",
"pinned",
"thumb",
]:
del playlist[key]
playlist_dicts.append(playlist)
# copy images
img_path = Path(Paths().playlist_img_path) / str(playlist["image"])
if img_path.exists():
if not img_folder_created:
img_folder.mkdir(parents=True)
img_folder_created = True
shutil.copy(img_path, img_folder / playlist["image"])
# !SECTION
# SECTION: Collections
collections_list = list(CollectionTable.get_all())
collections_dicts = []
for collection in collections_list:
# Remove auto-generated id field
collection_copy = collection.copy()
if "id" in collection_copy:
del collection_copy["id"]
collections_dicts.append(collection_copy)
# !SECTION
data = {
"favorites": favorites,
"scrobbles": scrobbles,
"playlists": playlist_dicts,
"collections": collections_dicts,
}
with open(backup_file, "w") as f:
json.dump(data, f, indent=4)
return {
"name": backup_name,
"date": timestamp_to_time_passed(int(backup_name.split(".")[1])),
"scrobbles": len(scrobbles),
"favorites": len(favorites),
"playlists": len(playlist_dicts),
"collections": len(collections_dicts),
}, 200
class RestoreBackup:
# TODO: BACKUP AND RESTORE MIXES!
# TODO: IMPROVE UX WHEN WAITING FOR RESTORE TO COMPLETE!
def __init__(self, backup_dir: Path):
self.backup_dir = backup_dir
self.backup_file = backup_dir / "data.json"
with open(self.backup_file, "r") as f:
self.data = json.load(f)
self.restore_favorites(self.data["favorites"])
self.restore_playlists(self.data["playlists"])
self.restore_scrobbles(self.data["scrobbles"])
self.restore_collections(self.data.get("collections", []))
def restore(self):
pass
def restore_favorites(self, favorites: list[dict]):
existing_favorites = FavoritesTable.get_all()
existing_hashes = set(fav.hash for fav in existing_favorites)
new_favorites = [fav for fav in favorites if fav["hash"] not in existing_hashes]
for fav in new_favorites:
try:
FavoritesTable.insert_item(fav)
except sqlalchemy.exc.IntegrityError:
print("Integrity error, skipping favorite")
print(fav)
def restore_playlists(self, playlists: list[dict]):
existing_playlists = PlaylistTable.get_all()
existing_names = set(playlist.name for playlist in existing_playlists)
new_playlists = [
playlist for playlist in playlists if playlist["name"] not in existing_names
]
for playlist in new_playlists:
try:
if playlist.get("_score") is not None:
del playlist["_score"]
PlaylistTable.add_one(playlist)
except sqlalchemy.exc.IntegrityError:
print("Integrity error, skipping playlist:")
print(playlist)
def restore_scrobbles(self, scrobbles: list[dict]):
existing_scrobbles = ScrobbleTable.get_all(0)
existing_hashes = set(
f"{scrobble.trackhash}.{scrobble.timestamp}"
for scrobble in existing_scrobbles
)
new_scrobbles = [
scrobble
for scrobble in scrobbles
if f"{scrobble['trackhash']}.{scrobble['timestamp']}" not in existing_hashes
]
for scrobble in new_scrobbles:
try:
ScrobbleTable.add(scrobble)
except sqlalchemy.exc.IntegrityError:
print("Integrity error, skipping scrobble:")
print(scrobble)
def restore_collections(self, collections: list[dict]):
existing_collections = list(CollectionTable.get_all())
existing_names = set(collection["name"] for collection in existing_collections)
new_collections = [
collection for collection in collections if collection["name"] not in existing_names
]
for collection in new_collections:
try:
# Ensure userid is set for the collection
if collection.get("userid") is None:
from swingmusic.utils.auth import get_current_userid
collection["userid"] = get_current_userid()
CollectionTable.insert_one(collection)
except sqlalchemy.exc.IntegrityError:
print("Integrity error, skipping collection:")
print(collection)
class RestoreBackupBody(BaseModel):
backup_dir: Optional[str] = Field(
default=None,
description="The name of the backup directory to restore from. If not provided, all backups will be restored.",
example="backup.1234567890",
)
@api.post("/restore")
@admin_required()
def restore(body: RestoreBackupBody):
"""
Restore your favorites, playlists, scrobble data, and collections from a specified backup or all backups.
"""
backup_base_dir = Path("~").expanduser() / "swingmusic.backup"
backups = []
if body.backup_dir:
# Restore from a specific backup
specified_backup_dir = backup_base_dir / body.backup_dir
if not specified_backup_dir.exists() or not specified_backup_dir.is_dir():
return {"msg": f"Backup '{body.backup_dir}' not found"}, 404
restore_backup = RestoreBackup(specified_backup_dir)
restore_backup.restore()
backups.append(body.backup_dir)
else:
# Restore from all backups
try:
backup_dirs = [d for d in backup_base_dir.iterdir() if d.is_dir()]
except FileNotFoundError:
backup_dirs = []
if not backup_dirs:
return {"msg": "No backups found"}, 404
for backup_dir in sorted(backup_dirs, key=lambda x: x.name, reverse=True):
restore_backup = RestoreBackup(backup_dir)
restore_backup.restore()
backups.append(backup_dir.name)
index_everything()
return {"msg": f"Restored successfully", "backups": backups}, 200
@api.get("/list")
@admin_required()
def list_backups():
"""
List all backups with detailed information.
"""
backup_dir = Path("~").expanduser() / "swingmusic.backup"
backups = []
entries = []
try:
paths = [p for p in backup_dir.iterdir() if p.is_dir()]
except FileNotFoundError:
paths = []
for path in paths:
try:
entries.append(
{"path": path, "timestamp": int(path.name.split(".")[1])}
)
except (IndexError, ValueError):
pass
entries = sorted(entries, key=lambda x: x["timestamp"], reverse=True)
for entry in entries:
backup_info = {
"name": entry["path"].name,
"date": timestamp_to_time_passed(entry["timestamp"]),
}
# Read the JSON file and count items
json_file: Path = entry["path"] / "data.json"
if json_file.exists():
with json_file.open("r") as f:
data = json.load(f)
backup_info["scrobbles"] = len(data.get("scrobbles", []))
backup_info["favorites"] = len(data.get("favorites", []))
backup_info["playlists"] = len(data.get("playlists", []))
backup_info["collections"] = len(data.get("collections", []))
else:
backup_info["scrobbles"] = 0
backup_info["favorites"] = 0
backup_info["playlists"] = 0
backup_info["collections"] = 0
backups.append(backup_info)
return {"backups": backups}, 200
class DeleteBackupBody(BaseModel):
backup_dir: str = Field(
..., description="The name of the backup directory to delete."
)
@api.delete("/delete")
@admin_required()
def delete_backup(body: DeleteBackupBody):
"""
Delete a backup.
"""
backup_dir = Path("~").expanduser() / "swingmusic.backup"
backup_dir = backup_dir / body.backup_dir
if not backup_dir.exists() or not backup_dir.is_dir():
return {"msg": f"Backup '{body.backup_dir}' not found"}, 404
shutil.rmtree(backup_dir)
return {"msg": f"Backup '{body.backup_dir}' deleted"}, 200
-182
View File
@@ -1,182 +0,0 @@
"""
Contains all the collection routes.
"""
from typing import Any
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import BaseModel, Field
from swingmusic.db.userdata import CollectionTable
from swingmusic.lib.pagelib import recover_page_items, remove_page_items, validate_page_items
from swingmusic.utils.auth import get_current_userid
bp_tag = Tag(name="Collections", description="Collections")
api = APIBlueprint(
"collections", __name__, url_prefix="/collections", abp_tags=[bp_tag]
)
class CreateCollectionBody(BaseModel):
name: str = Field(description="The name of the collection")
description: str = Field(description="The description of the collection")
items: list[dict[str, Any]] = Field(
description="The items to add to the collection",
json_schema_extra={"example": [{"type": "album", "hash": "1234567890"}]},
)
@api.post("")
def create_collection(body: CreateCollectionBody):
"""
Create a new collection.
"""
items = validate_page_items(body.items, existing=[])
if len(items) == 0:
return {"error": "No items to add"}, 400
payload = {
"name": body.name,
"items": items,
"userid": get_current_userid(),
"extra": {
"description": body.description,
},
}
CollectionTable.insert_one(payload)
return {"message": "collection created"}, 201
@api.get("")
def get_collections():
"""
Get all collections.
"""
return [collection for collection in CollectionTable.get_all()]
class AddCollectionItemBody(BaseModel):
item: dict[str, Any] = Field(
description="The item to add to the collection",
json_schema_extra={"example": {"type": "album", "hash": "1234567890"}},
)
class AddCollectionItemPath(BaseModel):
collection_id: int = Field(
description="The ID of the collection to add items to",
json_schema_extra={"example": 1},
)
@api.post("/<int:collection_id>/items")
def add_collection_item(path: AddCollectionItemPath, body: AddCollectionItemBody):
"""
Add an item to a collection.
"""
collection = CollectionTable.get_by_id(path.collection_id)
if collection is None:
return {"error": "Collection not found"}, 404
new_items = validate_page_items([body.item], existing=collection["items"])
if len(new_items) == 0:
return {"error": "items already in collection"}, 400
collection["items"].extend(new_items)
CollectionTable.update_items(collection["id"], collection["items"])
return {"message": "Items added to collection"}
class RemoveCollectionItemBody(BaseModel):
item: dict[str, Any] = Field(
description="The item to remove from the collection",
json_schema_extra={"example": {"type": "album", "hash": "1234567890"}},
)
class RemoveCollectionItemPath(BaseModel):
collection_id: int = Field(
description="The ID of the collection to remove items from"
)
@api.delete("/<int:collection_id>/items")
def remove_collection_item(
path: RemoveCollectionItemPath, body: RemoveCollectionItemBody
):
"""
Remove an item from a collection.
"""
collection = CollectionTable.get_by_id(path.collection_id)
if collection is None:
return {"error": "Collection not found"}, 404
remaining = remove_page_items(collection["items"], body.item)
CollectionTable.update_items(collection["id"], remaining)
return {"message": "Item removed from collection"}
class GetCollectionBody(BaseModel):
collection_id: int = Field(description="The ID of the collection to get")
@api.get("/<int:collection_id>")
def get_collection(path: GetCollectionBody):
"""
Get a collection.
"""
collection = CollectionTable.get_by_id(path.collection_id)
if not collection:
return {"error": "Collection not found"}, 404
items = recover_page_items(collection["items"])
return {
"id": collection["id"],
"name": collection["name"],
"items": items,
"extra": collection["extra"],
}
class UpdateCollectionBody(BaseModel):
name: str = Field(description="The name of the collection")
description: str = Field(
description="The description of the collection", default=""
)
@api.put("/<int:collection_id>")
def update_collection(path: GetCollectionBody, body: UpdateCollectionBody):
"""
Update a collection.
"""
payload = {
"id": path.collection_id,
"name": body.name,
"extra": {"description": body.description},
}
CollectionTable.update_one(payload)
return payload
class DeleteCollectionPath(BaseModel):
collection_id: int = Field(description="The ID of the collection to delete")
@api.delete("/<int:collection_id>")
def delete_collection(path: DeleteCollectionPath):
"""
Delete a collection.
"""
CollectionTable.delete_by_id(path.collection_id)
return {"message": "Collection deleted"}
-22
View File
@@ -1,22 +0,0 @@
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from swingmusic.api.apischemas import AlbumHashSchema
from swingmusic.store.albums import AlbumStore as Store
bp_tag = Tag(name="Colors", description="Get item colors")
api = APIBlueprint("colors", __name__, url_prefix="/colors", abp_tags=[bp_tag])
@api.get("/album/<albumhash>")
def get_album_color(path: AlbumHashSchema):
"""
Get album color
"""
album = Store.get_album_by_hash(path.albumhash)
msg = {"color": ""}
if album is None or len(album.colors) == 0:
return msg, 404
return {"color": album.colors[0]}
-463
View File
@@ -1,463 +0,0 @@
"""
Enhanced Search API for SwingMusic
Integrates global music catalog search with existing local search
"""
from flask import Blueprint, request, jsonify
from typing import Dict, List, Any, Optional
import asyncio
from swingmusic.services.music_catalog import music_catalog_service
from swingmusic.api.search import search as local_search
from swingmusic import logger
from swingmusic.db.spotify import UserCatalogPreferencesTable
# Create blueprint
enhanced_search_bp = Blueprint('enhanced_search', __name__, url_prefix='/api/search')
@enhanced_search_bp.route('/global', methods=['POST'])
def global_search():
"""
Search across global music catalog (Spotify)
Request body:
{
"query": "search query",
"type": "all|tracks|albums|artists|playlists",
"limit": 20,
"user_id": 1
}
"""
try:
data = request.get_json()
if not data or not data.get('query'):
return jsonify({'error': 'Search query is required'}), 400
query = data['query'].strip()
search_type = data.get('type', 'all')
limit = min(data.get('limit', 20), 50) # Cap at 50
user_id = data.get('user_id')
# Get user preferences if available
user_prefs = None
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
limit = min(limit, user_prefs.max_search_results)
# Run async search
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(
music_catalog_service.search_global_catalog(query, search_type, limit)
)
finally:
loop.close()
# Filter based on user preferences
if user_prefs and not user_prefs.show_explicit:
result.tracks = [track for track in result.tracks if not track.explicit]
result.albums = [album for album in result.albums if not album.explicit]
# Convert to dict for JSON response
response_data = {
'query': result.query,
'total': result.total,
'tracks': [_catalog_item_to_dict(track) for track in result.tracks],
'albums': [_catalog_item_to_dict(album) for album in result.albums],
'artists': [_catalog_item_to_dict(artist) for artist in result.artists],
'playlists': [_catalog_item_to_dict(playlist) for playlist in result.playlists],
'source': 'global_catalog',
'cache_info': {
'from_cache': True, # TODO: Implement cache detection
'expires_at': None
}
}
return jsonify(response_data)
except Exception as e:
logger.error(f"Error in global search: {e}")
return jsonify({'error': 'Search failed'}), 500
@enhanced_search_bp.route('/combined', methods=['POST'])
def combined_search():
"""
Search both local library and global catalog
Request body:
{
"query": "search query",
"include_local": true,
"include_global": true,
"type": "all|tracks|albums|artists",
"limit": 20,
"user_id": 1
}
"""
try:
data = request.get_json()
if not data or not data.get('query'):
return jsonify({'error': 'Search query is required'}), 400
query = data['query'].strip()
include_local = data.get('include_local', True)
include_global = data.get('include_global', True)
search_type = data.get('type', 'all')
limit = min(data.get('limit', 20), 50)
user_id = data.get('user_id')
results = {
'query': query,
'local': {'tracks': [], 'albums': [], 'artists': []},
'global': {'tracks': [], 'albums': [], 'artists': [], 'playlists': []},
'total': 0
}
# Search local library
if include_local:
try:
# Use existing local search
local_results = local_search(query, search_type)
results['local'] = local_results if local_results else {'tracks': [], 'albums': [], 'artists': []}
except Exception as e:
logger.error(f"Error in local search: {e}")
# Search global catalog
if include_global:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
global_results = loop.run_until_complete(
music_catalog_service.search_global_catalog(query, search_type, limit)
)
# Filter based on user preferences
user_prefs = None
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
if not user_prefs.show_explicit:
global_results.tracks = [track for track in global_results.tracks if not track.explicit]
global_results.albums = [album for album in global_results.albums if not album.explicit]
results['global'] = {
'tracks': [_catalog_item_to_dict(track) for track in global_results.tracks],
'albums': [_catalog_item_to_dict(album) for album in global_results.albums],
'artists': [_catalog_item_to_dict(artist) for artist in global_results.artists],
'playlists': [_catalog_item_to_dict(playlist) for playlist in global_results.playlists]
}
finally:
loop.close()
# Calculate total
results['total'] = (
len(results['local'].get('tracks', [])) +
len(results['local'].get('albums', [])) +
len(results['local'].get('artists', [])) +
len(results['global'].get('tracks', [])) +
len(results['global'].get('albums', [])) +
len(results['global'].get('artists', [])) +
len(results['global'].get('playlists', []))
)
return jsonify(results)
except Exception as e:
logger.error(f"Error in combined search: {e}")
return jsonify({'error': 'Search failed'}), 500
@enhanced_search_bp.route('/suggestions', methods=['GET'])
def search_suggestions():
"""
Get search suggestions based on query and user preferences
Query parameters:
- q: search query
- type: tracks|albums|artists|all
- limit: number of suggestions (default 10)
- user_id: user ID for preferences
"""
try:
query = request.args.get('q', '').strip()
if not query or len(query) < 2:
return jsonify({'suggestions': []})
search_type = request.args.get('type', 'all')
limit = min(int(request.args.get('limit', 10)), 20)
user_id = request.args.get('user_id')
# Get user preferences
user_prefs = None
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
limit = min(limit, user_prefs.max_search_results)
# Search cached items for fast suggestions
item_types = None
if search_type != 'all':
item_types = [search_type]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# For suggestions, search both cache and live
suggestions = []
# Search cached items first (fast)
from swingmusic.db.spotify import GlobalCatalogCacheTable
cached_items = GlobalCatalogCacheTable.search_cached(query, item_types, limit)
for item in cached_items:
if user_prefs and not user_prefs.show_explicit and item.explicit:
continue
suggestion = {
'id': item.spotify_id,
'type': item.item_type,
'title': item.title,
'artist': item.artist,
'album': item.album,
'image_url': item.image_url,
'popularity': item.popularity,
'source': 'cache'
}
suggestions.append(suggestion)
# If we need more suggestions, search global catalog
if len(suggestions) < limit:
remaining = limit - len(suggestions)
global_results = loop.run_until_complete(
music_catalog_service.search_global_catalog(query, search_type, remaining)
)
for track in global_results.tracks[:remaining]:
if user_prefs and not user_prefs.show_explicit and track.explicit:
continue
suggestion = {
'id': track.spotify_id,
'type': 'track',
'title': track.title,
'artist': track.artist,
'album': track.album,
'image_url': track.image_url,
'popularity': track.popularity,
'source': 'global'
}
suggestions.append(suggestion)
return jsonify({'suggestions': suggestions[:limit]})
finally:
loop.close()
except Exception as e:
logger.error(f"Error in search suggestions: {e}")
return jsonify({'suggestions': []})
@enhanced_search_bp.route('/artist/<artist_id>', methods=['GET'])
def get_artist_info(artist_id: str):
"""
Get comprehensive artist information including top tracks and albums
Path parameters:
- artist_id: Spotify artist ID
Query parameters:
- user_id: user ID for preferences
"""
try:
user_id = request.args.get('user_id')
# Get user preferences
user_prefs = None
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
artist_info = loop.run_until_complete(
music_catalog_service.get_artist_info(artist_id)
)
if not artist_info:
return jsonify({'error': 'Artist not found'}), 404
# Filter based on user preferences
if user_prefs and not user_prefs.show_explicit:
artist_info.top_tracks = [
track for track in artist_info.top_tracks or [] if not track.explicit
]
artist_info.albums = [
album for album in artist_info.albums or [] if not album.explicit
]
response_data = {
'spotify_id': artist_info.spotify_id,
'name': artist_info.name,
'image_url': artist_info.image_url,
'followers': artist_info.followers,
'popularity': artist_info.popularity,
'genres': artist_info.genres or [],
'top_tracks': [_catalog_item_to_dict(track) for track in (artist_info.top_tracks or [])],
'albums': [_catalog_item_to_dict(album) for album in (artist_info.albums or [])],
'related_artists': artist_info.related_artists or []
}
return jsonify(response_data)
finally:
loop.close()
except Exception as e:
logger.error(f"Error getting artist info: {e}")
return jsonify({'error': 'Failed to get artist info'}), 500
@enhanced_search_bp.route('/album/<album_id>', methods=['GET'])
def get_album_details(album_id: str):
"""
Get detailed album information with tracklist
Path parameters:
- album_id: Spotify album ID
Query parameters:
- user_id: user ID for preferences
"""
try:
user_id = request.args.get('user_id')
# Get user preferences
user_prefs = None
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
album = loop.run_until_complete(
music_catalog_service.get_album_details(album_id)
)
if not album:
return jsonify({'error': 'Album not found'}), 404
# Filter based on user preferences
if user_prefs and not user_prefs.show_explicit and album.explicit:
return jsonify({'error': 'Explicit content filtered'}), 403
response_data = _catalog_item_to_dict(album)
# Add tracklist if available in data
if album.data and 'tracks' in album.data:
response_data['tracks'] = [
_catalog_item_to_dict(track) for track in album.data['tracks']
]
return jsonify(response_data)
finally:
loop.close()
except Exception as e:
logger.error(f"Error getting album details: {e}")
return jsonify({'error': 'Failed to get album details'}), 500
@enhanced_search_bp.route('/preferences/<int:user_id>', methods=['GET', 'POST'])
def user_preferences(user_id: int):
"""Get or update user catalog search preferences"""
try:
if request.method == 'GET':
prefs = UserCatalogPreferencesTable.get_or_create(user_id)
return jsonify({
'user_id': prefs.user_id,
'show_explicit': prefs.show_explicit,
'default_quality': prefs.default_quality,
'auto_download': prefs.auto_download,
'show_suggestions': prefs.show_suggestions,
'preferred_genres': prefs.preferred_genres or [],
'excluded_genres': prefs.excluded_genres or [],
'max_search_results': prefs.max_search_results,
'cache_ttl_preference': prefs.cache_ttl_preference
})
elif request.method == 'POST':
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
# Update only provided fields
update_data = {}
allowed_fields = [
'show_explicit', 'default_quality', 'auto_download',
'show_suggestions', 'preferred_genres', 'excluded_genres',
'max_search_results', 'cache_ttl_preference'
]
for field in allowed_fields:
if field in data:
update_data[field] = data[field]
if update_data:
UserCatalogPreferencesTable.update_preferences(user_id, update_data)
return jsonify({'message': 'Preferences updated successfully'})
except Exception as e:
logger.error(f"Error handling user preferences: {e}")
return jsonify({'error': 'Failed to handle preferences'}), 500
def _catalog_item_to_dict(item) -> Dict[str, Any]:
"""Convert CatalogItem to dictionary for JSON response"""
if hasattr(item, '__dict__'):
# It's a dataclass instance
return {
'spotify_id': item.spotify_id,
'type': item.item_type.value if hasattr(item.item_type, 'value') else str(item.item_type),
'title': item.title,
'artist': item.artist,
'album': item.album,
'duration_ms': item.duration_ms,
'popularity': item.popularity,
'preview_url': item.preview_url,
'image_url': item.image_url,
'release_date': item.release_date,
'explicit': item.explicit,
'data': item.data
}
else:
# It's likely a database model
return {
'spotify_id': getattr(item, 'spotify_id', None),
'type': getattr(item, 'item_type', None),
'title': getattr(item, 'title', None),
'artist': getattr(item, 'artist', None),
'album': getattr(item, 'album', None),
'duration_ms': getattr(item, 'duration_ms', None),
'popularity': getattr(item, 'popularity', None),
'preview_url': getattr(item, 'preview_url', None),
'image_url': getattr(item, 'image_url', None),
'release_date': getattr(item, 'release_date', None),
'explicit': getattr(item, 'explicit', False),
'data': getattr(item, 'data', None)
}
def register_enhanced_search_api(app):
"""Register enhanced search API with Flask app"""
app.register_blueprint(enhanced_search_bp)
logger.info("Enhanced search API registered")
-297
View File
@@ -1,297 +0,0 @@
from typing import List, TypeVar
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import BaseModel, Field
from swingmusic.api.apischemas import GenericLimitSchema
from swingmusic.db.userdata import FavoritesTable
from swingmusic.lib.extras import get_extra_info
from swingmusic.models import FavType
from swingmusic.settings import Defaults
from swingmusic.store.albums import AlbumStore
from swingmusic.store.artists import ArtistStore
from swingmusic.store.tracks import TrackStore
from swingmusic.serializers.track import serialize_track, serialize_tracks
from swingmusic.serializers.artist import (
serialize_for_card as serialize_artist,
serialize_for_cards,
)
from swingmusic.utils.dates import timestamp_to_time_passed
from swingmusic.serializers.album import serialize_for_card, serialize_for_card_many
bp_tag = Tag(name="Favorites", description="Your favorite items")
api = APIBlueprint("favorites", __name__, url_prefix="/favorites", abp_tags=[bp_tag])
T = TypeVar("T")
def remove_none(items: List[T]) -> List[T]:
return [i for i in items if i is not None]
class FavoritesAddBody(BaseModel):
hash: str = Field(
description="The hash of the item",
min_length=Defaults.HASH_LENGTH,
max_length=Defaults.HASH_LENGTH,
)
type: str = Field(description="The type of the item")
def toggle_fav(type: str, hash: str):
"""
Toggles a favorite item.
"""
if type == FavType.track:
entry = TrackStore.trackhashmap.get(hash)
if entry is not None:
entry.toggle_favorite_user()
elif type == FavType.album:
entry = AlbumStore.albummap.get(hash)
if entry is not None:
entry.toggle_favorite_user()
elif type == FavType.artist:
entry = ArtistStore.artistmap.get(hash)
if entry is not None:
entry.toggle_favorite_user()
return {"msg": "Added to favorites"}
@api.post("/add")
def toggle_favorite(body: FavoritesAddBody):
"""
Adds a favorite to the database.
"""
extra = get_extra_info(body.hash, body.type)
try:
FavoritesTable.insert_item(
{"hash": body.hash, "type": body.type, "extra": extra}
)
except Exception as e:
print(e)
return {"msg": "Failed! An error occured"}, 500
toggle_fav(body.type, body.hash)
return {"msg": "Added to favorites"}
@api.post("/remove")
def remove_favorite(body: FavoritesAddBody):
"""
Removes a favorite from the database.
"""
try:
FavoritesTable.remove_item({"hash": body.hash, "type": body.type})
except Exception as e:
print(e)
return {"msg": "Failed! An error occured"}, 500
toggle_fav(body.type, body.hash)
return {"msg": "Removed from favorites"}
class GetAllOfTypeQuery(GenericLimitSchema):
"""
Extending this class will give you a model with the `limit` field
"""
start: int = Field(
description="Where to start from",
default=Defaults.API_CARD_LIMIT,
)
@api.get("/albums")
def get_favorite_albums(query: GetAllOfTypeQuery):
"""
Get favorite albums
Note: Only the first request will return the total number of favorites.
Others will return -1
"""
fav_albums, total = FavoritesTable.get_fav_albums(query.start, query.limit)
albums = AlbumStore.get_albums_by_hashes(a.hash for a in fav_albums)
return {"albums": serialize_for_card_many(albums), "total": total}
@api.get("/tracks")
def get_favorite_tracks(query: GetAllOfTypeQuery):
"""
Get favorite tracks
Note: Only the first request will return the total number of favorites.
Others will return -1
"""
tracks, total = FavoritesTable.get_fav_tracks(query.start, query.limit)
tracks = TrackStore.get_tracks_by_trackhashes([t.hash for t in tracks])
return {"tracks": serialize_tracks(tracks), "total": total}
@api.get("/artists")
def get_favorite_artists(query: GetAllOfTypeQuery):
"""
Get favorite artists
Note: Only the first request will return the total number of favorites.
Others will return -1
"""
artists, total = FavoritesTable.get_fav_artists(
start=query.start,
limit=query.limit,
)
artists = ArtistStore.get_artists_by_hashes(a.hash for a in artists)
return {"artists": [serialize_artist(a) for a in artists], "total": total}
class GetAllFavoritesQuery(BaseModel):
"""
Extending this class will give you a model with the `limit` field
"""
track_limit: int = Field(
description="The number of tracks to return",
default=Defaults.API_CARD_LIMIT,
)
album_limit: int = Field(
description="The number of albums to return",
default=Defaults.API_CARD_LIMIT,
)
artist_limit: int = Field(
description="The number of artists to return",
default=Defaults.API_CARD_LIMIT,
)
@api.get("")
def get_all_favorites(query: GetAllFavoritesQuery):
"""
Returns all the favorites in the database.
"""
track_limit = query.track_limit
album_limit = query.album_limit
artist_limit = query.artist_limit
# largest is x2 to accound for broken hashes if any
largest = max(track_limit, album_limit, artist_limit)
favs = FavoritesTable.get_all(with_user=True)
favs = sorted(favs, key=lambda x: x.timestamp, reverse=True)
tracks = []
albums = []
artists = []
track_master_hash = TrackStore.trackhashmap.keys()
album_master_hash = AlbumStore.albummap.keys()
artist_master_hash = ArtistStore.artistmap.keys()
# INFO: Filter out invalid hashes (file not found or tags edited)
for fav in favs:
hash = fav.hash
type = fav.type
if type == FavType.track:
tracks.append(hash) if hash in track_master_hash else None
if type == FavType.artist:
artists.append(hash) if hash in artist_master_hash else None
if type == FavType.album:
albums.append(hash) if hash in album_master_hash else None
count = {
"tracks": len(tracks),
"albums": len(albums),
"artists": len(artists),
}
tracks = TrackStore.get_tracks_by_trackhashes(tracks[:track_limit])
albums = AlbumStore.get_albums_by_hashes(albums[:album_limit])
artists = ArtistStore.get_artists_by_hashes(artists[:artist_limit])
recents = []
for fav in favs:
if len(recents) >= largest:
break
if fav.type == FavType.album:
album = next((a for a in albums if a.albumhash == fav.hash), None)
if album is None:
continue
album = serialize_for_card(album)
album["help_text"] = "album"
album["time"] = timestamp_to_time_passed(fav.timestamp)
recents.append(
{
"type": "album",
"item": album,
}
)
if fav.type == FavType.artist:
artist = next((a for a in artists if a.artisthash == fav.hash), None)
if artist is None:
continue
artist = serialize_artist(artist)
artist["help_text"] = "artist"
artist["time"] = timestamp_to_time_passed(fav.timestamp)
recents.append(
{
"type": "artist",
"item": artist,
}
)
if fav.type == FavType.track:
track = next((t for t in tracks if t.trackhash == fav.hash), None)
if track is None:
continue
track = serialize_track(track)
track["help_text"] = "track"
track["time"] = timestamp_to_time_passed(fav.timestamp)
recents.append({"type": "track", "item": track})
return {
"recents": recents[:album_limit],
"tracks": serialize_tracks(tracks[:track_limit]),
"albums": serialize_for_card_many(albums[:album_limit]),
"artists": serialize_for_cards(artists[:artist_limit]),
"count": count,
}
@api.get("/check")
def check_favorite(query: FavoritesAddBody):
"""
Checks if a favorite exists in the database.
"""
itemhash = query.hash
itemtype = query.type
return {"is_favorite": FavoritesTable.check_exists(itemhash, itemtype)}
-354
View File
@@ -1,354 +0,0 @@
"""
Contains all the folder routes.
"""
import os
import pathlib
from pathlib import Path
from datetime import datetime
import psutil
from flask_openapi3 import Tag
from pydantic import BaseModel, Field
from flask_openapi3 import APIBlueprint
from showinfm import show_in_file_manager
from swingmusic import settings
from swingmusic.config import UserConfig
from swingmusic.db.libdata import TrackTable
from swingmusic.api.auth import admin_required
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.wintools import is_windows
from swingmusic.db.userdata import FavoritesTable, PlaylistTable
from swingmusic.lib.folderslib import get_files_and_dirs, get_folders
from swingmusic.serializers.track import serialize_track, serialize_tracks
tag = Tag(name="Folders", description="Get folders and tracks in a directory")
api = APIBlueprint("folder", __name__, url_prefix="/folder", abp_tags=[tag])
def is_path_within_root_dirs(filepath: str) -> bool:
"""
Check if a filepath is within one of the configured root directories.
Prevents directory traversal attacks.
"""
config = UserConfig()
resolved_path = Path(filepath).resolve()
for root_dir in config.rootDirs:
if root_dir == "$home":
root_path = Path.home().resolve()
else:
root_path = Path(root_dir).resolve()
# Check if resolved_path is the root or a child of root
if resolved_path == root_path or root_path in resolved_path.parents:
return True
return False
class FolderTree(BaseModel):
folder: str = Field("$home", description="The folder to things from")
sorttracksby: str = Field(
"default",
description="""The field to sort tracks by. Options: [
"default",
"album",
"albumartists",
"artists",
"bitrate",
"date",
"disc",
"duration",
"last_mod",
"lastplayed",
"playduration",
"playcount",
"title",
]""",
)
tracksort_reverse: bool = Field(
False,
description="Whether to reverse the sort order of the tracks",
)
sortfoldersby: str = Field(
"lastmod",
description="""The field to sort folders by.
Options: [
"default",
"name",
"lastmod",
"trackcount",
]
""",
)
foldersort_reverse: bool = Field(
False,
description="Whether to reverse the sort order of the folders",
)
start: int = Field(0, description="The start index")
limit: int = Field(50, description="The max number of items to return")
tracks_only: bool = Field(False, description="Whether to only get tracks")
@api.post("")
def get_folder_tree(body: FolderTree):
"""
Get folder
Returns a list of all the folders and tracks in the given folder.
"""
og_req_dir = body.folder
req_dir = body.folder
tracks_only = body.tracks_only
config = UserConfig()
root_dirs = config.rootDirs
if req_dir == "$home" and "$home" in root_dirs:
req_dir = settings.Paths().USER_HOME_DIR.as_posix()
if req_dir == "$home":
folders = get_folders(root_dirs)
return {
"folders": folders,
"tracks": [],
}
if req_dir.startswith("$playlist"):
splits = req_dir.split("/")
if len(splits) == 2:
pid = splits[1]
playlist = PlaylistTable.get_by_id(int(pid))
tracks = TrackStore.get_tracks_by_trackhashes(
playlist.trackhashes[
body.start : body.start + body.limit if body.limit != -1 else None
]
)
return {
"path": f"$playlist/{playlist.name}",
"folders": [],
"tracks": serialize_tracks(tracks),
}
playlists = PlaylistTable.get_all()
playlists = sorted(
playlists,
key=lambda p: datetime.strptime(p.last_updated, "%Y-%m-%d %H:%M:%S"),
reverse=True,
)
return {
"path": req_dir,
"folders": [
{
"name": p.name,
"path": f"$playlist/{p.id}",
"trackcount": p.count,
}
for p in playlists
],
"tracks": [],
}
if req_dir == "$favorites":
tracks, total = FavoritesTable.get_fav_tracks(body.start, body.limit)
tracks = TrackStore.get_tracks_by_trackhashes([t.hash for t in tracks])
return {
"tracks": serialize_tracks(tracks),
"folders": [],
"path": req_dir,
}
# Resolve path to prevent directory traversal attacks
resolved_path = pathlib.Path(req_dir).resolve()
# Validate path is within configured root directories
if not is_path_within_root_dirs(str(resolved_path)):
return {
"folders": [],
"tracks": [],
"error": "Path not within allowed directories",
}, 403
if not resolved_path.exists() or not resolved_path.is_dir():
return {
"folders": [],
"tracks": [],
"error": "Invalid directory",
}, 400
results = get_files_and_dirs(
resolved_path,
start=body.start,
limit=body.limit,
tracks_only=tracks_only,
tracksortby=body.sorttracksby,
foldersortby=body.sortfoldersby,
tracksort_reverse=body.tracksort_reverse,
foldersort_reverse=body.foldersort_reverse,
)
if og_req_dir == "$home" and config.showPlaylistsInFolderView:
# Get all playlists and return them as a list of folders
playlists_item = {
"name": "Playlists",
"path": "$playlists",
"trackcount": sum(p.count for p in PlaylistTable.get_all()),
}
favorites_item = {
"name": "Favorites",
"path": "$favorites",
"trackcount": FavoritesTable.get_fav_tracks(0, -1)[1],
}
results["folders"].insert(0, playlists_item)
results["folders"].insert(0, favorites_item)
return results
def get_all_drives(is_win: bool = False):
"""
Returns a list of all the drives on a Windows machine.
"""
drives_ = psutil.disk_partitions(all=True)
drives = [Path(d.mountpoint).as_posix() for d in drives_]
if is_win:
return drives
else:
remove = (
"/boot",
"/tmp",
"/snap",
"/var",
"/sys",
"/proc",
"/etc",
"/run",
"/dev",
)
drives = [d for d in drives if not d.startswith(remove)]
return drives
class DirBrowserBody(BaseModel):
folder: str = Field(
"$root",
description="The folder to list directories from",
)
@api.post("/dir-browser")
@admin_required()
def list_folders(body: DirBrowserBody):
"""
List folders
Returns a list of all the folders in the given folder.
Used when selecting root dirs. Admin only.
"""
req_dir = body.folder
is_win = is_windows()
if req_dir == "$root":
return {
"folders": [{"name": d, "path": d} for d in get_all_drives(is_win=is_win)]
}
# Resolve path to prevent directory traversal attacks
req_dir = pathlib.Path(req_dir).resolve()
if not req_dir.exists() or not req_dir.is_dir():
return {"folders": [], "error": "Invalid directory"}, 400
try:
entries = os.scandir(req_dir)
except PermissionError:
return {"folders": []}
# only get dirs and remove hidden dirs
dirs = []
for entry in entries:
entry = pathlib.Path(entry)
name = entry.name
if name.startswith("$"):
continue
if name.startswith("."):
continue
if entry.is_dir():
dirs.append({"name": name, "path": entry.resolve().as_posix()})
return {
"folders": sorted(dirs, key=lambda i: i["name"]),
}
class FolderOpenInFileManagerQuery(BaseModel):
path: str = Field(
description="The path to open in the file manager",
)
@api.get("/show-in-files")
def open_in_file_manager(query: FolderOpenInFileManagerQuery):
"""
Open in file manager
Opens the given path in the file manager on the host machine.
Path must be within configured root directories.
"""
# Resolve path to prevent directory traversal
resolved_path = Path(query.path).resolve()
# Validate path is within root directories
if not is_path_within_root_dirs(query.path):
return {"success": False, "error": "Path not within allowed directories"}, 403
if not resolved_path.exists():
return {"success": False, "error": "Path does not exist"}, 404
show_in_file_manager(str(resolved_path))
return {"success": True}
class GetTracksInPathQuery(BaseModel):
path: str = Field(
description="The path to get tracks from",
)
@api.get("/tracks/all")
def get_tracks_in_path(query: GetTracksInPathQuery):
"""
Get tracks in path
Gets all (or a max of 300) tracks from the given path and its subdirectories.
Used when adding tracks to the queue.
"""
# Resolve path to prevent directory traversal
resolved_path = Path(query.path).resolve()
# Validate path is within root directories
if not is_path_within_root_dirs(str(resolved_path)):
return {"tracks": [], "error": "Path not within allowed directories"}, 403
tracks = TrackTable.get_tracks_in_path(str(resolved_path))
tracks = (serialize_track(t) for t in tracks if Path(t.filepath).exists())
return {
"tracks": list(tracks)[:300],
}
-152
View File
@@ -1,152 +0,0 @@
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import BaseModel, Field
from datetime import datetime
from swingmusic.api.apischemas import GenericLimitSchema
from swingmusic.store.albums import AlbumStore
from swingmusic.store.artists import ArtistStore
from swingmusic.serializers.album import serialize_for_card as serialize_album
from swingmusic.serializers.artist import serialize_for_card as serialize_artist
from swingmusic.utils import format_number
from swingmusic.utils.dates import (
create_new_date,
date_string_to_time_passed,
seconds_to_time_string,
timestamp_to_time_passed,
)
bp_tag = Tag(name="Get all", description="List all items")
api = APIBlueprint("getall", __name__, url_prefix="/getall", abp_tags=[bp_tag])
class GetAllItemsQuery(GenericLimitSchema):
start: int = Field(
description="The start index of the items to return",
example=0,
default=0,
)
sortby: str = Field(
description="The key to sort items by",
example="created_date",
default="created_date",
)
reverse: str = Field(
description="Reverse the sort",
example=1,
default="1",
)
class GetAllItemsPath(BaseModel):
itemtype: str = Field(
description="The type of items to return (albums | artists)",
example="albums",
default="albums",
)
@api.get("/<itemtype>")
def get_all_items(path: GetAllItemsPath, query: GetAllItemsQuery):
"""
Get all items
Used to show all albums or artists in the library
Sort keys:
-
Both albums and artists: `duration`, `created_date`, `playcount`, `playduration`, `lastplayed`, `trackcount`
Albums only: `title`, `albumartists`, `date`
Artists only: `name`, `albumcount`
"""
is_albums = path.itemtype == "albums"
is_artists = path.itemtype == "artists"
if is_albums:
items = AlbumStore.get_flat_list()
elif is_artists:
items = ArtistStore.get_flat_list()
total = len(items)
start = query.start
limit = query.limit
sort = query.sortby
reverse = query.reverse == "1"
sort_is_count = sort == "trackcount"
sort_is_duration = sort == "duration"
sort_is_create_date = sort == "created_date"
sort_is_playcount = sort == "playcount"
sort_is_playduration = sort == "playduration"
sort_is_lastplayed = sort == "lastplayed"
sort_is_date = is_albums and sort == "date"
sort_is_artist = is_albums and sort == "albumartists"
sort_is_artist_trackcount = is_artists and sort == "trackcount"
sort_is_artist_albumcount = is_artists and sort == "albumcount"
lambda_sort = lambda x: getattr(x, sort)
lambda_sort_casefold = lambda x: getattr(x, sort).casefold()
if sort_is_artist:
lambda_sort = lambda x: getattr(x, sort)[0]["name"].casefold()
try:
sorted_items = sorted(items, key=lambda_sort_casefold, reverse=reverse)
except AttributeError:
sorted_items = sorted(items, key=lambda_sort, reverse=reverse)
items = sorted_items[start : start + limit]
album_list = []
for item in items:
item_dict = serialize_album(item) if is_albums else serialize_artist(item)
if sort_is_date:
item_dict["help_text"] = datetime.fromtimestamp(item.date).year
if sort_is_create_date:
date = create_new_date(datetime.fromtimestamp(item.created_date))
timeago = date_string_to_time_passed(date)
item_dict["help_text"] = timeago
if sort_is_count:
item_dict["help_text"] = (
f"{format_number(item.trackcount)} track{'' if item.trackcount == 1 else 's'}"
)
if sort_is_duration:
item_dict["help_text"] = seconds_to_time_string(item.duration)
if sort_is_artist_trackcount:
item_dict["help_text"] = (
f"{format_number(item.trackcount)} track{'' if item.trackcount == 1 else 's'}"
)
if sort_is_artist_albumcount:
item_dict["help_text"] = (
f"{format_number(item.albumcount)} album{'' if item.albumcount == 1 else 's'}"
)
if sort_is_playcount:
item_dict["help_text"] = (
f"{format_number(item.playcount)} play{'' if item.playcount == 1 else 's'}"
)
if sort_is_lastplayed:
if item.playduration == 0:
item_dict["help_text"] = "Never played"
else:
item_dict["help_text"] = timestamp_to_time_passed(item.lastplayed)
if sort_is_playduration:
item_dict["help_text"] = seconds_to_time_string(item.playduration)
album_list.append(item_dict)
return {"items": album_list, "total": total}
-38
View File
@@ -1,38 +0,0 @@
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import BaseModel, Field
from swingmusic.api.apischemas import GenericLimitSchema
from swingmusic.lib.home.recentlyadded import get_recently_added_items
from swingmusic.lib.home.get_recently_played import get_recently_played
from swingmusic.store.homepage import HomepageStore
bp_tag = Tag(name="Home", description="Homepage items")
api = APIBlueprint("home", __name__, url_prefix="/nothome", abp_tags=[bp_tag])
@api.get("/recents/added")
def get_recently_added(query: GenericLimitSchema):
"""
Get recently added
"""
return {"items": get_recently_added_items(query.limit)}
@api.get("/recents/played")
def get_recent_plays(query: GenericLimitSchema):
"""
Get recently played
"""
return {"items": get_recently_played(query.limit)}
class HomepageItem(BaseModel):
limit: int = Field(
default=9, description="The max number of items per group to return"
)
@api.get("/")
def homepage_items(query: HomepageItem):
return HomepageStore.get_homepage_items(limit=query.limit)
-259
View File
@@ -1,259 +0,0 @@
from fileinput import filename
from pathlib import Path
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import BaseModel, Field
from flask import send_from_directory
from swingmusic.settings import Defaults, Paths
from swingmusic.store.albums import AlbumStore
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.threading import background
from PIL import Image
bp_tag = Tag(
name="Images", description="Image filenames are constructured as '{itemhash}.webp'"
)
api = APIBlueprint("imgserver", __name__, url_prefix="/img", abp_tags=[bp_tag])
@background
def cache_thumbnails(filepath: Path, trackhash: str):
"""
Resizes the image and stores it in the cache directory.
"""
image = Image.open(filepath)
path = Path(Paths().image_cache_path)
aspect_ratio = image.width / image.height
sizes = {
"xsmall": 64,
"small": 96,
"medium": 256,
"large": 512,
}
for size, width in sizes.items():
width = min(width, image.width)
height = int(width / aspect_ratio)
resized_path = path / size / (trackhash + ".webp")
resized_path.parent.mkdir(parents=True, exist_ok=True)
image.resize((width, height)).save(resized_path, format="webp")
def find_thumbnail(albumhash: str, pathhash: str):
# entry = TrackStore.trackhashmap.get(albumhash)
entry = AlbumStore.albummap.get(albumhash)
if entry is None:
return None, None, ""
track_file = None
tracks = TrackStore.get_tracks_by_trackhashes(entry.trackhashes)
for track in tracks:
if track.pathhash == pathhash:
track_file = track
break
if track_file is None:
return None, None, ""
folder = Path(track_file.folder)
# INFO: Check if the folder has image files
extensions = [".jpg", ".jpeg", ".png", ".webp"]
hierarchy = ["cover", "front", "back", "folder", "album", "artwork"]
images: list[Path] = []
for item in folder.iterdir():
if item.suffix in extensions:
images.append(item)
if len(images) == 0:
return None, None, ""
# INFO: Check if the folder has image files in the hierarchy
for item in hierarchy:
for image in images:
if image.name.lower().startswith(item.lower()):
return image.parent, image.name, track_file.albumhash
# INFO: If no image falls in the hierarchy, return the first image
first_image = images[0]
return first_image.parent, first_image.name, track_file.albumhash
def send_fallback_img(filename: str = "default.webp"):
"""
Returns the fallback image from the assets folder.
"""
folder = Paths().assets_path
img = Path(folder) / filename
if not img.exists():
return "", 404
return send_from_directory(folder, filename)
def send_file_or_fallback(
folder: str, filename: str, fallback: str = "default.webp", pathhash: str = ""
):
"""
Returns the file from the folder or the fallback image.
"""
fpath = Path(folder) / filename
if fpath.exists():
return send_from_directory(folder, filename)
if pathhash != "":
# INFO: Check if the image is in the cache
cache_path = Paths().image_cache_path / fpath.parent.name / filename
if cache_path.exists():
return send_from_directory(cache_path.parent, cache_path.name)
# INFO: Find the thumbnail
parent, file, albumhash = find_thumbnail(
filename.replace(".webp", ""), pathhash
)
# INFO: Cache and send the thumbnail
if file is not None and parent is not None:
cache_thumbnails(parent / file, albumhash)
return send_from_directory(parent, file)
return send_fallback_img(fallback)
class ImagePath(BaseModel):
imgpath: str = Field(
description="The image filename",
example=Defaults.API_ALBUMHASH + ".webp",
)
class ImageQuery(BaseModel):
pathhash: str = Field(
description="The path hash used to find the thumbnail",
default="",
)
# @api.get("/t/o/<imgpath>")
# def send_original_thumbnail(path: ImagePath):
# """
# Get original thumbnail
# """
# folder = Paths.get_original_thumb_path()
# fpath = Path(folder) / path.imgpath
# if fpath.exists():
# return send_from_directory(folder, path.imgpath)
# return send_fallback_img()
# TRACK THUMBNAILS
@api.get("/thumbnail/<imgpath>")
def send_lg_thumbnail(path: ImagePath, query: ImageQuery):
"""
Get large thumbnail (500 x 500)
"""
folder = Paths().lg_thumb_path
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
@api.get("/thumbnail/xsmall/<imgpath>")
def send_xsm_thumbnail(path: ImagePath, query: ImageQuery):
"""
Get extra small thumbnail (64px)
"""
folder = Paths().xsm_thumb_path
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
@api.get("/thumbnail/small/<imgpath>")
def send_sm_thumbnail(path: ImagePath, query: ImageQuery):
"""
Get small thumbnail (96px)
"""
folder = Paths().sm_thumb_path
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
@api.get("/thumbnail/medium/<imgpath>")
def send_md_thumbnail(path: ImagePath, query: ImageQuery):
"""
Get medium thumbnail (256px)
"""
folder = Paths().md_thumb_path
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
# ARTISTS
@api.get("/artist/<imgpath>")
def send_lg_artist_image(path: ImagePath):
"""
Get large artist image (500 x 500)
"""
folder = Paths().lg_artist_img_path
return send_file_or_fallback(str(folder), path.imgpath, "artist.webp")
@api.get("/artist/small/<imgpath>")
def send_sm_artist_image(path: ImagePath):
"""
Get small artist image (128)
"""
folder = Paths().sm_artist_img_path
return send_file_or_fallback(str(folder), path.imgpath, "artist.webp")
@api.get("/artist/medium/<imgpath>")
def send_md_artist_image(path: ImagePath):
"""
Get medium artist image (256px)
"""
folder = Paths().md_artist_img_path
return send_file_or_fallback(folder, path.imgpath, "artist.webp")
# PLAYLISTS
class PlaylistImagePath(BaseModel):
imgpath: str = Field(
description="The image path",
example="1.webp",
)
@api.get("/playlist/<imgpath>")
def send_playlist_image(path: PlaylistImagePath):
"""
Get playlist image
Images are constructed as '{playlist_id}.webp'
"""
folder = Paths().playlist_img_path
return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
# MIXES
@api.get("/mix/medium/<imgpath>")
def send_md_mix_image(path: ImagePath):
"""
Get medium mix image
"""
folder = Paths().md_mixes_img_path
return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
@api.get("/mix/small/<imgpath>")
def send_sm_mix_image(path: ImagePath):
"""
Get small mix image
"""
folder = Paths().sm_mixes_img_path
return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
-124
View File
@@ -1,124 +0,0 @@
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import Field
from swingmusic.store.tracks import TrackStore
from swingmusic.api.apischemas import TrackHashSchema
from swingmusic.lib.lyrics import (
get_lyrics_file,
get_lyrics_from_duplicates,
get_lyrics_from_tags,
Lyrics as Lyrics_class,
)
from swingmusic.plugins.lyrics import Lyrics
bp_tag = Tag(name="Lyrics", description="Get lyrics")
api = APIBlueprint("lyrics", __name__, url_prefix="/lyrics", abp_tags=[bp_tag])
class SendLyricsBody(TrackHashSchema):
filepath: str = Field(description="The path to the file")
@api.post("")
def send_lyrics(body: SendLyricsBody):
"""
Returns the lyrics for a track
"""
# 1. try to get lyrics by .lrc / .elrc file
# 2. try to get lyrics by extra key
# 3. try to get by duplicates
# 4. iter plugins
filepath = body.filepath
trackhash = body.trackhash
# get copyright first
copyright = ""
if entry:=TrackStore.trackhashmap.get(trackhash, None):
for track in entry.tracks:
copyright = track.copyright
if copyright:
break
lyrics = get_lyrics_file(filepath)
if not lyrics:
lyrics = get_lyrics_from_tags(trackhash) # type: ignore
if not lyrics:
lyrics = get_lyrics_from_duplicates(filepath, trackhash)
# check lyrics plugins
if not lyrics:
try:
# Get track metadata for plugin search
entry = TrackStore.trackhashmap.get(trackhash, None)
if entry and len(entry.tracks) > 0:
track = entry.tracks[0] # Use first track for metadata
title = getattr(track, 'title', '') or ''
artist = ''
if hasattr(track, 'artists') and track.artists:
artist = track.artists[0].name if hasattr(track.artists[0], 'name') else str(track.artists[0])
album = ''
if hasattr(track, 'album') and track.album:
album = track.album.name if hasattr(track.album, 'name') else str(track.album)
# Only proceed if we have basic metadata
if title and artist:
# Initialize lyrics plugin
lyrics_plugin = Lyrics()
if lyrics_plugin.enabled:
# Search for lyrics using plugin
search_results = lyrics_plugin.search_lyrics_by_title_and_artist(title, artist)
if search_results and len(search_results) > 0:
# Use first result or perfect match
perfect_match = search_results[0]
# Try to find perfect match by comparing title and album
if album:
for result in search_results:
result_title = result.get("title", "").lower()
result_album = result.get("album", "").lower()
if (result_title == title.lower() and
result_album == album.lower()):
perfect_match = result
break
# Download lyrics using track ID
track_id = perfect_match.get("track_id")
if track_id:
lrc_content = lyrics_plugin.download_lyrics(track_id, filepath)
if lrc_content and len(lrc_content.strip()) > 0:
lyrics = Lyrics_class(lrc_content)
except Exception as e:
# Log error but don't break the lyrics fetching process
# In production, you might want to log this error
pass
if not lyrics:
return {"error": "No lyrics found"}
if lyrics.is_synced:
text = lyrics.format_synced_lyrics()
else:
text = lyrics.format_unsynced_lyrics()
return {"lyrics": text, "synced": lyrics.is_synced, "copyright": copyright}, 200
@api.post("/check")
def check_lyrics(body: SendLyricsBody):
"""
Checks if lyrics file or tag exists for a track
"""
result = send_lyrics(body)
if "error" in result:
return {"exists": False}
else:
return {"exists": True}, 200
-621
View File
@@ -1,621 +0,0 @@
"""
Mobile Offline Mode API Endpoints
This module provides REST API endpoints for mobile offline functionality,
including device management, sync operations, and offline library access.
"""
import logging
from datetime import datetime
from typing import Dict, List, Optional, Any
from flask import Blueprint, request, jsonify
from flask_login import login_required, current_user
from swingmusic.db import db
from swingmusic.services.mobile_offline_service import mobile_offline_service, OfflineQuality, SyncStatus
from swingmusic.utils.request import APIError, success_response, error_response
from swingmusic.utils.validators import validate_device_info, validate_track_ids
logger = logging.getLogger(__name__)
mobile_offline_bp = Blueprint('mobile_offline', __name__, url_prefix='/api/mobile-offline')
def get_current_user_id() -> int:
"""Get current user ID from Flask-Login"""
return current_user.id if current_user.is_authenticated else None
@mobile_offline_bp.route('/devices/register', methods=['POST'])
@login_required
async def register_device():
"""
Register a new mobile device for offline sync
Request Body:
{
"name": "iPhone 14 Pro",
"type": "ios",
"storage_capacity": 256000000000,
"available_storage": 128000000000,
"preferences": {
"auto_sync": true,
"wifi_only": true,
"quality": "balanced"
}
}
"""
try:
user_id = get_current_user_id()
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
# Validate device information
device_info = validate_device_info(data)
# Register device
device = await mobile_offline_service.register_device(user_id, device_info)
return success_response({
'message': 'Device registered successfully',
'device': {
'device_id': device.device_id,
'name': device.device_name,
'type': device.device_type,
'storage_capacity': device.storage_capacity,
'available_storage': device.available_storage,
'offline_quality': device.offline_quality.value,
'auto_sync_enabled': device.auto_sync_enabled,
'sync_status': device.sync_status.value,
'created_at': device.created_at.isoformat()
}
})
except Exception as e:
logger.error(f"Error registering device: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices', methods=['GET'])
@login_required
async def get_user_devices():
"""
Get all registered devices for the current user
"""
try:
user_id = get_current_user_id()
# This would get all devices for the user from database
# For now, return empty list as placeholder
devices = []
return success_response({
'devices': devices,
'total_count': len(devices)
})
except Exception as e:
logger.error(f"Error getting user devices: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices/<device_id>', methods=['GET'])
@login_required
async def get_device_info(device_id: str):
"""
Get specific device information
Path Parameters:
- device_id: Device ID
"""
try:
user_id = get_current_user_id()
device = await mobile_offline_service._get_device(device_id, user_id)
if not device:
return error_response("Device not found", 404)
return success_response({
'device': {
'device_id': device.device_id,
'name': device.device_name,
'type': device.device_type,
'storage_capacity': device.storage_capacity,
'available_storage': device.available_storage,
'last_sync': device.last_sync.isoformat() if device.last_sync else None,
'sync_status': device.sync_status.value,
'offline_quality': device.offline_quality.value,
'auto_sync_enabled': device.auto_sync_enabled,
'sync_preferences': device.sync_preferences,
'created_at': device.created_at.isoformat(),
'updated_at': device.updated_at.isoformat()
}
})
except Exception as e:
logger.error(f"Error getting device info: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices/<device_id>/settings', methods=['PUT'])
@login_required
async def update_device_settings(device_id: str):
"""
Update device settings
Path Parameters:
- device_id: Device ID
Request Body:
{
"offline_quality": "high_quality",
"auto_sync_enabled": true,
"sync_preferences": {
"wifi_only": true,
"auto_cleanup": true
},
"available_storage": 120000000000
}
"""
try:
user_id = get_current_user_id()
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
# Validate settings
if 'offline_quality' in data:
try:
OfflineQuality(data['offline_quality'])
except ValueError:
return error_response("Invalid offline quality", 400)
# Update settings
success = await mobile_offline_service.update_device_settings(user_id, device_id, data)
if not success:
return error_response("Failed to update device settings", 500)
return success_response({
'message': 'Device settings updated successfully'
})
except Exception as e:
logger.error(f"Error updating device settings: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices/<device_id>/offline-library', methods=['GET'])
@login_required
async def get_offline_library(device_id: str):
"""
Get offline library for device
Path Parameters:
- device_id: Device ID
Query Parameters:
- include_tracks: Include track details (default: true)
- include_queue: Include sync queue status (default: true)
- include_storage: Include storage usage (default: true)
"""
try:
user_id = get_current_user_id()
# Parse include flags
include_flags = {
'tracks': request.args.get('include_tracks', 'true').lower() == 'true',
'queue': request.args.get('include_queue', 'true').lower() == 'true',
'storage': request.args.get('include_storage', 'true').lower() == 'true'
}
# Get offline library
library_data = await mobile_offline_service.get_offline_library(user_id, device_id)
# Build response based on include flags
response_data = {
'device': library_data['device'],
'last_sync': library_data['last_sync'],
'sync_status': library_data['sync_status']
}
if include_flags['tracks']:
response_data['offline_tracks'] = library_data['offline_tracks']
if include_flags['queue']:
response_data['sync_queue'] = library_data['sync_queue']
if include_flags['storage']:
response_data['storage_usage'] = library_data['storage_usage']
return success_response({
'offline_library': response_data
})
except Exception as e:
logger.error(f"Error getting offline library: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices/<device_id>/add-tracks', methods=['POST'])
@login_required
async def add_tracks_to_offline(device_id: str):
"""
Add tracks to offline library
Path Parameters:
- device_id: Device ID
Request Body:
{
"track_ids": ["track1", "track2", "track3"],
"quality": "high_quality"
}
"""
try:
user_id = get_current_user_id()
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
track_ids = data.get('track_ids', [])
if not track_ids:
return error_response("track_ids are required", 400)
# Validate track IDs
validate_track_ids(track_ids)
# Parse quality
quality = None
if 'quality' in data:
try:
quality = OfflineQuality(data['quality'])
except ValueError:
return error_response("Invalid quality", 400)
# Add tracks to offline library
queue_items = await mobile_offline_service.add_to_offline_library(
user_id, device_id, track_ids, quality
)
return success_response({
'message': f'Added {len(queue_items)} tracks to offline library',
'queue_items': [
{
'queue_id': item.queue_id,
'track_id': item.track_id,
'priority': item.priority,
'quality': item.quality,
'status': item.status,
'added_at': item.added_at.isoformat()
}
for item in queue_items
]
})
except Exception as e:
logger.error(f"Error adding tracks to offline library: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices/<device_id>/sync-playlist/<playlist_id>', methods=['POST'])
@login_required
async def sync_playlist_offline(device_id: str, playlist_id: str):
"""
Sync entire playlist for offline playback
Path Parameters:
- device_id: Device ID
- playlist_id: Playlist ID
Request Body:
{
"quality": "balanced"
}
"""
try:
user_id = get_current_user_id()
data = request.get_json() or {}
# Parse quality
quality = None
if 'quality' in data:
try:
quality = OfflineQuality(data['quality'])
except ValueError:
return error_response("Invalid quality", 400)
# Sync playlist
queue_items = await mobile_offline_service.sync_playlist_offline(
user_id, device_id, playlist_id, quality
)
return success_response({
'message': f'Playlist sync started with {len(queue_items)} tracks',
'queue_items': [
{
'queue_id': item.queue_id,
'track_id': item.track_id,
'priority': item.priority,
'quality': item.quality,
'status': item.status,
'added_at': item.added_at.isoformat()
}
for item in queue_items
]
})
except Exception as e:
logger.error(f"Error syncing playlist offline: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices/<device_id>/remove-tracks', methods=['POST'])
@login_required
async def remove_tracks_from_offline(device_id: str):
"""
Remove tracks from offline library
Path Parameters:
- device_id: Device ID
Request Body:
{
"track_ids": ["track1", "track2", "track3"]
}
"""
try:
user_id = get_current_user_id()
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
track_ids = data.get('track_ids', [])
if not track_ids:
return error_response("track_ids are required", 400)
# Validate track IDs
validate_track_ids(track_ids)
# Remove tracks
success = await mobile_offline_service.remove_from_offline_library(
user_id, device_id, track_ids
)
if not success:
return error_response("Failed to remove tracks from offline library", 500)
return success_response({
'message': f'Removed {len(track_ids)} tracks from offline library'
})
except Exception as e:
logger.error(f"Error removing tracks from offline library: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices/<device_id>/sync-progress', methods=['GET'])
@login_required
async def get_sync_progress(device_id: str):
"""
Get sync progress for device
Path Parameters:
- device_id: Device ID
"""
try:
user_id = get_current_user_id()
progress_data = await mobile_offline_service.get_sync_progress(user_id, device_id)
return success_response({
'sync_progress': progress_data
})
except Exception as e:
logger.error(f"Error getting sync progress: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices/<device_id>/force-sync', methods=['POST'])
@login_required
async def force_sync_now(device_id: str):
"""
Force immediate sync for device
Path Parameters:
- device_id: Device ID
"""
try:
user_id = get_current_user_id()
success = await mobile_offline_service.force_sync_now(user_id, device_id)
if not success:
return error_response("Failed to force sync", 500)
return success_response({
'message': 'Sync started successfully'
})
except Exception as e:
logger.error(f"Error forcing sync: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices/<device_id>/storage-info', methods=['GET'])
@login_required
async def get_storage_info(device_id: str):
"""
Get detailed storage information for device
Path Parameters:
- device_id: Device ID
"""
try:
user_id = get_current_user_id()
# Get device info
device = await mobile_offline_service._get_device(device_id, user_id)
if not device:
return error_response("Device not found", 404)
# Get storage usage
storage_usage = await mobile_offline_service._get_storage_usage(device_id)
# Calculate additional info
usage_percentage = (storage_usage.used_space / storage_usage.total_capacity * 100) if storage_usage.total_capacity > 0 else 0
return success_response({
'storage_info': {
'total_capacity': storage_usage.total_capacity,
'used_space': storage_usage.used_space,
'available_space': storage_usage.available_space,
'usage_percentage': round(usage_percentage, 2),
'offline_tracks_count': storage_usage.offline_tracks_count,
'offline_tracks_size': storage_usage.offline_tracks_size,
'other_data_size': storage_usage.other_data_size,
'quality_breakdown': storage_usage.quality_breakdown,
'needs_cleanup': usage_percentage > 90,
'recommendations': _get_storage_recommendations(usage_percentage, storage_usage)
}
})
except Exception as e:
logger.error(f"Error getting storage info: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices/<device_id>/cleanup', methods=['POST'])
@login_required
async def cleanup_storage(device_id: str):
"""
Cleanup storage by removing old/unused content
Path Parameters:
- device_id: Device ID
Request Body:
{
"strategy": "least_played|oldest|all",
"free_space_bytes": 1000000000
}
"""
try:
user_id = get_current_user_id()
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
strategy = data.get('strategy', 'least_played')
free_space_bytes = data.get('free_space_bytes', 0)
# Validate strategy
valid_strategies = ['least_played', 'oldest', 'all']
if strategy not in valid_strategies:
return error_response(f"Invalid strategy. Must be one of: {valid_strategies}", 400)
# Perform cleanup
# This would implement the actual cleanup logic
freed_space = await mobile_offline_service._cleanup_old_content(device_id, free_space_bytes)
return success_response({
'message': f'Cleanup completed',
'freed_space': freed_space,
'strategy_used': strategy
})
except Exception as e:
logger.error(f"Error during cleanup: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/quality-presets', methods=['GET'])
@login_required
async def get_quality_presets():
"""
Get available quality presets for offline downloads
"""
try:
presets = {
'space_saver': {
'name': 'Space Saver',
'description': 'Low quality, maximum storage efficiency',
'estimated_size_per_track': '3MB',
'recommended_for': 'Limited storage, large libraries',
'formats': ['MP3 128kbps', 'AAC 128kbps']
},
'balanced': {
'name': 'Balanced',
'description': 'Medium quality, good balance',
'estimated_size_per_track': '6MB',
'recommended_for': 'Most users, good quality',
'formats': ['MP3 256kbps', 'AAC 256kbps']
},
'high_quality': {
'name': 'High Quality',
'description': 'High quality, more storage usage',
'estimated_size_per_track': '12MB',
'recommended_for': 'Audiophiles, premium headphones',
'formats': ['MP3 320kbps', 'AAC 320kbps', 'OGG Vorbis']
},
'lossless': {
'name': 'Lossless',
'description': 'Lossless quality, maximum storage usage',
'estimated_size_per_track': '30MB',
'recommended_for': 'Critical listening, unlimited storage',
'formats': ['FLAC', 'ALAC', 'WAV']
}
}
return success_response({
'quality_presets': presets
})
except Exception as e:
logger.error(f"Error getting quality presets: {e}")
return error_response("Internal server error", 500)
def _get_storage_recommendations(usage_percentage: float, storage_usage) -> List[str]:
"""Get storage recommendations based on usage"""
recommendations = []
if usage_percentage > 95:
recommendations.extend([
"Critical: Storage almost full",
"Remove least played tracks immediately",
"Consider upgrading to higher capacity device"
])
elif usage_percentage > 90:
recommendations.extend([
"Storage nearly full",
"Enable auto-cleanup settings",
"Remove old or rarely played tracks"
])
elif usage_percentage > 80:
recommendations.extend([
"Storage getting full",
"Consider using space saver quality",
"Review offline library regularly"
])
elif usage_percentage > 70:
recommendations.extend([
"Moderate storage usage",
"Monitor storage regularly",
"Consider quality adjustments"
])
else:
recommendations.extend([
"Storage usage is healthy",
"Continue current settings",
"Consider adding more content if desired"
])
return recommendations
-467
View File
@@ -1,467 +0,0 @@
"""
Music Catalog API for SwingMusic
Provides Spotify-like browsing of global music catalog with download capabilities
"""
from flask import Blueprint, request, jsonify
from typing import Dict, List, Any, Optional
import asyncio
from swingmusic.services.music_catalog import music_catalog_service
from swingmusic import logger
from swingmusic.db.spotify import UserCatalogPreferencesTable
# Create blueprint
music_catalog_bp = Blueprint('music_catalog', __name__, url_prefix='/api/catalog')
@music_catalog_bp.route('/artist/<artist_id>/top-tracks', methods=['GET'])
def get_artist_top_tracks(artist_id: str):
"""
Get artist's most popular tracks
Query parameters:
- limit: Maximum number of tracks (default: 15, max: 50)
- user_id: User ID for preferences
"""
try:
limit = min(request.args.get('limit', 15, type=int), 50)
user_id = request.args.get('user_id', type=int)
# Get user preferences if available
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
limit = min(limit, user_prefs.max_top_tracks)
# Run async operation
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
tracks = loop.run_until_complete(
music_catalog_service.get_artist_top_tracks(artist_id, limit)
)
return jsonify({
'tracks': [track.__dict__ for track in tracks],
'total': len(tracks)
})
except Exception as e:
logger.error(f"Error getting artist top tracks: {e}")
return jsonify({'error': 'Failed to get artist top tracks'}), 500
@music_catalog_bp.route('/artist/<artist_id>/albums', methods=['GET'])
def get_artist_discography(artist_id: str):
"""
Get complete artist discography with albums
Query parameters:
- limit: Maximum number of albums (default: 20, max: 50)
- user_id: User ID for preferences
"""
try:
limit = min(request.args.get('limit', 20, type=int), 50)
user_id = request.args.get('user_id', type=int)
# Get user preferences if available
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
limit = min(limit, user_prefs.max_albums_per_artist)
# Run async operation
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
albums = loop.run_until_complete(
music_catalog_service.get_artist_discography(artist_id)
)
# Apply limit
albums = albums[:limit]
return jsonify({
'albums': [album.__dict__ for album in albums],
'total': len(albums)
})
except Exception as e:
logger.error(f"Error getting artist discography: {e}")
return jsonify({'error': 'Failed to get artist discography'}), 500
@music_catalog_bp.route('/artist/<artist_id>', methods=['GET'])
def get_artist_info(artist_id: str):
"""
Get comprehensive artist information including top tracks and albums
Query parameters:
- user_id: User ID for preferences
"""
try:
user_id = request.args.get('user_id', type=int)
# Run async operation
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
artist_info = loop.run_until_complete(
music_catalog_service.get_artist_info(artist_id)
)
if not artist_info:
return jsonify({'error': 'Artist not found'}), 404
return jsonify({
'spotify_id': artist_info.spotify_id,
'name': artist_info.name,
'image_url': artist_info.image_url,
'followers': artist_info.followers,
'popularity': artist_info.popularity,
'genres': artist_info.genres or [],
'top_tracks': [track.__dict__ for track in (artist_info.top_tracks or [])],
'albums': [album.__dict__ for album in (artist_info.albums or [])],
'related_artists': artist_info.related_artists or []
})
except Exception as e:
logger.error(f"Error getting artist info: {e}")
return jsonify({'error': 'Failed to get artist info'}), 500
@music_catalog_bp.route('/album/<album_id>', methods=['GET'])
def get_album_details(album_id: str):
"""
Get full album information with tracklist
Query parameters:
- user_id: User ID for preferences
"""
try:
user_id = request.args.get('user_id', type=int)
# Run async operation
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
album = loop.run_until_complete(
music_catalog_service.get_album_details(album_id)
)
if not album:
return jsonify({'error': 'Album not found'}), 404
return jsonify(album.__dict__)
except Exception as e:
logger.error(f"Error getting album details: {e}")
return jsonify({'error': 'Failed to get album details'}), 500
@music_catalog_bp.route('/search', methods=['POST'])
def search_catalog():
"""
Search across global music catalog
Request body:
{
"query": "search query",
"type": "all|tracks|albums|artists|playlists",
"limit": 20,
"user_id": 1
}
"""
try:
data = request.get_json()
if not data or not data.get('query'):
return jsonify({'error': 'Search query is required'}), 400
query = data['query'].strip()
search_type = data.get('type', 'all')
limit = min(data.get('limit', 20), 50) # Cap at 50
user_id = data.get('user_id')
# Validate search type
valid_types = ['all', 'tracks', 'albums', 'artists', 'playlists']
if search_type not in valid_types:
return jsonify({'error': f'Invalid search type. Must be one of: {valid_types}'}), 400
# Get user preferences if available
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
limit = min(limit, user_prefs.max_search_results)
# Run async search
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(
music_catalog_service.search_global_catalog(query, search_type, limit)
)
return jsonify({
'tracks': [track.__dict__ for track in result.tracks],
'albums': [album.__dict__ for album in result.albums],
'artists': [artist.__dict__ for artist in result.artists],
'playlists': [playlist.__dict__ for playlist in result.playlists],
'total': result.total,
'query': result.query
})
except Exception as e:
logger.error(f"Error searching catalog: {e}")
return jsonify({'error': 'Failed to search catalog'}), 500
@music_catalog_bp.route('/trending', methods=['GET'])
def get_trending_content():
"""
Get trending/popular content from global catalog
Query parameters:
- type: "tracks|albums|artists" (default: "tracks")
- limit: Maximum results (default: 20, max: 50)
- user_id: User ID for preferences
"""
try:
content_type = request.args.get('type', 'tracks')
limit = min(request.args.get('limit', 20, type=int), 50)
user_id = request.args.get('user_id', type=int)
# Validate content type
valid_types = ['tracks', 'albums', 'artists']
if content_type not in valid_types:
return jsonify({'error': f'Invalid type. Must be one of: {valid_types}'}), 400
# Get user preferences if available
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
limit = min(limit, user_prefs.max_trending_results)
# For now, search for popular content with generic queries
# This could be enhanced with actual trending data from Spotify API
trending_queries = {
'tracks': 'popular hits 2024',
'albums': 'new releases 2024',
'artists': 'popular artists'
}
query = trending_queries.get(content_type, 'popular')
# Run async search
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(
music_catalog_service.search_global_catalog(query, content_type, limit)
)
# Return only the requested type
response = {
'type': content_type,
'total': len(getattr(result, content_type)),
'query': query
}
if content_type == 'tracks':
response['tracks'] = [track.__dict__ for track in result.tracks]
elif content_type == 'albums':
response['albums'] = [album.__dict__ for album in result.albums]
elif content_type == 'artists':
response['artists'] = [artist.__dict__ for artist in result.artists]
return jsonify(response)
except Exception as e:
logger.error(f"Error getting trending content: {e}")
return jsonify({'error': 'Failed to get trending content'}), 500
@music_catalog_bp.route('/recommendations', methods=['POST'])
def get_recommendations():
"""
Get personalized recommendations based on seeds
Request body:
{
"seed_artists": ["artist_id1", "artist_id2"],
"seed_tracks": ["track_id1", "track_id2"],
"seed_genres": ["rock", "pop"],
"limit": 20,
"user_id": 1
}
"""
try:
data = request.get_json()
if not data:
return jsonify({'error': 'Request body is required'}), 400
seed_artists = data.get('seed_artists', [])
seed_tracks = data.get('seed_tracks', [])
seed_genres = data.get('seed_genres', [])
limit = min(data.get('limit', 20), 50)
user_id = data.get('user_id')
# Validate at least one seed type
if not any([seed_artists, seed_tracks, seed_genres]):
return jsonify({'error': 'At least one seed type must be provided'}), 400
# Get user preferences if available
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
limit = min(limit, user_prefs.max_recommendations)
# For now, generate recommendations based on seed artists
# This could be enhanced with Spotify's recommendations API
recommendations = []
if seed_artists:
# Get top tracks from seed artists
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
for artist_id in seed_artists[:5]: # Limit to 5 artists
try:
tracks = loop.run_until_complete(
music_catalog_service.get_artist_top_tracks(artist_id, 5)
)
recommendations.extend(tracks)
except Exception as e:
logger.warning(f"Failed to get tracks for artist {artist_id}: {e}")
# Remove duplicates and apply limit
seen_ids = set()
unique_recommendations = []
for track in recommendations:
if track.spotify_id not in seen_ids:
seen_ids.add(track.spotify_id)
unique_recommendations.append(track)
if len(unique_recommendations) >= limit:
break
return jsonify({
'tracks': [track.__dict__ for track in unique_recommendations],
'total': len(unique_recommendations),
'seeds': {
'artists': seed_artists,
'tracks': seed_tracks,
'genres': seed_genres
}
})
except Exception as e:
logger.error(f"Error getting recommendations: {e}")
return jsonify({'error': 'Failed to get recommendations'}), 500
@music_catalog_bp.route('/preferences/<int:user_id>', methods=['GET', 'POST'])
def user_catalog_preferences(user_id: int):
"""
Get or update user's catalog preferences
GET: Returns user preferences
POST: Updates user preferences
POST request body:
{
"max_search_results": 50,
"max_top_tracks": 15,
"max_albums_per_artist": 20,
"max_trending_results": 20,
"max_recommendations": 20,
"show_explicit": true,
"preferred_markets": ["US", "GB", "DE"]
}
"""
try:
if request.method == 'GET':
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
return jsonify({
'user_id': user_id,
'max_search_results': user_prefs.max_search_results,
'max_top_tracks': user_prefs.max_top_tracks,
'max_albums_per_artist': user_prefs.max_albums_per_artist,
'max_trending_results': user_prefs.max_trending_results,
'max_recommendations': user_prefs.max_recommendations,
'show_explicit': user_prefs.show_explicit,
'preferred_markets': user_prefs.preferred_markets or []
})
else: # POST
data = request.get_json()
if not data:
return jsonify({'error': 'Request body is required'}), 400
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
# Update preferences
if 'max_search_results' in data:
user_prefs.max_search_results = min(data['max_search_results'], 100)
if 'max_top_tracks' in data:
user_prefs.max_top_tracks = min(data['max_top_tracks'], 50)
if 'max_albums_per_artist' in data:
user_prefs.max_albums_per_artist = min(data['max_albums_per_artist'], 100)
if 'max_trending_results' in data:
user_prefs.max_trending_results = min(data['max_trending_results'], 100)
if 'max_recommendations' in data:
user_prefs.max_recommendations = min(data['max_recommendations'], 100)
if 'show_explicit' in data:
user_prefs.show_explicit = bool(data['show_explicit'])
if 'preferred_markets' in data:
user_prefs.preferred_markets = data['preferred_markets']
user_prefs.save()
return jsonify({'message': 'Preferences updated successfully'})
except Exception as e:
logger.error(f"Error handling catalog preferences: {e}")
return jsonify({'error': 'Failed to handle preferences'}), 500
@music_catalog_bp.route('/cache/cleanup', methods=['POST'])
def cleanup_cache():
"""
Clean up expired cache entries
This is typically called by a background job
"""
try:
music_catalog_service.cleanup_expired_cache()
return jsonify({'message': 'Cache cleanup completed'})
except Exception as e:
logger.error(f"Error cleaning up cache: {e}")
return jsonify({'error': 'Failed to cleanup cache'}), 500
@music_catalog_bp.route('/health', methods=['GET'])
def health_check():
"""
Health check endpoint for the music catalog service
"""
try:
# Check if the service is accessible
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Simple test - try to get a popular artist's top tracks
# Using a well-known artist ID for testing
test_result = loop.run_until_complete(
music_catalog_service.get_artist_top_tracks("4q3ewHC7JlriWjjK2XsvrO", 1) # Daft Punk
)
return jsonify({
'status': 'healthy',
'service': 'music_catalog',
'test_query_success': len(test_result) > 0
})
except Exception as e:
logger.error(f"Health check failed: {e}")
return jsonify({
'status': 'unhealthy',
'service': 'music_catalog',
'error': str(e)
}), 500
-484
View File
@@ -1,484 +0,0 @@
"""
All playlist-related routes.
"""
import json
from datetime import datetime
import pathlib
from typing import Any
from PIL import UnidentifiedImageError, Image
from pydantic_core import core_schema
from pydantic import BaseModel, Field, GetCoreSchemaHandler
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint, FileStorage as _FileStorage
from swingmusic import models
from swingmusic.api.apischemas import GenericLimitSchema
from swingmusic.db.userdata import PlaylistTable
from swingmusic.lib import playlistlib
from swingmusic.lib.albumslib import sort_by_track_no
from swingmusic.lib.home.recentlyadded import get_recently_added_playlist
from swingmusic.lib.home.recentlyplayed import get_recently_played_playlist
from swingmusic.lib.sortlib import sort_tracks
from swingmusic.models.playlist import Playlist
from swingmusic.serializers.playlist import serialize_for_card
from swingmusic.serializers.track import serialize_tracks
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.dates import create_new_date, date_string_to_time_passed
from swingmusic.settings import Paths
tag = Tag(name="Playlists", description="Get and manage playlists")
api = APIBlueprint("playlists", __name__, url_prefix="/playlists", abp_tags=[tag])
def insert_playlist(name: str, image: str = None):
playlist = {
"image": image,
"last_updated": create_new_date(),
"name": name,
"trackhashes": [],
"settings": {
"has_gif": False,
"banner_pos": 50,
"square_img": True if image else False,
"pinned": False,
},
}
rowid = PlaylistTable.add_one(playlist)
if rowid:
playlist["id"] = rowid
return Playlist(**playlist)
return None
def get_path_trackhashes(path: str, tracksortby: str, reverse: bool):
"""
Returns a list of trackhashes in a folder.
"""
tracks = TrackStore.get_tracks_in_path(path)
tracks = sort_tracks(tracks, key=tracksortby, reverse=reverse)
return [t.trackhash for t in tracks]
def get_album_trackhashes(albumhash: str):
"""
Returns a list of trackhashes in an album.
"""
tracks = TrackStore.get_tracks_by_albumhash(albumhash)
tracks = sort_by_track_no(tracks)
return [t.trackhash for t in tracks]
def get_artist_trackhashes(artisthash: str):
"""
Returns a list of trackhashes for an artist.
"""
tracks = TrackStore.get_tracks_by_artisthash(artisthash)
tracks = sort_tracks(tracks, key="playcount", reverse=True)
return [t.trackhash for t in tracks]
def format_custom_playlist(playlist: models.Playlist, tracks: list[models.Track]):
playlist.duration = sum(t.duration for t in tracks)
playlist.count = len(tracks)
return {
"info": serialize_for_card(playlist),
"tracks": serialize_tracks(tracks),
}
class SendAllPlaylistsQuery(BaseModel):
no_images: bool = Field(False, description="Whether to include images")
@api.get("")
def send_all_playlists(query: SendAllPlaylistsQuery):
"""
Gets all the playlists.
"""
playlists = PlaylistTable.get_all()
playlists = sorted(
playlists,
key=lambda p: datetime.strptime(p.last_updated, "%Y-%m-%d %H:%M:%S"),
reverse=True,
)
for playlist in playlists:
if not playlist.has_image:
playlist.images = playlistlib.get_first_4_images(
trackhashes=playlist.trackhashes
)
playlist.clear_lists()
# playlists.sort(
# key=lambda p: datetime.strptime(p.last_updated, "%Y-%m-%d %H:%M:%S"),
# reverse=True,
# )
return {"data": playlists}
class CreatePlaylistBody(BaseModel):
name: str = Field(..., description="The name of the playlist")
@api.post("/new")
def create_playlist(body: CreatePlaylistBody):
"""
New playlist
Creates a new playlist. Accepts POST method with a JSON body.
"""
exists = PlaylistTable.check_exists_by_name(body.name)
if exists:
return {"error": "Playlist already exists"}, 409
playlist = insert_playlist(body.name)
if playlist is None:
return {"error": "Playlist could not be created"}, 500
return {"playlist": playlist}, 201
class PlaylistIDPath(BaseModel):
# INFO: playlistid string examples: "recentlyadded"
playlistid: str = Field(..., description="The ID of the playlist")
class AddItemToPlaylistBody(BaseModel):
itemtype: str = Field(
default="tracks",
description="The type of item to add",
examples=["tracks", "folder", "album", "artist"],
)
sortoptions: dict = Field(
default=None,
description="The sort options for the tracks",
)
itemhash: str = Field(..., description="The hash of the item to add")
@api.post("/<playlistid>/add")
def add_item_to_playlist(path: PlaylistIDPath, body: AddItemToPlaylistBody):
"""
Add to playlist.
If itemtype is not "tracks", itemhash is expected to be a folder, album or artist hash.
"""
itemtype = body.itemtype
itemhash = body.itemhash
playlist_id = int(path.playlistid)
sortoptions = body.sortoptions
if itemtype == "tracks":
trackhashes = itemhash.split(",")
if len(trackhashes) == 1 and trackhashes[0] in PlaylistTable.get_trackhashes(playlist_id):
return {"msg": "Track already exists in playlist"}, 409
elif itemtype == "folder":
trackhashes = get_path_trackhashes(
itemhash,
sortoptions.get("tracksortby") or "default",
sortoptions.get("tracksortreverse") or False,
)
elif itemtype == "album":
trackhashes = get_album_trackhashes(itemhash)
elif itemtype == "artist":
trackhashes = get_artist_trackhashes(itemhash)
else:
trackhashes = []
PlaylistTable.append_to_playlist(playlist_id, trackhashes)
return {"msg": "Done"}, 200
class GetPlaylistQuery(GenericLimitSchema):
no_tracks: bool = Field(False, description="Whether to include tracks")
start: int = Field(0, description="The start index of the tracks")
@api.get("/<playlistid>")
def get_playlist(path: PlaylistIDPath, query: GetPlaylistQuery):
"""
Get playlist by id
"""
no_tracks = query.no_tracks
playlistid = path.playlistid
custom_playlists = [
{"name": "recentlyadded", "handler": get_recently_added_playlist},
{"name": "recentlyplayed", "handler": get_recently_played_playlist},
]
is_custom = playlistid in {p["name"] for p in custom_playlists}
if is_custom:
if query.start != 0:
return {
"tracks": [],
}
handler = next(
p["handler"] for p in custom_playlists if p["name"] == playlistid
)
playlist, tracks = handler()
return format_custom_playlist(playlist, tracks)
playlist = PlaylistTable.get_by_id(int(playlistid))
if playlist is None:
return {"msg": "Playlist not found"}, 404
if query.limit == -1:
query.limit = len(playlist.trackhashes) - 1
tracks = TrackStore.get_tracks_by_trackhashes(
playlist.trackhashes[query.start : query.start + query.limit]
)
duration = sum(t.duration for t in tracks)
playlist._last_updated = date_string_to_time_passed(playlist.last_updated)
playlist.duration = duration
playlist.images = playlistlib.get_first_4_images(tracks)
playlist.clear_lists()
return {
"info": playlist,
"tracks": serialize_tracks(tracks) if not no_tracks else [],
}
class FileStorage(_FileStorage):
@classmethod
def __get_pydantic_core_schema__(
cls, _source: Any, handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
return core_schema.with_info_plain_validator_function(cls.validate)
class UpdatePlaylistForm(BaseModel):
image: FileStorage = Field(description="The image file")
name: str = Field(..., description="The name of the playlist")
settings: str = Field(
...,
description="The settings of the playlist",
json_schema_extra={
"example": '{"has_gif": false, "banner_pos": 50, "square_img": false, "pinned": false}'
},
)
@api.put("/<playlistid>/update", methods=["PUT"])
def update_playlist_info(path: PlaylistIDPath, form: UpdatePlaylistForm):
"""
Update playlist
"""
playlistid = path.playlistid
db_playlist = PlaylistTable.get_by_id(playlistid)
if db_playlist is None:
return {"error": "Playlist not found"}, 404
image = form.image
if form.image:
image = form.image
settings = json.loads(form.settings)
settings["has_gif"] = False
playlist = {
"id": int(playlistid),
"image": db_playlist.image,
"last_updated": create_new_date(),
"name": str(form.name).strip(),
"settings": settings,
}
if image:
try:
pil_image = Image.open(image)
content_type = image.content_type
playlist["image"] = playlistlib.save_p_image(
pil_image, playlistid, content_type
)
if image.content_type == "image/gif":
playlist["settings"]["has_gif"] = True
except UnidentifiedImageError:
return {"error": "Failed: Invalid image"}, 400
p_tuple = (*playlist.values(),)
PlaylistTable.update_one(playlistid, playlist)
playlistlib.cleanup_playlist_images()
playlist = models.Playlist(*p_tuple)
playlist.last_updated = date_string_to_time_passed(playlist.last_updated)
return {
"data": playlist,
}
@api.post("/<playlistid>/pin_unpin")
def pin_unpin_playlist(path: PlaylistIDPath):
"""
Pin playlist.
"""
playlist = PlaylistTable.get_by_id(path.playlistid)
if playlist is None:
return {"error": "Playlist not found"}, 404
settings = playlist.settings
try:
settings["pinned"] = not settings["pinned"]
except KeyError:
settings["pinned"] = True
PlaylistTable.update_settings(path.playlistid, settings)
return {"msg": "Done"}, 200
@api.delete("/<playlistid>/remove-img")
def remove_playlist_image(path: PlaylistIDPath):
"""
Clear playlist image.
"""
playlist = PlaylistTable.get_by_id(path.playlistid)
if playlist is None:
return {"error": "Playlist not found"}, 404
PlaylistTable.remove_image(path.playlistid)
playlist.image = None
playlist.thumb = None
playlist.settings["has_gif"] = False
playlist.has_image = False
playlist.images = playlistlib.get_first_4_images(trackhashes=playlist.trackhashes)
playlist.last_updated = date_string_to_time_passed(playlist.last_updated)
return {"playlist": playlist}, 200
@api.delete("/<playlistid>/delete", methods=["DELETE"])
def remove_playlist(path: PlaylistIDPath):
"""
Delete playlist
"""
PlaylistTable.remove_one(path.playlistid)
playlistlib.cleanup_playlist_images()
return {"msg": "Done"}, 200
class RemoveTracksFromPlaylistBody(BaseModel):
tracks: list[dict] = Field(..., description="A list of trackhashes to remove")
@api.post("/<playlistid>/remove-tracks")
def remove_tracks_from_playlist(
path: PlaylistIDPath, body: RemoveTracksFromPlaylistBody
):
"""
Remove track from playlist
"""
# A track looks like this:
# {
# trackhash: str;
# index: int;
# }
PlaylistTable.remove_from_playlist(path.playlistid, body.tracks)
return {"msg": "Done"}, 200
class SavePlaylistAsItemBody(BaseModel):
itemtype: str = Field(..., description="The type of item", example="tracks")
playlist_name: str = Field(..., description="The name of the playlist")
itemhash: str = Field(..., description="The hash of the item to save")
sortoptions: dict = Field(
default=dict(),
description="The sort options for the tracks",
)
@api.post("/save-item")
def save_item_as_playlist(body: SavePlaylistAsItemBody):
"""
Save as playlist
Saves a track, album, artist or folder as a playlist
"""
itemtype = body.itemtype
playlist_name = body.playlist_name
itemhash = body.itemhash
sortoptions = body.sortoptions
if PlaylistTable.check_exists_by_name(playlist_name):
return {"error": "Playlist already exists"}, 409
if itemtype == "tracks":
trackhashes = itemhash.split(",")
elif itemtype == "folder":
trackhashes = get_path_trackhashes(
itemhash,
sortoptions.get("tracksortby") or "default",
sortoptions.get("tracksortreverse") or False,
)
elif itemtype == "album":
trackhashes = get_album_trackhashes(itemhash)
elif itemtype == "artist":
trackhashes = get_artist_trackhashes(itemhash)
else:
trackhashes = []
if len(trackhashes) == 0:
return {"error": "No tracks founds"}, 404
image = (
itemhash + ".webp" if itemtype != "folder" and itemtype != "tracks" else None
)
playlist = insert_playlist(playlist_name, image)
if playlist is None:
return {"error": "Playlist could not be created"}, 500
# save image
if itemtype != "folder" and itemtype != "tracks":
filename = itemhash + ".webp"
base_path = (
Paths().lg_artist_img_path
if itemtype == "artist"
else Paths().lg_thumb_path()
)
img_path = pathlib.Path(base_path + "/" + filename)
if img_path.exists():
img = Image.open(img_path)
playlistlib.save_p_image(
img, str(playlist.id), "image/webp", filename=filename
)
PlaylistTable.append_to_playlist(playlist.id, trackhashes)
playlist.count = len(trackhashes)
images = playlistlib.get_first_4_images(trackhashes=trackhashes)
playlist.images = [img["image"] for img in images]
return {"playlist": playlist}, 201
-103
View File
@@ -1,103 +0,0 @@
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import BaseModel, Field
from swingmusic.api.auth import admin_required
from swingmusic.config import UserConfig
from swingmusic.db.userdata import PluginTable
from swingmusic.plugins.lastfm import LastFmPlugin
from swingmusic.utils.auth import get_current_userid
bp_tag = Tag(name="Plugins", description="Manage plugins")
api = APIBlueprint("plugins", __name__, url_prefix="/plugins", abp_tags=[bp_tag])
@api.get("/")
def get_all_plugins():
"""
List all plugins
"""
plugins = PluginTable.get_all()
return {"plugins": plugins}
class PluginBody(BaseModel):
plugin: str = Field(description="The plugin name", example="lyrics")
class PluginActivateBody(PluginBody):
active: bool = Field(
description="New plugin active state", example=False, default=False
)
@api.post("/setactive")
@admin_required()
def activate_deactivate_plugin(body: PluginActivateBody):
"""
Activate/Deactivate plugin
"""
name = body.plugin
PluginTable.activate(name, body.active)
return {"message": "OK"}, 200
class PluginSettingsBody(PluginBody):
settings: dict = Field(
description="The new plugin settings", example={"key": "value"}
)
@api.post("/settings")
@admin_required()
def update_plugin_settings(body: PluginSettingsBody):
"""
Update plugin settings
"""
plugin = body.plugin
settings = body.settings
if not plugin or not settings:
return {"error": "Missing plugin or settings"}, 400
PluginTable.update_settings(plugin, settings)
plugin = PluginTable.get_by_name(plugin)
return {"status": "success", "settings": plugin.settings}
class LastFmSessionBody(BaseModel):
token: str = Field(description="The token to use to create the session")
@api.post("/lastfm/session/create")
def create_lastfm_session(body: LastFmSessionBody):
"""
Create a Last.fm session
"""
if not body.token:
return {"error": "Missing token"}, 400
lastfm = LastFmPlugin(current_userid=get_current_userid())
session_key = lastfm.get_session_key(body.token)
if session_key:
config = UserConfig()
current_user = get_current_userid()
config.lastfmSessionKeys[str(current_user)] = session_key
config.lastfmSessionKeys = config.lastfmSessionKeys
return {"status": "success", "session_key": session_key}
@api.post("/lastfm/session/delete")
def delete_lastfm_session():
"""
Delete the Last.fm session
"""
config = UserConfig()
current_user = get_current_userid()
config.lastfmSessionKeys[str(current_user)] = ""
config.lastfmSessionKeys = config.lastfmSessionKeys
return {"status": "success"}
-64
View File
@@ -1,64 +0,0 @@
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import Field
from swingmusic.api.apischemas import TrackHashSchema
from swingmusic.lib.lyrics import Lyrics as Lyrics_class
from swingmusic.plugins.lyrics import Lyrics
from swingmusic.settings import Defaults
from swingmusic.utils.hashing import create_hash
bp_tag = Tag(name="Lyrics Plugin", description="Musixmatch lyrics plugin")
api = APIBlueprint(
"lyricsplugin", __name__, url_prefix="/plugins/lyrics", abp_tags=[bp_tag]
)
class LyricsSearchBody(TrackHashSchema):
title: str = Field(description="The track title ", example=Defaults.API_TRACKNAME)
artist: str = Field(description="The track artist ", example=Defaults.API_ARTISTNAME)
album: str = Field(description="The track track album ", example=Defaults.API_ALBUMNAME)
filepath: str = Field(
description="Track filepath to save the lyrics file relative to",
example="/home/cwilvx/temp/crazy song.mp3",
)
@api.post("/search")
def search_lyrics(body: LyricsSearchBody):
"""
Search for lyrics by title and artist
"""
title = body.title
artist = body.artist
album = body.album
filepath = body.filepath
trackhash = body.trackhash
finder = Lyrics()
data = finder.search_lyrics_by_title_and_artist(title, artist)
if not data:
return {"trackhash": trackhash, "lyrics": None}
perfect_match = data[0]
for track in data:
i_title = track["title"]
i_album = track["album"]
if create_hash(i_title) == create_hash(title) and create_hash(i_album) == create_hash(album):
perfect_match = track
track_id = perfect_match["track_id"]
lrc = finder.download_lyrics(track_id, filepath)
if lrc is not None:
lyrics = Lyrics_class(lrc)
if lyrics.is_synced:
formatted_lyrics = lyrics.format_synced_lyrics()
else:
formatted_lyrics = lyrics.format_unsynced_lyrics()
return {"trackhash": trackhash, "lyrics": formatted_lyrics, "synced": lyrics.is_synced}, 200
return {"trackhash": trackhash, "lyrics": None, "synced": False}, 200
-109
View File
@@ -1,109 +0,0 @@
from typing import Literal
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import BaseModel, Field
from swingmusic.db.userdata import MixTable
from swingmusic.plugins.mixes import MixesPlugin
from swingmusic.store.homepage import HomepageStore
from swingmusic.store.tracks import TrackStore
bp_tag = Tag(name="Mixes Plugin", description="Mixes plugin hehe")
api = APIBlueprint(
"mixesplugin", __name__, url_prefix="/plugins/mixes", abp_tags=[bp_tag]
)
class GetMixesBody(BaseModel):
mixtype: Literal["artists", "tracks"] = Field(description="The type of mix")
@api.get("/<mixtype>")
def get_artist_mixes(path: GetMixesBody):
srcmixes = MixTable.get_all(with_userid=True)
mixes = []
if path.mixtype == "artists":
mixes = [mix.to_dict(convert_timestamp=True) for mix in srcmixes]
elif path.mixtype == "tracks":
plugin = MixesPlugin()
for mix in srcmixes:
custom_mix = plugin.get_track_mix(mix)
if custom_mix:
mixes.append(custom_mix.to_dict(convert_timestamp=True))
seen_mixids = set()
# filter duplicates by trackshash
final_mixes = []
for mix in mixes:
# INFO: Ignore duplicates for artist mixes
if mix["id"] in seen_mixids and path.mixtype == "tracks":
continue
final_mixes.append(mix)
seen_mixids.add(mix["id"])
return final_mixes
class MixQuery(BaseModel):
mixid: str = Field(description="The mix id")
sourcehash: str = Field(description="The sourcehash of the mix")
@api.get("/")
def get_mix(query: MixQuery):
mixtype = ""
match query.mixid[0]:
case "a":
mixtype = "artist_mixes"
case "t":
mixtype = "custom_mixes"
case _:
return {"msg": "Invalid mix ID"}, 400
# INFO: Check if the mix is already in the homepage store
mix = HomepageStore.get_mix(mixtype, query.mixid)
if mix and mix["sourcehash"] == query.sourcehash:
return mix, 200
# INF0: Get the mix from the db
mix = MixTable.get_by_sourcehash(query.sourcehash)
if not mix:
return {"msg": "Mix not found"}, 404
if mixtype == "custom_mixes":
mix = MixesPlugin.get_track_mix(mix)
if not mix:
return {"msg": "Mix not found"}, 404
return mix.to_full_dict(), 200
class SaveMixRequest(BaseModel):
mixid: str = Field(description="The id of the mix")
type: str = Field(description="The type of mix")
sourcehash: str = Field(description="The sourcehash of the mix")
@api.post("/save")
def save_mix(body: SaveMixRequest):
mix_type = body.type
mix_sourcehash = body.sourcehash
if mix_type == "artist":
state = MixTable.save_artist_mix(mix_sourcehash)
elif mix_type == "track":
state = MixTable.save_track_mix(mix_sourcehash)
mix = HomepageStore.find_mix(body.mixid)
if mix:
mix.saved = state
return {"msg": "Mixes saved"}, 200
-435
View File
@@ -1,435 +0,0 @@
"""
Year-in-Review API Endpoints
This module provides REST API endpoints for the year-in-review experience,
including recap generation, summary retrieval, and video generation.
"""
import logging
from datetime import datetime
from typing import Dict, List, Optional, Any
from flask import Blueprint, request, jsonify
from flask_login import login_required, current_user
from swingmusic.db import db
from swingmusic.services.recap_service import recap_service, RecapTheme
from swingmusic.utils.request import APIError, success_response, error_response
logger = logging.getLogger(__name__)
recap_bp = Blueprint('recap', __name__, url_prefix='/api/recap')
def get_current_user_id() -> int:
"""Get current user ID from Flask-Login"""
return current_user.id if current_user.is_authenticated else None
@recap_bp.route('/generate/<int:year>', methods=['POST'])
@login_required
async def generate_recap(year: int):
"""
Generate year-in-review for a specific year
Path Parameters:
- year: Year to generate recap for
Query Parameters:
- force: Force regeneration even if recap exists (default: false)
"""
try:
user_id = get_current_user_id()
force_regeneration = request.args.get('force', 'false').lower() == 'true'
# Check if recap already exists
if not force_regeneration:
existing_recap = await recap_service.get_recap_summary(user_id, year)
if existing_recap:
return success_response({
'message': f'Recap for {year} already exists',
'recap': existing_recap
})
# Generate new recap
recap_data = await recap_service.generate_year_recap(user_id, year)
return success_response({
'message': f'Successfully generated recap for {year}',
'recap_id': f"{user_id}_{year}",
'year': recap_data.year,
'stats': {
'total_minutes': recap_data.stats.total_minutes,
'total_tracks': recap_data.stats.total_tracks,
'total_artists': recap_data.stats.total_artists,
'unique_tracks': recap_data.stats.unique_tracks,
'listening_streak': recap_data.stats.listening_streak,
'personality_type': recap_data.personality.personality_type
}
})
except Exception as e:
logger.error(f"Error generating recap: {e}")
return error_response("Internal server error", 500)
@recap_bp.route('/summary/<int:year>', methods=['GET'])
@login_required
async def get_recap_summary(year: int):
"""
Get recap summary for a specific year
Path Parameters:
- year: Year to get recap summary for
"""
try:
user_id = get_current_user_id()
recap_summary = await recap_service.get_recap_summary(user_id, year)
if not recap_summary:
return error_response(f"No recap found for year {year}", 404)
return success_response({
'recap': recap_summary
})
except Exception as e:
logger.error(f"Error getting recap summary: {e}")
return error_response("Internal server error", 500)
@recap_bp.route('/details/<int:year>', methods=['GET'])
@login_required
async def get_recap_details(year: int):
"""
Get detailed recap data for a specific year
Path Parameters:
- year: Year to get recap details for
Query Parameters:
- include_top_tracks: Include top tracks data (default: true)
- include_top_artists: Include top artists data (default: true)
- include_top_albums: Include top albums data (default: true)
- include_discoveries: Include discoveries data (default: true)
- include_milestones: Include milestones data (default: true)
"""
try:
user_id = get_current_user_id()
# Get recap summary first
recap_summary = await recap_service.get_recap_summary(user_id, year)
if not recap_summary:
return error_response(f"No recap found for year {year}", 404)
# Parse include flags
include_flags = {
'top_tracks': request.args.get('include_top_tracks', 'true').lower() == 'true',
'top_artists': request.args.get('include_top_artists', 'true').lower() == 'true',
'top_albums': request.args.get('include_top_albums', 'true').lower() == 'true',
'discoveries': request.args.get('include_discoveries', 'true').lower() == 'true',
'milestones': request.args.get('include_milestones', 'true').lower() == 'true'
}
# Load full recap data from file
import json
from pathlib import Path
recap_file = Path(recap_service.recap_dir) / f"recap_{user_id}_{year}.json"
if not recap_file.exists():
return error_response(f"Recap data not found for year {year}", 404)
with open(recap_file, 'r') as f:
full_recap_data = json.load(f)
# Build response based on include flags
response_data = {
'year': full_recap_data['year'],
'stats': full_recap_data['stats'],
'personality': full_recap_data['personality'],
'monthly_breakdown': full_recap_data['monthly_breakdown'],
'created_at': full_recap_data['created_at']
}
if include_flags['top_tracks']:
response_data['top_tracks'] = full_recap_data['top_tracks']
if include_flags['top_artists']:
response_data['top_artists'] = full_recap_data['top_artists']
if include_flags['top_albums']:
response_data['top_albums'] = full_recap_data['top_albums']
if include_flags['discoveries']:
response_data['discoveries'] = full_recap_data['discoveries']
if include_flags['milestones']:
response_data['milestones'] = full_recap_data['milestones']
return success_response({
'recap': response_data
})
except Exception as e:
logger.error(f"Error getting recap details: {e}")
return error_response("Internal server error", 500)
@recap_bp.route('/video/<int:year>', methods=['POST'])
@login_required
async def generate_recap_video(year: int):
"""
Generate recap video for a specific year
Path Parameters:
- year: Year to generate video for
Request Body:
{
"theme": "modern|retro|minimal|vibrant|dark|light",
"include_audio": true,
"duration_limit": 180 // Optional: max duration in seconds
}
"""
try:
user_id = get_current_user_id()
# Get request data
data = request.get_json() or {}
# Validate theme
theme_name = data.get('theme', 'modern')
try:
theme = RecapTheme(theme_name)
except ValueError:
return error_response(f"Invalid theme: {theme_name}. Must be one of: {[t.value for t in RecapTheme]}", 400)
# Check if recap exists
recap_summary = await recap_service.get_recap_summary(user_id, year)
if not recap_summary:
return error_response(f"No recap found for year {year}. Generate recap first.", 404)
# Generate video (this is a placeholder - would integrate with Remotion service)
video_path = await recap_service.generate_recap_video(
# This would need to load the full recap data
None, # recap_data would be loaded here
theme
)
return success_response({
'message': f'Video generation started for {year}',
'video_path': video_path,
'theme': theme.value,
'estimated_completion': '2-5 minutes'
})
except Exception as e:
logger.error(f"Error generating recap video: {e}")
return error_response("Internal server error", 500)
@recap_bp.route('/available-years', methods=['GET'])
@login_required
async def get_available_years():
"""
Get list of years for which recaps are available
"""
try:
user_id = get_current_user_id()
# Scan recap directory for user's recaps
import os
from pathlib import Path
recap_dir = Path(recap_service.recap_dir)
available_years = []
if recap_dir.exists():
for file_path in recap_dir.glob(f"recap_{user_id}_*.json"):
# Extract year from filename
parts = file_path.stem.split('_')
if len(parts) >= 3:
year = parts[2]
try:
year_int = int(year)
available_years.append(year_int)
except ValueError:
continue
# Sort years in descending order
available_years.sort(reverse=True)
return success_response({
'available_years': available_years,
'total_recaps': len(available_years)
})
except Exception as e:
logger.error(f"Error getting available years: {e}")
return error_response("Internal server error", 500)
@recap_bp.route('/share/<int:year>', methods=['POST'])
@login_required
async def create_shareable_link(year: int):
"""
Create a shareable link for recap
Path Parameters:
- year: Year to create shareable link for
Request Body:
{
"include_personal_data": false,
"expires_in_days": 30
}
"""
try:
user_id = get_current_user_id()
# Get request data
data = request.get_json() or {}
include_personal_data = data.get('include_personal_data', False)
expires_in_days = data.get('expires_in_days', 30)
# Check if recap exists
recap_summary = await recap_service.get_recap_summary(user_id, year)
if not recap_summary:
return error_response(f"No recap found for year {year}", 404)
# Generate shareable link (this is a placeholder implementation)
import secrets
import hashlib
# Generate unique token
token_data = f"{user_id}_{year}_{datetime.utcnow().timestamp()}"
share_token = hashlib.sha256(token_data.encode()).hexdigest()[:16]
# Create shareable data
shareable_data = {
'year': year,
'stats': {
'total_minutes': recap_summary['total_minutes'],
'total_tracks': recap_summary['total_tracks'],
'personality_type': recap_summary['personality_type']
},
'top_track': recap_summary.get('top_track'),
'top_artist': recap_summary.get('top_artist'),
'created_at': recap_summary['created_at']
}
# Save shareable data (in a real implementation, this would go to database)
share_file = Path(recap_service.recap_dir) / f"share_{share_token}.json"
import json
with open(share_file, 'w') as f:
json.dump({
'user_id': user_id,
'year': year,
'data': shareable_data,
'expires_at': (datetime.utcnow() + datetime.timedelta(days=expires_in_days)).isoformat(),
'created_at': datetime.utcnow().isoformat()
}, f)
share_url = f"/recap/shared/{share_token}"
return success_response({
'share_url': share_url,
'share_token': share_token,
'expires_in_days': expires_in_days,
'includes_personal_data': include_personal_data
})
except Exception as e:
logger.error(f"Error creating shareable link: {e}")
return error_response("Internal server error", 500)
@recap_bp.route('/shared/<token>', methods=['GET'])
async def get_shared_recap(token: str):
"""
Get shared recap by token (public endpoint)
Path Parameters:
- token: Share token
"""
try:
# Load share data
share_file = Path(recap_service.recap_dir) / f"share_{token}.json"
if not share_file.exists():
return error_response("Shared recap not found or expired", 404)
import json
with open(share_file, 'r') as f:
share_data = json.load(f)
# Check if expired
expires_at = datetime.fromisoformat(share_data['expires_at'])
if datetime.utcnow() > expires_at:
share_file.unlink() # Clean up expired share
return error_response("Shared recap has expired", 410)
return success_response({
'shared_recap': share_data['data'],
'year': share_data['year'],
'created_at': share_data['created_at']
})
except Exception as e:
logger.error(f"Error getting shared recap: {e}")
return error_response("Internal server error", 500)
@recap_bp.route('/compare/<int:year1>/<int:year2>', methods=['GET'])
@login_required
async def compare_years(year1: int, year2: int):
"""
Compare recaps between two years
Path Parameters:
- year1: First year to compare
- year2: Second year to compare
"""
try:
user_id = get_current_user_id()
# Get both recaps
recap1 = await recap_service.get_recap_summary(user_id, year1)
recap2 = await recap_service.get_recap_summary(user_id, year2)
if not recap1:
return error_response(f"No recap found for year {year1}", 404)
if not recap2:
return error_response(f"No recap found for year {year2}", 404)
# Calculate comparisons
comparison = {
'year1': year1,
'year2': year2,
'listening_time_change': {
'absolute': recap2['total_minutes'] - recap1['total_minutes'],
'percentage': ((recap2['total_minutes'] - recap1['total_minutes']) / recap1['total_minutes'] * 100) if recap1['total_minutes'] > 0 else 0
},
'tracks_change': {
'absolute': recap2['total_tracks'] - recap1['total_tracks'],
'percentage': ((recap2['total_tracks'] - recap1['total_tracks']) / recap1['total_tracks'] * 100) if recap1['total_tracks'] > 0 else 0
},
'personality_change': {
'from': recap1['personality_type'],
'to': recap2['personality_type'],
'changed': recap1['personality_type'] != recap2['personality_type']
}
}
return success_response({
'comparison': comparison,
'recap1': recap1,
'recap2': recap2
})
except Exception as e:
logger.error(f"Error comparing years: {e}")
return error_response("Internal server error", 500)
-382
View File
@@ -1,382 +0,0 @@
from gettext import ngettext
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
import pendulum
from pydantic import Field, BaseModel
from swingmusic.api.apischemas import TrackHashSchema
from typing import Literal
import locale
from swingmusic.db.userdata import FavoritesTable, ScrobbleTable
from swingmusic.lib.extras import get_extra_info
from swingmusic.lib.recipes.recents import RecentlyPlayed
from swingmusic.models.album import Album
from swingmusic.models.stats import StatItem
from swingmusic.models.track import Track
from swingmusic.plugins.lastfm import LastFmPlugin
from swingmusic.serializers.artist import serialize_for_card
from swingmusic.serializers.album import serialize_for_card as serialize_for_album_card
from swingmusic.serializers.track import serialize_track, serialize_tracks
from swingmusic.settings import Defaults
from swingmusic.store.albums import AlbumStore
from swingmusic.store.artists import ArtistStore
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.dates import (
get_date_range,
get_duration_in_seconds,
seconds_to_time_string,
)
from swingmusic.utils.stats import (
calculate_album_trend,
calculate_artist_trend,
calculate_new_albums,
calculate_new_artists,
calculate_scrobble_trend,
calculate_track_trend,
get_albums_in_period,
get_artists_in_period,
get_tracks_in_period,
)
from swingmusic.utils.auth import get_current_userid
bp_tag = Tag(name="Logger", description="Log item plays")
api = APIBlueprint("logger", __name__, url_prefix="/logger", abp_tags=[bp_tag])
class LogTrackBody(TrackHashSchema):
timestamp: int = Field(description="The timestamp of the track")
duration: int = Field(description="The duration of the track in seconds")
source: str = Field(
description="The play source of the track",
json_schema_extra={
"examples": [
f"al:{Defaults.API_ALBUMHASH}",
f"tr:{Defaults.API_TRACKHASH}",
f"ar:{Defaults.API_ARTISTHASH}",
]
},
)
def format_date(start: float, end: float):
return f"{pendulum.from_timestamp(start).format('MMM D, YYYY')} - {pendulum.from_timestamp(end).format('MMM D, YYYY')}"
@api.post("/track/log")
def log_track(body: LogTrackBody):
"""
Log a track play to the database.
"""
timestamp = body.timestamp
duration = body.duration
if not timestamp or duration < 5:
return {"msg": "Invalid entry."}, 400
trackentry = TrackStore.trackhashmap.get(body.trackhash)
if trackentry is None:
return {"msg": "Track not found."}, 404
scrobble_data = dict(body)
# REVIEW: Do we need to store the extra info in the database?
# OR .... can we just write it to the backup file on demand?
scrobble_data["extra"] = get_extra_info(body.trackhash, "track")
ScrobbleTable.add(scrobble_data)
# NOTE: Update the recently played homepage for this userid
RecentlyPlayed(userid=scrobble_data["userid"])
# Update play data on the in-memory stores
track = trackentry.tracks[0]
album = AlbumStore.albummap.get(track.albumhash)
if album:
album.increment_playcount(duration, timestamp)
for hash in track.artisthashes:
artist = ArtistStore.artistmap.get(hash)
if artist:
artist.increment_playcount(duration, timestamp)
trackentry.increment_playcount(duration, timestamp)
track = trackentry.tracks[0]
lastfm = LastFmPlugin(current_userid=get_current_userid())
if (
lastfm.enabled
and track.duration > 30
and body.duration >= min(track.duration / 2, 240)
# SEE: https://www.last.fm/api/scrobbling#when-is-a-scrobble-a-scrobble
):
lastfm.scrobble(trackentry.tracks[0], timestamp)
return {"msg": "recorded"}, 201
class ChartItemsQuery(BaseModel):
duration: Literal["week", "month", "year", "alltime"] = Field(
"year",
description="Duration to fetch data for",
)
limit: int = Field(10, description="Number of top tracks to return")
order_by: Literal["playcount", "playduration"] = Field(
"playduration", description="Property to order by"
)
# SECTION: STATS
def get_help_text(
playcount: int, playduration: int, order_by: Literal["playcount", "playduration"]
):
"""
Get the help text given the playcount and playduration.
"""
if order_by == "playcount":
if playcount == 0:
return "unplayed"
return f"{playcount} play{'' if playcount == 1 else 's'}"
if order_by == "playduration":
return seconds_to_time_string(playduration)
# DISCLAIMER: Code beyond this point was partially written by Claude 3.5 Sonnet in Cursor.
# TODO: Refactor, group and clean up
@api.get("/top-tracks")
def get_top_tracks(query: ChartItemsQuery):
"""
Get the top N tracks played within a given duration.
"""
start_time, end_time = get_date_range(query.duration)
previous_start_time = start_time - get_duration_in_seconds(query.duration)
current_period_tracks, current_period_scrobbles, duration = get_tracks_in_period(
start_time, end_time
)
previous_period_tracks, previous_period_scrobbles, _ = get_tracks_in_period(
previous_start_time, start_time
)
scrobble_trend = (
"rising"
if current_period_scrobbles > previous_period_scrobbles
else (
"falling"
if current_period_scrobbles < previous_period_scrobbles
else "stable"
)
)
sorted_tracks = sort_tracks(current_period_tracks, query.order_by)
top_tracks = sorted_tracks[: query.limit]
response = []
for track in top_tracks:
trend = calculate_track_trend(
track, current_period_tracks, previous_period_tracks
)
track = {
**serialize_track(track),
"trend": trend,
"help_text": get_help_text(
track.playcount, track.playduration, query.order_by
),
}
response.append(track)
return {
"tracks": response,
"scrobbles": {
"text": f"{current_period_scrobbles} total play{'' if current_period_scrobbles == 1 else 's'} ({seconds_to_time_string(duration)})",
"trend": scrobble_trend,
"dates": format_date(start_time, end_time),
},
}, 200
def sort_tracks(tracks: list[Track], order_by: Literal["playcount", "playduration"]):
return sorted(tracks, key=lambda x: getattr(x, order_by), reverse=True)
@api.get("/top-artists")
def get_top_artists(query: ChartItemsQuery):
"""
Get the top N artists played within a given duration.
"""
start_time, end_time = get_date_range(query.duration)
previous_start_time = start_time - get_duration_in_seconds(query.duration)
current_period_artists = get_artists_in_period(start_time, end_time)
previous_period_artists = get_artists_in_period(previous_start_time, start_time)
new_artists = calculate_new_artists(current_period_artists, start_time)
scrobble_trend = calculate_scrobble_trend(
len(current_period_artists), len(previous_period_artists)
)
sorted_artists = sort_artists(current_period_artists, query.order_by)
top_artists = sorted_artists[: query.limit]
response = []
for artist in top_artists:
trend = calculate_artist_trend(
artist, current_period_artists, previous_period_artists
)
db_artist = ArtistStore.get_artist_by_hash(artist["artisthash"])
if db_artist is None:
continue
artist = {
**serialize_for_card(db_artist),
"trend": trend,
"help_text": get_help_text(
artist["playcount"], artist["playduration"], query.order_by
),
"extra": {
"playcount": artist["playcount"],
},
}
response.append(artist)
return {
"artists": response,
"scrobbles": {
"text": f"{new_artists} {'new' if query.duration != 'alltime' else ''} {ngettext('artist', 'artists', new_artists)}",
"trend": scrobble_trend,
"dates": format_date(start_time, end_time),
},
}, 200
def sort_artists(artists, order_by):
return sorted(artists, key=lambda x: x[order_by], reverse=True)
@api.get("/top-albums")
def get_top_albums(query: ChartItemsQuery):
"""
Get the top N albums played within a given duration.
"""
start_time, end_time = get_date_range(query.duration)
previous_start_time = start_time - get_duration_in_seconds(query.duration)
current_period_albums = get_albums_in_period(start_time, end_time)
previous_period_albums = get_albums_in_period(previous_start_time, start_time)
new_albums = calculate_new_albums(current_period_albums, previous_period_albums)
scrobble_trend = calculate_scrobble_trend(
len(current_period_albums), len(previous_period_albums)
)
sorted_albums = sort_albums(current_period_albums, query.order_by)
top_albums = sorted_albums[: query.limit]
response = []
for album in top_albums:
trend = calculate_album_trend(
album, current_period_albums, previous_period_albums
)
album = {
**serialize_for_album_card(album),
"trend": trend,
"help_text": get_help_text(
album.playcount, album.playduration, query.order_by
),
}
response.append(album)
return {
"albums": response,
"scrobbles": {
"text": f"{new_albums} new album{'' if new_albums == 1 else 's'} played",
"trend": scrobble_trend,
"dates": format_date(start_time, end_time),
},
}, 200
def sort_albums(albums: list[Album], order_by: Literal["playcount", "playduration"]):
return sorted(albums, key=lambda x: getattr(x, order_by), reverse=True)
@api.get("/stats")
def get_stats():
"""
Get the stats for the user.
"""
period = "week"
start_time, end_time = get_date_range(period)
said_period = period
match period:
case "week":
said_period = "this week"
case "month":
said_period = "this month"
case "year":
said_period = "this year"
case "alltime":
said_period = "all time"
count = len(TrackStore.get_flat_list())
total_tracks = StatItem(
"trackcount",
"in your library",
locale.format_string("%d", count, grouping=True)
+ " "
+ ngettext("track", "tracks", count),
)
tracks, playcount, playduration = get_tracks_in_period(start_time, end_time)
playcount = StatItem(
"streams",
said_period,
f"{playcount} track {ngettext('play', 'plays', playcount)}",
)
playduration = StatItem(
"playtime",
said_period,
f"{seconds_to_time_string(playduration)} listened",
)
tracks = sorted(tracks, key=lambda t: t.playduration, reverse=True)
# Find the top track from the last 7 days
top_track = StatItem(
"toptrack",
f"Top track {said_period}",
(
tracks[0].title + " - " + tracks[0].artists[0]["name"]
if len(tracks) > 0
else ""
),
(tracks[0].image if len(tracks) > 0 else None),
)
fav_count = FavoritesTable.count_favs_in_period(start_time, end_time)
favorites = StatItem(
"favorites",
said_period,
f"{fav_count} {'new' if period != 'alltime' else ''} favorite{'' if fav_count == 1 else 's'}",
)
return {
"stats": [
top_track,
playcount,
playduration,
favorites,
total_tracks,
],
"dates": format_date(start_time, end_time),
}, 200
-124
View File
@@ -1,124 +0,0 @@
"""
Contains all the search routes.
"""
from typing import Any, Literal
from unidecode import unidecode
from pydantic import Field
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from swingmusic import models
from swingmusic.api.apischemas import GenericLimitSchema
from swingmusic.lib import searchlib
from swingmusic.serializers.artist import serialize_for_cards
from swingmusic.settings import Defaults
from swingmusic.store.tracks import TrackStore
tag = Tag(name="Search", description="Search for tracks, albums and artists")
api = APIBlueprint("search", __name__, url_prefix="/search", abp_tags=[tag])
SEARCH_COUNT = 30
"""
The max amount of items to return per request
"""
class SearchQuery(GenericLimitSchema):
q: str = Field(
description="The search query",
json_schema_extra={"example": "Fleetwood Mac"},
)
start: int = Field(description="The index to start from", default=0)
limit: int = Field(
description="The number of items to return", default=SEARCH_COUNT
)
class TopResultsQuery(SearchQuery):
limit: int = Field(
description="The number of items to return", default=Defaults.API_CARD_LIMIT
)
class SearchLoadMoreQuery(SearchQuery):
itemtype: Literal["tracks", "albums", "artists"] = Field(
description="The type of search",
json_schema_extra={"example": "tracks"},
)
class Search:
def __init__(self, query: str) -> None:
self.tracks: list[models.Track] = []
self.query = unidecode(query)
def search_tracks(self):
"""
Calls :class:`SearchTracks` which returns the tracks that fuzzily match
the search terms. Then adds them to the `SearchResults` store.
"""
self.tracks = TrackStore.get_flat_list()
return searchlib.TopResults().search(self.query, tracks_only=True)
def search_artists(self):
"""Calls :class:`SearchArtists` which returns the artists that fuzzily match
the search term. Then adds them to the `SearchResults` store.
"""
artists = searchlib.SearchArtists(self.query)()
return serialize_for_cards(artists)
def search_albums(self):
"""Calls :class:`SearchAlbums` which returns the albums that fuzzily match
the search term. Then adds them to the `SearchResults` store.
"""
return searchlib.TopResults().search(self.query, albums_only=True)
def get_top_results(
self,
limit: int,
):
finder = searchlib.TopResults()
return finder.search(self.query, limit=limit)
@api.get("/top")
def get_top_results(query: TopResultsQuery):
"""
Get top results
Returns the top results for the given query.
"""
if not query.q:
return {"error": "No query provided"}, 400
return Search(query.q).get_top_results(limit=query.limit)
@api.get("/")
def search_items(query: SearchLoadMoreQuery):
"""
Find tracks, albums or artists from a search query.
"""
results: Any = []
match query.itemtype:
case "tracks":
results = Search(query.q).search_tracks()
case "albums":
results = Search(query.q).search_albums()
case "artists":
results = Search(query.q).search_artists()
case _:
return {
"error": "Invalid item type. Valid types are 'tracks', 'albums' and 'artists'"
}, 400
return {
"results": results[query.start : query.start + query.limit],
"more": len(results) > query.start + query.limit,
}
# TODO: Rewrite this file using generators where possible
-178
View File
@@ -1,178 +0,0 @@
from dataclasses import asdict
from typing import Any
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import BaseModel, Field
from swingmusic.api.auth import admin_required
from swingmusic.db.userdata import PluginTable
from swingmusic.lib.index import index_everything
from swingmusic.config import UserConfig
from swingmusic.settings import Metadata
from swingmusic.utils.auth import get_current_userid
bp_tag = Tag(name="Settings", description="Customize stuff")
api = APIBlueprint("settings", __name__, url_prefix="/notsettings", abp_tags=[bp_tag])
def get_child_dirs(parent: str, children: list[str]):
"""Returns child directories in a list, given a parent directory"""
return [_dir for _dir in children if _dir.startswith(parent) and _dir != parent]
class AddRootDirsBody(BaseModel):
new_dirs: list[str] = Field(
description="The new directories to add",
example=["/home/user/Music", "/home/user/Downloads"],
)
removed: list[str] = Field(
description="The directories to remove",
example=["/home/user/Downloads"],
)
@api.post("/add-root-dirs")
@admin_required()
def add_root_dirs(body: AddRootDirsBody):
"""
Add custom root directories to the database.
"""
new_dirs = body.new_dirs
removed_dirs = body.removed
config = UserConfig()
db_dirs = config.rootDirs
home = "$home"
db_home = any([d == home for d in db_dirs]) # if $home is in db
incoming_home = any([d == home for d in new_dirs]) # if $home is in incoming
# handle $home case
if db_home and incoming_home:
return {"msg": "Not changed!"}, 304
# if $home is the current root dir or the incoming root dir
# is $home, remove all root dirs
if db_home or incoming_home:
config.rootDirs = []
if incoming_home:
config.rootDirs = [home]
index_everything()
return {"root_dirs": [home]}
# ---
for _dir in new_dirs:
children = get_child_dirs(_dir, db_dirs)
removed_dirs.extend(children)
for _dir in removed_dirs:
try:
db_dirs.remove(_dir)
except ValueError:
pass
db_dirs.extend(new_dirs)
config.rootDirs = [dir_ for dir_ in db_dirs if dir_ != home]
index_everything()
return {"root_dirs": config.rootDirs}
@api.get("/get-root-dirs")
def get_root_dirs():
"""
Get root directories
"""
return {"dirs": UserConfig().rootDirs}
@api.get("")
def get_all_settings():
"""
Get all settings
"""
config = asdict(UserConfig())
# Convert sets to lists for JSON serialization
for key, value in config.items():
if isinstance(value, set):
config[key] = sorted(list(value))
config["plugins"] = [p for p in PluginTable.get_all()]
config["version"] = Metadata.version
if config["version"] == "0.0.0":
# fallback to version.txt (useful for docker builds)
config["version"] = open("version.txt", "r").read().strip()
# only return lastfmSessionKey for the current user
current_user = get_current_userid()
config["lastfmSessionKey"] = config["lastfmSessionKeys"].get(str(current_user), "")
del config["lastfmSessionKeys"]
return config
class SetSettingBody(BaseModel):
key: str = Field(
description="The setting key",
example="artist_separators",
)
value: Any = Field(
description="The setting value",
example=",",
)
@api.get("/trigger-scan")
def trigger_scan():
"""
Triggers scan for new music
"""
index_everything()
return {"msg": "Scan triggered!"}
class UpdateConfigBody(BaseModel):
key: str = Field(
description="The setting key",
example="usersOnLogin",
)
value: Any = Field(
description="The setting value",
example=False,
)
@api.put("/update")
@admin_required()
def update_config(body: UpdateConfigBody):
"""
Update the config file
"""
config = UserConfig()
if body.key == "artistSeparators":
body.value = body.value.split(",")
setattr(config, body.key, body.value)
# INFO: Rebuild stores when these settings are updated
reset_stores_lists = {
"artistSeparators",
"artistSplitIgnoreList",
"removeProdBy",
"removeRemasterInfo",
"mergeAlbums",
"cleanAlbumTitle",
"showAlbumsAsSingles",
}
if body.key in reset_stores_lists:
index_everything()
return {
"msg": "Config updated!",
}
-425
View File
@@ -1,425 +0,0 @@
"""
Spotify Downloader API endpoints for SwingMusic
Provides REST API for Spotify URL downloading functionality
"""
from flask import Blueprint, request, jsonify
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
import asyncio
from swingmusic.services.spotify_downloader import spotify_downloader, DownloadSource
from swingmusic import logger
from swingmusic.utils import create_valid_filename
spotify_bp = APIBlueprint(
'spotify',
import_name='spotify',
url_prefix='/api/spotify'
)
class SpotifyURLRequest(BaseModel):
url: str = Field(..., description='Spotify URL (track, album, or playlist)')
quality: Optional[str] = Field('flac', description='Audio quality (flac, mp3_320, mp3_128)')
output_dir: Optional[str] = Field(None, description='Output directory (optional)')
class SpotifyMetadataResponse(BaseModel):
spotify_id: str
title: str
artist: str
album: str
duration_ms: int
image_url: str
release_date: str
track_number: int
total_tracks: int
is_explicit: bool
preview_url: Optional[str]
class DownloadItemResponse(BaseModel):
id: str
spotify_url: str
spotify_id: str
title: str
artist: str
album: str
duration_ms: int
image_url: str
quality: str
source: str
status: str
progress: int
file_path: Optional[str]
error_message: Optional[str]
created_at: float
started_at: Optional[float]
completed_at: Optional[float]
class QueueStatusResponse(BaseModel):
queue_length: int
active_downloads: int
pending_items: int
queue: List[DownloadItemResponse]
active: List[DownloadItemResponse]
history: List[DownloadItemResponse]
class ActionResponse(BaseModel):
success: bool
message: str
item_id: Optional[str] = None
@spotify_bp.post('/metadata', summary='Get Spotify metadata')
async def get_metadata(body: SpotifyURLRequest):
"""
Extract metadata from a Spotify URL without downloading
- **url**: Spotify URL for track, album, or playlist
- **quality**: Preferred audio quality (optional)
Returns metadata for the Spotify content.
"""
try:
metadata = await spotify_downloader.get_metadata(body.url)
if not metadata:
return jsonify({
'error': 'Invalid Spotify URL or failed to fetch metadata',
'success': False
}), 400
return jsonify({
'success': True,
'metadata': {
'spotify_id': metadata.spotify_id,
'title': metadata.title,
'artist': metadata.artist,
'album': metadata.album,
'duration_ms': metadata.duration_ms,
'image_url': metadata.image_url,
'release_date': metadata.release_date,
'track_number': metadata.track_number,
'total_tracks': metadata.total_tracks,
'is_explicit': metadata.is_explicit,
'preview_url': metadata.preview_url
}
})
except Exception as e:
logger.error(f"Error getting Spotify metadata: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@spotify_bp.post('/download', summary='Download from Spotify URL')
async def download_from_url(body: SpotifyURLRequest):
"""
Add a Spotify URL to the download queue
- **url**: Spotify URL for track, album, or playlist
- **quality**: Audio quality preference (flac, mp3_320, mp3_128)
- **output_dir**: Custom output directory (optional)
Adds the item to the download queue and returns the download ID.
"""
try:
# Validate quality
valid_qualities = ['flac', 'mp3_320', 'mp3_128']
if body.quality not in valid_qualities:
return jsonify({
'error': f'Invalid quality. Must be one of: {", ".join(valid_qualities)}',
'success': False
}), 400
# Add to download queue
item_id = spotify_downloader.add_download(
spotify_url=body.url,
output_dir=body.output_dir,
quality=body.quality
)
if not item_id:
return jsonify({
'error': 'Failed to add download. Invalid URL or duplicate.',
'success': False
}), 400
return jsonify({
'success': True,
'message': 'Download added to queue',
'item_id': item_id
})
except Exception as e:
logger.error(f"Error adding download: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@spotify_bp.get('/queue', summary='Get download queue status')
def get_queue_status():
"""
Get current status of the download queue
Returns information about queued items, active downloads, and history.
"""
try:
status = spotify_downloader.get_queue_status()
return jsonify({
'success': True,
'data': status
})
except Exception as e:
logger.error(f"Error getting queue status: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@spotify_bp.post('/cancel/<item_id>', summary='Cancel download')
def cancel_download(item_id: str):
"""
Cancel a pending or active download
- **item_id**: ID of the download item to cancel
Returns success status of the cancellation.
"""
try:
success = spotify_downloader.cancel_download(item_id)
if success:
return jsonify({
'success': True,
'message': 'Download cancelled successfully'
})
else:
return jsonify({
'success': False,
'message': 'Download not found or cannot be cancelled'
}), 404
except Exception as e:
logger.error(f"Error cancelling download: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@spotify_bp.post('/retry/<item_id>', summary='Retry failed download')
def retry_download(item_id: str):
"""
Retry a failed download
- **item_id**: ID of the failed download item to retry
Returns success status of the retry operation.
"""
try:
success = spotify_downloader.retry_download(item_id)
if success:
return jsonify({
'success': True,
'message': 'Download added to queue for retry'
})
else:
return jsonify({
'success': False,
'message': 'Download not found or cannot be retried'
}), 404
except Exception as e:
logger.error(f"Error retrying download: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@spotify_bp.get('/sources', summary='Get available download sources')
def get_download_sources():
"""
Get list of available download sources and their status
Returns information about available download sources (Tidal, Qobuz, Amazon).
"""
try:
sources = []
for source in DownloadSource:
sources.append({
'name': source.value,
'display_name': source.value.title(),
'enabled': True, # In real implementation, check availability
'priority': list(DownloadSource).index(source)
})
return jsonify({
'success': True,
'sources': sources
})
except Exception as e:
logger.error(f"Error getting download sources: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@spotify_bp.get('/qualities', summary='Get available audio qualities')
def get_audio_qualities():
"""
Get list of available audio qualities
Returns supported audio formats and quality options.
"""
try:
qualities = [
{
'id': 'flac',
'name': 'FLAC',
'description': 'Lossless audio quality',
'extension': 'flac',
'bitrate': 'Lossless'
},
{
'id': 'mp3_320',
'name': 'MP3 320kbps',
'description': 'High quality MP3',
'extension': 'mp3',
'bitrate': '320 kbps'
},
{
'id': 'mp3_128',
'name': 'MP3 128kbps',
'description': 'Standard quality MP3',
'extension': 'mp3',
'bitrate': '128 kbps'
}
]
return jsonify({
'success': True,
'qualities': qualities
})
except Exception as e:
logger.error(f"Error getting audio qualities: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@spotify_bp.get('/history', summary='Get download history')
def get_download_history():
"""
Get download history
Returns paginated download history.
"""
try:
# Get query parameters
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 50))
status_filter = request.args.get('status', None)
# Get history from downloader
status = spotify_downloader.get_queue_status()
history = status.get('history', [])
# Apply status filter
if status_filter:
history = [item for item in history if item.get('status') == status_filter]
# Paginate
total = len(history)
start = (page - 1) * limit
end = start + limit
paginated_history = history[start:end]
return jsonify({
'success': True,
'data': {
'items': paginated_history,
'pagination': {
'page': page,
'limit': limit,
'total': total,
'pages': (total + limit - 1) // limit
}
}
})
except Exception as e:
logger.error(f"Error getting download history: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@spotify_bp.delete('/clear-history', summary='Clear download history')
def clear_download_history():
"""
Clear download history
Removes all completed and failed downloads from history.
"""
try:
# Clear history in downloader
spotify_downloader.download_history.clear()
return jsonify({
'success': True,
'message': 'Download history cleared'
})
except Exception as e:
logger.error(f"Error clearing download history: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
# Error handlers
@spotify_bp.errorhandler(400)
def bad_request(error):
return jsonify({
'error': 'Bad request',
'message': str(error),
'success': False
}), 400
@spotify_bp.errorhandler(404)
def not_found(error):
return jsonify({
'error': 'Not found',
'message': str(error),
'success': False
}), 404
@spotify_bp.errorhandler(500)
def internal_error(error):
return jsonify({
'error': 'Internal server error',
'message': str(error),
'success': False
}), 500
-371
View File
@@ -1,371 +0,0 @@
"""
Spotify Downloader Settings API endpoints
"""
from flask import Blueprint, request, jsonify
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any
from swingmusic import logger
from swingmusic.config import UserConfig
spotify_settings_bp = APIBlueprint(
'spotify_settings',
import_name='spotify_settings',
url_prefix='/api/settings/spotify'
)
class SpotifySettingsRequest(BaseModel):
defaultQuality: str = Field('flac', description='Default download quality')
downloadFolder: Optional[str] = Field(None, description='Download folder path')
autoAddToLibrary: bool = Field(True, description='Auto-add downloads to library')
maxConcurrentDownloads: int = Field(3, description='Max concurrent downloads')
sources: Optional[list] = Field(None, description='Download sources configuration')
maxRetryAttempts: int = Field(3, description='Max retry attempts')
cleanupHistoryDays: int = Field(30, description='Auto-cleanup history days')
showExplicitWarning: bool = Field(True, description='Show explicit content warning')
class SpotifySettingsResponse(BaseModel):
success: bool
settings: Optional[Dict[str, Any]] = None
message: Optional[str] = None
# Default settings
DEFAULT_SETTINGS = {
'defaultQuality': 'flac',
'downloadFolder': '',
'autoAddToLibrary': True,
'maxConcurrentDownloads': 3,
'sources': [
{
'name': 'tidal',
'display_name': 'Tidal',
'enabled': True,
'priority': 1,
'config': {
'quality_preference': ['lossless', 'high', 'normal'],
'formats': ['flac', 'mp3']
}
},
{
'name': 'qobuz',
'display_name': 'Qobuz',
'enabled': True,
'priority': 2,
'config': {
'quality_preference': ['lossless', 'high', 'normal'],
'formats': ['flac', 'mp3']
}
},
{
'name': 'amazon',
'display_name': 'Amazon Music',
'enabled': False,
'priority': 3,
'config': {
'quality_preference': ['high', 'normal'],
'formats': ['mp3', 'aac']
}
}
],
'maxRetryAttempts': 3,
'cleanupHistoryDays': 30,
'showExplicitWarning': True
}
def get_spotify_settings():
"""Get Spotify downloader settings from config"""
try:
config = UserConfig()
spotify_settings = config.spotify_downloads if hasattr(config, 'spotify_downloads') else {}
# Merge with defaults
settings = {**DEFAULT_SETTINGS}
settings.update(spotify_settings)
return settings
except Exception as e:
logger.error(f"Error loading Spotify settings: {e}")
return DEFAULT_SETTINGS
def save_spotify_settings(settings_data: dict):
"""Save Spotify downloader settings to config"""
try:
config = UserConfig()
# Update only provided settings
current_settings = get_spotify_settings()
current_settings.update(settings_data)
# Save to config
config.spotify_downloads = current_settings
config.save()
logger.info("Spotify settings saved successfully")
return True
except Exception as e:
logger.error(f"Error saving Spotify settings: {e}")
return False
@spotify_settings_bp.get('/', summary='Get Spotify downloader settings')
def get_settings():
"""
Get current Spotify downloader settings
Returns all Spotify downloader configuration including:
- Default quality settings
- Download folder configuration
- Source priorities and enablement
- Advanced options
"""
try:
settings = get_spotify_settings()
return jsonify({
'success': True,
'settings': settings
})
except Exception as e:
logger.error(f"Error getting Spotify settings: {e}")
return jsonify({
'success': False,
'message': str(e)
}), 500
@spotify_settings_bp.post('/', summary='Update Spotify downloader settings')
def update_settings(body: SpotifySettingsRequest):
"""
Update Spotify downloader settings
- **defaultQuality**: Default download quality (flac, mp3_320, mp3_128)
- **downloadFolder**: Custom download folder path
- **autoAddToLibrary**: Whether to auto-add downloads to library
- **maxConcurrentDownloads**: Maximum concurrent downloads (1-10)
- **sources**: Download sources configuration
- **maxRetryAttempts**: Maximum retry attempts for failed downloads
- **cleanupHistoryDays**: Days to keep download history (0 = disabled)
- **showExplicitWarning**: Show warning for explicit content
Updates the Spotify downloader configuration and saves to user settings.
"""
try:
# Validate inputs
if body.defaultQuality not in ['flac', 'mp3_320', 'mp3_128']:
return jsonify({
'success': False,
'message': 'Invalid quality setting'
}), 400
if not 1 <= body.maxConcurrentDownloads <= 10:
return jsonify({
'success': False,
'message': 'Max concurrent downloads must be between 1 and 10'
}), 400
if not 0 <= body.maxRetryAttempts <= 10:
return jsonify({
'success': False,
'message': 'Max retry attempts must be between 0 and 10'
}), 400
if not 0 <= body.cleanupHistoryDays <= 365:
return jsonify({
'success': False,
'message': 'Cleanup days must be between 0 and 365'
}), 400
# Prepare settings data
settings_data = {
'defaultQuality': body.defaultQuality,
'downloadFolder': body.downloadFolder,
'autoAddToLibrary': body.autoAddToLibrary,
'maxConcurrentDownloads': body.maxConcurrentDownloads,
'sources': body.sources,
'maxRetryAttempts': body.maxRetryAttempts,
'cleanupHistoryDays': body.cleanupHistoryDays,
'showExplicitWarning': body.showExplicitWarning
}
# Remove None values
settings_data = {k: v for k, v in settings_data.items() if v is not None}
# Save settings
if save_spotify_settings(settings_data):
return jsonify({
'success': True,
'message': 'Settings saved successfully'
})
else:
return jsonify({
'success': False,
'message': 'Failed to save settings'
}), 500
except Exception as e:
logger.error(f"Error updating Spotify settings: {e}")
return jsonify({
'success': False,
'message': str(e)
}), 500
@spotify_settings_bp.post('/reset', summary='Reset Spotify settings to defaults')
def reset_settings():
"""
Reset all Spotify downloader settings to default values
Resets all Spotify downloader configuration to factory defaults.
"""
try:
if save_spotify_settings(DEFAULT_SETTINGS):
return jsonify({
'success': True,
'message': 'Settings reset to defaults',
'settings': DEFAULT_SETTINGS
})
else:
return jsonify({
'success': False,
'message': 'Failed to reset settings'
}), 500
except Exception as e:
logger.error(f"Error resetting Spotify settings: {e}")
return jsonify({
'success': False,
'message': str(e)
}), 500
@spotify_settings_bp.delete('/queue', summary='Clear download queue')
def clear_queue():
"""
Clear the entire download queue
Removes all pending and active downloads from the queue.
"""
try:
from swingmusic.services.spotify_downloader import spotify_downloader
# Clear queue
spotify_downloader.download_queue.clear()
return jsonify({
'success': True,
'message': 'Download queue cleared'
})
except Exception as e:
logger.error(f"Error clearing download queue: {e}")
return jsonify({
'success': False,
'message': str(e)
}), 500
@spotify_settings_bp.delete('/history', summary='Clear download history')
def clear_history():
"""
Clear the download history
Removes all completed and failed downloads from history.
"""
try:
from swingmusic.services.spotify_downloader import spotify_downloader
# Clear history
spotify_downloader.download_history.clear()
return jsonify({
'success': True,
'message': 'Download history cleared'
})
except Exception as e:
logger.error(f"Error clearing download history: {e}")
return jsonify({
'success': False,
'message': str(e)
}), 500
@spotify_settings_bp.get('/sources', summary='Get available download sources')
def get_available_sources():
"""
Get list of available download sources
Returns information about supported download sources and their capabilities.
"""
try:
sources = [
{
'name': 'tidal',
'display_name': 'Tidal',
'description': 'High-quality FLAC downloads from Tidal',
'quality_options': ['lossless', 'high', 'normal'],
'formats': ['flac', 'mp3'],
'available': True,
'requires_auth': False,
'max_quality': 'lossless'
},
{
'name': 'qobuz',
'display_name': 'Qobuz',
'description': 'Alternative high-quality source with extensive catalog',
'quality_options': ['lossless', 'high', 'normal'],
'formats': ['flac', 'mp3'],
'available': True,
'requires_auth': True,
'max_quality': 'lossless'
},
{
'name': 'amazon',
'display_name': 'Amazon Music',
'description': 'Fallback source with wide availability',
'quality_options': ['high', 'normal'],
'formats': ['mp3', 'aac'],
'available': False, # Disabled by default
'requires_auth': True,
'max_quality': 'high'
}
]
return jsonify({
'success': True,
'sources': sources
})
except Exception as e:
logger.error(f"Error getting available sources: {e}")
return jsonify({
'success': False,
'message': str(e)
}), 500
# Error handlers
@spotify_settings_bp.errorhandler(400)
def bad_request(error):
return jsonify({
'error': 'Bad request',
'message': str(error),
'success': False
}), 400
@spotify_settings_bp.errorhandler(500)
def internal_error(error):
return jsonify({
'error': 'Internal server error',
'message': str(error),
'success': False
}), 500
-507
View File
@@ -1,507 +0,0 @@
"""
Contains all the track routes with iOS compatibility enhancements.
"""
import os
from pathlib import Path
import tempfile
import time
from typing import Literal
from pydantic import BaseModel, Field
from flask_openapi3 import APIBlueprint, Tag
from swingmusic.api.apischemas import TrackHashSchema
from swingmusic.config import UserConfig
from swingmusic.lib.transcoder import start_transcoding
from flask import request, Response, send_from_directory
from swingmusic.lib.trackslib import get_silence_paddings
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.files import guess_mime_type
from swingmusic.services.ios_audio_compatibility import ios_audio_manager
bp_tag = Tag(name="File", description="Audio files")
api = APIBlueprint("track", __name__, url_prefix="/file", abp_tags=[bp_tag])
class TransCodeStore:
map: dict[str, str] = {}
@classmethod
def add_file(cls, trackhash: str, filepath: str):
cls.map[trackhash] = filepath
@classmethod
def remove_file(cls, trackhash: str):
del cls.map[trackhash]
@classmethod
def find(cls, trackhash: str):
return cls.map.get(trackhash)
class SendTrackFileQuery(BaseModel):
filepath: str = Field(description="The filepath to play (if available)")
quality: str = Field(
"original",
description="The quality of the audio file. Options: original, 1411, 1024, 512, 320, 256, 128, 96",
)
container: Literal["mp3", "aac", "flac", "webm", "ogg"] = Field(
"mp3",
description="The container format of the audio file. Options: mp3, aac, flac, webm, ogg",
)
@api.get("/<trackhash>/legacy")
def send_track_file_legacy(path: TrackHashSchema, query: SendTrackFileQuery):
"""
Get a playable audio file without Range support (iOS compatible)
Returns a playable audio file that corresponds to the given filepath. Falls back to track hash if filepath is not found.
Automatically handles iOS compatibility by transcoding to supported formats when needed.
NOTE: Does not support range requests or transcoding beyond iOS compatibility.
"""
requested_trackhash = path.trackhash.strip()
filepath = query.filepath.strip()
msg = {"msg": "File Not Found"}
# prevent path traversal
if "/../" in filepath:
return {"msg": "Invalid filepath", "error": "Path traversal detected"}, 400
requested_filepath = Path(filepath).resolve()
# check if filepath is a child of any of the root dirs
for root_dir in UserConfig().rootDirs:
if root_dir == "$home":
root_dir = Path.home()
else:
root_dir = Path(root_dir).resolve()
if root_dir not in requested_filepath.parents:
return {
"msg": "Invalid filepath",
"error": "File not inside root directories",
}, 400
track = None
tracks = TrackStore.get_tracks_by_filepaths([filepath])
if len(tracks) > 0 and os.path.exists(tracks[0].filepath):
for t in tracks:
if os.path.exists(t.filepath) and t.trackhash == requested_trackhash:
track = t
break
else:
group = TrackStore.trackhashmap.get(requested_trackhash)
# When finding by trackhash, sort by bitrate
# and get the first track that exists
if group is not None:
tracks = sorted(group.tracks, key=lambda x: x.bitrate, reverse=True)
for t in tracks:
if os.path.exists(t.filepath):
track = t
break
if track is not None:
# Detect iOS capabilities and handle compatibility
user_agent = request.headers.get('User-Agent', '')
ios_capabilities = ios_audio_manager.detect_ios_capabilities(user_agent)
# Create iOS-compatible audio source
audio_source = ios_audio_manager.create_ios_audio_source(
track.filepath,
ios_capabilities,
quality="high"
)
# Use the potentially transcoded file path
final_file_path = audio_source['file_path']
audio_type = audio_source['mime_type']
# Add iOS compatibility headers
response = send_from_directory(
Path(final_file_path).parent,
Path(final_file_path).name,
mimetype=audio_type,
conditional=True,
as_attachment=True,
)
# Add iOS-specific headers
if ios_capabilities.is_ios:
response.headers['Accept-Ranges'] = 'bytes'
response.headers['Cache-Control'] = 'public, max-age=3600'
# Add transcoding info if applicable
if audio_source['needs_transcoding']:
response.headers['X-iOS-Transcoded'] = 'true'
response.headers['X-iOS-Original-Format'] = guess_mime_type(track.filepath)
response.headers['X-iOS-Target-Format'] = audio_source['format']
return response
return msg, 404
@api.get("/<trackhash>/ios")
def send_track_file_ios(path: TrackHashSchema, query: SendTrackFileQuery):
"""
Get a playable audio file optimized for iOS devices
Returns a playable audio file optimized for iOS compatibility with automatic transcoding.
Supports FLAC to ALAC/AAC conversion and proper MIME types for iOS Safari and other browsers.
iOS Features:
- Automatic FLAC to ALAC/AAC transcoding
- Proper MP4 container formatting
- iOS-compatible MIME types
- Optimized bitrate for mobile streaming
- Caching for transcoded files
"""
requested_trackhash = path.trackhash.strip()
filepath = query.filepath.strip()
msg = {"msg": "File Not Found"}
# prevent path traversal
if "/../" in filepath:
return {"msg": "Invalid filepath", "error": "Path traversal detected"}, 400
requested_filepath = Path(filepath).resolve()
# check if filepath is a child of any of the root dirs
for root_dir in UserConfig().rootDirs:
if root_dir == "$home":
root_dir = Path.home()
else:
root_dir = Path(root_dir).resolve()
if root_dir not in requested_filepath.parents:
return {
"msg": "Invalid filepath",
"error": "File not inside root directories",
}, 400
track = None
tracks = TrackStore.get_tracks_by_filepaths([filepath])
if len(tracks) > 0 and os.path.exists(tracks[0].filepath):
for t in tracks:
if os.path.exists(t.filepath) and t.trackhash == requested_trackhash:
track = t
break
else:
group = TrackStore.trackhashmap.get(requested_trackhash)
# When finding by trackhash, sort by bitrate
# and get the first track that exists
if group is not None:
tracks = sorted(group.tracks, key=lambda x: x.bitrate, reverse=True)
for t in tracks:
if os.path.exists(t.filepath):
track = t
break
if track is not None:
# Detect iOS capabilities
user_agent = request.headers.get('User-Agent', '')
ios_capabilities = ios_audio_manager.detect_ios_capabilities(user_agent)
# Determine quality based on query parameter or device capabilities
quality_map = {
'original': 'lossless',
'1411': 'lossless',
'1024': 'lossless',
'512': 'high',
'320': 'high',
'256': 'high',
'128': 'medium',
'96': 'low'
}
quality = quality_map.get(query.quality, 'high')
# Create iOS-optimized audio source
audio_source = ios_audio_manager.create_ios_audio_source(
track.filepath,
ios_capabilities,
quality=quality
)
# Use the potentially transcoded file path
final_file_path = audio_source['file_path']
audio_type = audio_source['mime_type']
# Create response with iOS-specific optimizations
response = send_from_directory(
Path(final_file_path).parent,
Path(final_file_path).name,
mimetype=audio_type,
conditional=True,
as_attachment=False, # Stream inline for iOS
)
# iOS-specific headers for optimal playback
response.headers['Accept-Ranges'] = 'bytes'
response.headers['Cache-Control'] = 'public, max-age=7200' # 2 hours
response.headers['X-Content-Type-Options'] = 'nosniff'
# Add iOS compatibility information
if ios_capabilities.is_ios:
response.headers['X-iOS-Optimized'] = 'true'
response.headers['X-iOS-Device'] = 'iPhone' if 'iPhone' in user_agent else 'iPad' if 'iPad' in user_agent else 'iPod'
# Add transcoding information
if audio_source['needs_transcoding']:
response.headers['X-iOS-Transcoded'] = 'true'
response.headers['X-iOS-Original-Format'] = guess_mime_type(track.filepath)
response.headers['X-iOS-Target-Format'] = audio_source['format']
response.headers['X-iOS-Quality'] = quality
else:
response.headers['X-iOS-Transcoded'] = 'false'
response.headers['X-iOS-Native-Format'] = 'true'
return response
return msg, 404
# @api.get("/<trackhash>")
# def send_track_file(path: TrackHashSchema, query: SendTrackFileQuery):
# """
# Get a playable audio file with Range headers support
# Returns a playable audio file that corresponds to the given filepath. Falls back to track hash if filepath is not found.
# Transcoding can be done by sending the quality and container query parameters.
# **NOTES:**
# - Transcoded streams report incorrect duration during playback (idk why! FFMPEG gurus we need your help here).
# - The quality parameter is the desired bitrate in kbps.
# - The mp3 container is the best container for upto 320kbps (and has better duration reporting). The flac container allows for higher bitrates but it produces dramatically larger files (when transcoding from lossy formats).
# - You can get the transcoded bitrate by checking the X-Transcoded-Bitrate header on the first request's response.
# """
# trackhash = path.trackhash
# filepath = query.filepath
# # If filepath is provided, try to send that
# track = None
# tracks = TrackStore.get_tracks_by_filepaths([filepath])
# if len(tracks) > 0 and os.path.exists(filepath):
# track = tracks[0]
# else:
# res = TrackStore.trackhashmap.get(trackhash)
# # When finding by trackhash, sort by bitrate
# # and get the first track that exists
# if res is not None:
# tracks = sorted(res.tracks, key=lambda x: x.bitrate, reverse=True)
# for t in tracks:
# if os.path.exists(t.filepath):
# track = t
# break
# if track is not None:
# if query.quality == "original":
# return send_file_as_chunks(track.filepath)
# # prevent requesting over transcoding
# max_bitrate = track.bitrate
# requested_bitrate = int(query.quality)
# if query.container != "flac":
# # drop to 320 for non-flac containers
# requested_bitrate = min(320, requested_bitrate)
# quality = f"{min(max_bitrate, requested_bitrate)}k"
# return transcode_and_stream(trackhash, track.filepath, quality, query.container)
# return {"msg": "File Not Found"}, 404
def transcode_and_stream(trackhash: str, filepath: str, bitrate: str, container: str):
"""
Initiates transcoding and returns the first chunk of the transcoded file.
The other chunks are streamed on subsequent requests and are rerouted to `send_file_as_chunks`.
"""
temp_file = TransCodeStore.find(trackhash)
if temp_file is not None:
return send_file_as_chunks(temp_file)
format_params = {
"mp3": ["-c:a", "libmp3lame"],
"aac": ["-c:a", "aac"],
"webm": ["-c:a", "libopus"],
"ogg": ["-c:a", "libvorbis"],
"flac": ["-c:a", "flac"],
"wav": ["-c:a", "pcm_s16le"],
}
# Create a temporary file
format = f".{container}" if container in format_params.keys() else ".flac"
container_args = (
format_params[container]
if container in format_params.keys()
else format_params["flac"]
)
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=format)
temp_filename = temp_file.name
temp_file.close()
TransCodeStore.add_file(trackhash, temp_filename)
start_transcoding(filepath, temp_filename, bitrate, container_args)
chunk_size = 1024 * 512 # 0.5MB
file_size = os.path.getsize(filepath)
def generate():
# Poll for the output file
while (
not os.path.exists(temp_filename)
or os.path.getsize(temp_filename) < chunk_size
):
print(f"Waiting for transcoding to complete... filename: {temp_filename}")
time.sleep(0.1) # Wait for 100ms before checking again
with open(temp_filename, "rb") as file:
file.seek(0)
return file.read(chunk_size)
audio_type = guess_mime_type(temp_filename)
response = Response(
generate(),
206,
mimetype=audio_type,
content_type=audio_type,
direct_passthrough=True,
)
response.headers.add("Content-Range", f"bytes {0}-{chunk_size}/{file_size}")
response.headers.add("Accept-Ranges", "bytes")
response.headers.add("X-Transcoded-Bitrate", bitrate)
return response
def send_file_as_chunks(filepath: str) -> Response:
"""
Returns a Response object that streams the file in chunks.
"""
# NOTE: +1 makes sure the last byte is included in the range.
# NOTE: -1 is used to convert the end index to a 0-based index.
chunk_size = 1024 * 512 # 0.5MB
# Get file size
file_size = os.path.getsize(filepath)
start = 0
end = chunk_size
# Read range header
range_header = request.headers.get("Range")
if range_header:
start = get_start_range(range_header)
# If start + chunk_size is greater than file_size,
# set end to file_size - 1
_end = start + chunk_size - 1
if _end > file_size:
end = file_size - 1
else:
end = _end
def generate_chunks():
with open(filepath, "rb") as file:
file.seek(start)
remaining_bytes = end - start + 1
retry_count = 0
max_retries = 10 # 5 * 100ms = 500ms total wait time
while remaining_bytes > 0 or retry_count < max_retries:
if retry_count == max_retries:
print("💚 sending final chunk! ...")
pos = file.tell()
chunk = file.read(os.path.getsize(filepath) - pos)
return chunk, pos, True
if remaining_bytes < chunk_size:
time.sleep(0.25)
retry_count += 1
remaining_bytes = os.path.getsize(filepath) - file.tell()
continue
chunk = file.read(min(chunk_size, remaining_bytes))
if chunk:
remaining_bytes -= len(chunk)
return chunk, file.tell(), False
else:
# If no data is read, wait for 100ms before retrying
time.sleep(0.25)
retry_count += 1
# update remaining bytes
remaining_bytes = os.path.getsize(filepath) - file.tell()
print(f"▶ Remaining bytes: {remaining_bytes}")
return None, 0, True
data, position, is_final = generate_chunks()
audio_type = guess_mime_type(filepath)
response = Response(
response=data,
status=206, # Partial Content status code
mimetype=audio_type,
content_type=audio_type,
direct_passthrough=True,
)
bytes_to_add = chunk_size if not is_final else 0
response.headers.add(
"Content-Range",
f"bytes {start}-{position}/{os.path.getsize(filepath) + bytes_to_add}",
)
response.headers.add("Access-Control-Expose-Headers", "Content-Range")
response.headers.add("Accept-Ranges", "bytes")
return response
def get_start_range(range_header: str):
try:
range_start, range_end = range_header.strip().split("=")[1].split("-")
return int(range_start)
except ValueError:
return 0
class GetAudioSilenceBody(BaseModel):
ending_file: str = Field(description="The ending file's path")
starting_file: str = Field(description="The beginning file's path")
@api.post("/silence")
def get_audio_silence(body: GetAudioSilenceBody):
"""
Get silence paddings
Returns the duration of silence at the end of the current ending track and the duration of silence at the beginning of the next track.
NOTE: Durations are in milliseconds.
"""
ending_file = body.ending_file # ending file's filepath
starting_file = body.starting_file # starting file's filepath
if ending_file is None or starting_file is None:
return {"msg": "No filepath provided"}, 400
return get_silence_paddings(ending_file, starting_file)
-439
View File
@@ -1,439 +0,0 @@
"""
Universal Music Downloader API for SwingMusic
Supports multiple music streaming services for universal downloading
"""
from flask import Blueprint, request, jsonify
from typing import Dict, List, Any, Optional
import asyncio
from swingmusic.services.universal_music_downloader import universal_music_downloader, DownloadQuality
from swingmusic.services.universal_url_parser import universal_url_parser, MusicService
from swingmusic import logger
# Create blueprint
universal_downloader_bp = Blueprint('universal_downloader', __name__, url_prefix='/api/universal')
@universal_downloader_bp.route('/download', methods=['POST'])
def add_download():
"""
Add a download from any supported music service URL
Request body:
{
"url": "music service URL",
"quality": "lossless|high|medium|low",
"output_dir": "/path/to/output"
}
"""
try:
data = request.get_json()
if not data or not data.get('url'):
return jsonify({'error': 'URL is required'}), 400
url = data['url'].strip()
quality_str = data.get('quality', 'high')
output_dir = data.get('output_dir')
# Validate quality
try:
quality = DownloadQuality(quality_str)
except ValueError:
return jsonify({'error': f'Invalid quality: {quality_str}'}), 400
# Parse URL
parsed_url = universal_music_downloader.parse_url(url)
if not parsed_url:
return jsonify({'error': 'Unsupported URL format'}), 400
# Add to download queue
item_id = universal_music_downloader.add_download(url, quality, output_dir)
if item_id:
return jsonify({
'success': True,
'item_id': item_id,
'service': parsed_url.service.value,
'item_type': parsed_url.item_type,
'message': f'Added to download queue from {parsed_url.service.value}'
})
else:
return jsonify({'error': 'Failed to add download'}), 500
except Exception as e:
logger.error(f"Error adding download: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/metadata', methods=['POST'])
def get_metadata():
"""
Get metadata for any supported music service URL
Request body:
{
"url": "music service URL"
}
"""
try:
data = request.get_json()
if not data or not data.get('url'):
return jsonify({'error': 'URL is required'}), 400
url = data['url'].strip()
# Parse URL
parsed_url = universal_music_downloader.parse_url(url)
if not parsed_url:
return jsonify({'error': 'Unsupported URL format'}), 400
# Get metadata
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
metadata = loop.run_until_complete(universal_music_downloader.get_metadata(url))
finally:
loop.close()
if metadata:
return jsonify({
'success': True,
'service': metadata.service.value,
'service_id': metadata.service_id,
'item_type': parsed_url.item_type,
'title': metadata.title,
'artist': metadata.artist,
'album': metadata.album,
'duration_ms': metadata.duration_ms,
'image_url': metadata.image_url,
'release_date': metadata.release_date,
'explicit': metadata.explicit,
'preview_url': metadata.preview_url,
'genre': metadata.genre,
'original_url': metadata.original_url,
'download_urls': metadata.download_urls
})
else:
return jsonify({'error': 'Failed to get metadata'}), 404
except Exception as e:
logger.error(f"Error getting metadata: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/queue', methods=['GET'])
def get_queue_status():
"""Get current download queue status"""
try:
status = universal_music_downloader.get_queue_status()
return jsonify(status)
except Exception as e:
logger.error(f"Error getting queue status: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/queue/<item_id>/cancel', methods=['POST'])
def cancel_download(item_id: str):
"""Cancel a download"""
try:
success = universal_music_downloader.cancel_download(item_id)
if success:
return jsonify({'success': True, 'message': 'Download cancelled'})
else:
return jsonify({'error': 'Download not found or cannot be cancelled'}), 404
except Exception as e:
logger.error(f"Error cancelling download: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/queue/<item_id>/retry', methods=['POST'])
def retry_download(item_id: str):
"""Retry a failed download"""
try:
success = universal_music_downloader.retry_download(item_id)
if success:
return jsonify({'success': True, 'message': 'Download retry added to queue'})
else:
return jsonify({'error': 'Download not found or cannot be retried'}), 404
except Exception as e:
logger.error(f"Error retrying download: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/history', methods=['GET'])
def get_download_history():
"""
Get download history
Query parameters:
- limit: number of items (default 100)
- offset: offset for pagination (default 0)
- user_id: user ID for filtering (optional)
"""
try:
limit = min(int(request.args.get('limit', 100)), 500)
offset = int(request.args.get('offset', 0))
user_id = request.args.get('user_id')
if user_id:
user_id = int(user_id)
# Get history from universal downloader
# This would need to be implemented in the service
return jsonify({
'downloads': [],
'total': 0,
'limit': limit,
'offset': offset
})
except ValueError:
return jsonify({'error': 'Invalid parameters'}), 400
except Exception as e:
logger.error(f"Error getting download history: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/services', methods=['GET'])
def get_supported_services():
"""Get list of supported music services"""
try:
services = universal_music_downloader.get_supported_services()
return jsonify({
'services': services,
'total': len(services)
})
except Exception as e:
logger.error(f"Error getting supported services: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/services/<service_name>/enable', methods=['POST'])
def enable_service(service_name: str):
"""Enable a music service"""
try:
from swingmusic.db.spotify import UniversalDownloadSourceTable
# Update service in database
UniversalDownloadSourceTable.update_source(service_name, enabled=True)
return jsonify({
'success': True,
'message': f'{service_name} service enabled'
})
except Exception as e:
logger.error(f"Error enabling service: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/services/<service_name>/disable', methods=['POST'])
def disable_service(service_name: str):
"""Disable a music service"""
try:
from swingmusic.db.spotify import UniversalDownloadSourceTable
# Update service in database
UniversalDownloadSourceTable.update_source(service_name, enabled=False)
return jsonify({
'success': True,
'message': f'{service_name} service disabled'
})
except Exception as e:
logger.error(f"Error disabling service: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/services/<service_name>/config', methods=['GET', 'POST'])
def service_config(service_name: str):
"""Get or update service configuration"""
try:
from swingmusic.db.spotify import UniversalDownloadSourceTable
if request.method == 'GET':
source = UniversalDownloadSourceTable.get_by_service(service_name)
if not source:
return jsonify({'error': 'Service not found'}), 404
return jsonify({
'service': source.service,
'display_name': source.display_name,
'enabled': source.enabled,
'priority': source.priority,
'supported_types': source.supported_types,
'features': source.features,
'config': source.config
})
elif request.method == 'POST':
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
# Update only allowed fields
update_data = {}
allowed_fields = ['enabled', 'priority', 'supported_types', 'features', 'config']
for field in allowed_fields:
if field in data:
update_data[field] = data[field]
if update_data:
UniversalDownloadSourceTable.update_source(service_name, **update_data)
return jsonify({'success': True, 'message': 'Service configuration updated'})
except Exception as e:
logger.error(f"Error handling service config: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/validate-url', methods=['POST'])
def validate_url():
"""
Validate and parse a music service URL
Request body:
{
"url": "music service URL"
}
"""
try:
data = request.get_json()
if not data or not data.get('url'):
return jsonify({'error': 'URL is required'}), 400
url = data['url'].strip()
# Parse URL
parsed_url = universal_music_downloader.parse_url(url)
if parsed_url:
return jsonify({
'valid': True,
'service': parsed_url.service.value,
'item_type': parsed_url.item_type,
'id': parsed_url.id,
'metadata': parsed_url.metadata
})
else:
return jsonify({
'valid': False,
'error': 'Unsupported URL format'
})
except Exception as e:
logger.error(f"Error validating URL: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/statistics', methods=['GET'])
def get_statistics():
"""Get download statistics by service"""
try:
from swingmusic.db.spotify import UniversalDownloadTable
stats = UniversalDownloadTable.get_statistics()
return jsonify({
'statistics': stats,
'generated_at': logger.info(f"Statistics generated")
})
except Exception as e:
logger.error(f"Error getting statistics: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/batch', methods=['POST'])
def batch_download():
"""
Add multiple URLs to download queue
Request body:
{
"urls": ["url1", "url2", "url3"],
"quality": "high",
"output_dir": "/path/to/output"
}
"""
try:
data = request.get_json()
if not data or not data.get('urls'):
return jsonify({'error': 'URLs array is required'}), 400
urls = data['urls']
quality_str = data.get('quality', 'high')
output_dir = data.get('output_dir')
if not isinstance(urls, list):
return jsonify({'error': 'URLs must be an array'}), 400
# Validate quality
try:
quality = DownloadQuality(quality_str)
except ValueError:
return jsonify({'error': f'Invalid quality: {quality_str}'}), 400
# Process each URL
results = []
for url in urls:
url = url.strip()
if not url:
continue
try:
# Parse URL
parsed_url = universal_music_downloader.parse_url(url)
if not parsed_url:
results.append({
'url': url,
'success': False,
'error': 'Unsupported URL format'
})
continue
# Add to download queue
item_id = universal_music_downloader.add_download(url, quality, output_dir)
if item_id:
results.append({
'url': url,
'success': True,
'item_id': item_id,
'service': parsed_url.service.value,
'item_type': parsed_url.item_type
})
else:
results.append({
'url': url,
'success': False,
'error': 'Failed to add to queue'
})
except Exception as e:
logger.error(f"Error processing URL {url}: {e}")
results.append({
'url': url,
'success': False,
'error': 'Processing error'
})
successful = sum(1 for r in results if r['success'])
failed = len(results) - successful
return jsonify({
'total': len(results),
'successful': successful,
'failed': failed,
'results': results
})
except Exception as e:
logger.error(f"Error in batch download: {e}")
return jsonify({'error': 'Internal server error'}), 500
def register_universal_downloader_api(app):
"""Register universal downloader API with Flask app"""
app.register_blueprint(universal_downloader_bp)
logger.info("Universal music downloader API registered")
-601
View File
@@ -1,601 +0,0 @@
"""
Update Tracking API Endpoints
This module provides REST API endpoints for the artist update tracking system,
including following artists, managing preferences, and getting updates.
"""
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any
from flask import Blueprint, request, jsonify
from flask_login import login_required, current_user
from swingmusic.db import db
from swingmusic.services.update_tracker import update_tracker, FollowLevel, ReleaseType
from swingmusic.utils.request import APIError, success_response, error_response
from swingmusic.utils.validators import validate_spotify_id, validate_email
logger = logging.getLogger(__name__)
update_tracking_bp = Blueprint('update_tracking', __name__, url_prefix='/api/updates')
def get_current_user_id() -> int:
"""Get current user ID from Flask-Login"""
return current_user.id if current_user.is_authenticated else None
@update_tracking_bp.route('/follow-artist', methods=['POST'])
@login_required
async def follow_artist():
"""
Follow an artist for update tracking
Request Body:
{
"artist_id": "spotify_artist_id",
"artist_name": "Artist Name",
"follow_level": "followed|favorite|casual",
"auto_download": false,
"preferred_quality": "flac"
}
"""
try:
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
# Validate required fields
artist_id = data.get('artist_id')
artist_name = data.get('artist_name')
if not artist_id or not artist_name:
return error_response("artist_id and artist_name are required", 400)
if not validate_spotify_id(artist_id):
return error_response("Invalid artist ID format", 400)
# Validate follow level
follow_level = data.get('follow_level', 'followed')
if follow_level not in ['casual', 'followed', 'favorite']:
return error_response("Invalid follow level. Must be: casual, followed, or favorite", 400)
# Validate quality preference
preferred_quality = data.get('preferred_quality', 'flac')
if preferred_quality not in ['flac', 'mp3_320', 'mp3_256', 'aac']:
return error_response("Invalid quality preference", 400)
follow_data = {
'user_id': get_current_user_id(),
'artist_id': artist_id,
'artist_name': artist_name,
'follow_level': follow_level,
'auto_download': data.get('auto_download', False),
'preferred_quality': preferred_quality
}
success = await update_tracker.follow_artist(follow_data)
if success:
return success_response({
'message': f'Now following {artist_name}',
'artist_id': artist_id,
'follow_level': follow_level
})
else:
return error_response("Failed to follow artist", 500)
except Exception as e:
logger.error(f"Error following artist: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/unfollow-artist', methods=['POST'])
@login_required
async def unfollow_artist():
"""
Unfollow an artist
Request Body:
{
"artist_id": "spotify_artist_id"
}
"""
try:
data = request.get_json()
if not data or not data.get('artist_id'):
return error_response("artist_id is required", 400)
artist_id = data['artist_id']
if not validate_spotify_id(artist_id):
return error_response("Invalid artist ID format", 400)
success = await update_tracker.unfollow_artist(get_current_user_id(), artist_id)
if success:
return success_response({
'message': 'Artist unfollowed successfully',
'artist_id': artist_id
})
else:
return error_response("Failed to unfollow artist", 500)
except Exception as e:
logger.error(f"Error unfollowing artist: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/recent', methods=['GET'])
@login_required
async def get_recent_updates():
"""
Get recent updates for followed artists
Query Parameters:
- limit: Number of updates to return (default: 20, max: 100)
- offset: Offset for pagination (default: 0)
- release_type: Filter by release type (album, single, ep, compilation)
- unread_only: Only return unread updates (true/false)
"""
try:
limit = min(request.args.get('limit', 20, type=int), 100)
offset = request.args.get('offset', 0, type=int)
release_type = request.args.get('release_type')
unread_only = request.args.get('unread_only', 'false').lower() == 'true'
# Validate release type
if release_type and release_type not in ['album', 'single', 'ep', 'compilation']:
return error_response("Invalid release type", 400)
updates = await update_tracker.get_user_updates(
get_current_user_id(),
limit=limit,
offset=offset,
release_type=release_type,
unread_only=unread_only
)
return success_response({
'updates': updates,
'limit': limit,
'offset': offset,
'total': len(updates)
})
except Exception as e:
logger.error(f"Error getting recent updates: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/settings', methods=['GET'])
@login_required
async def get_settings():
"""
Get user's update tracking settings
"""
try:
settings = await update_tracker.get_user_settings(get_current_user_id())
return success_response(settings)
except Exception as e:
logger.error(f"Error getting settings: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/settings', methods=['POST'])
@login_required
async def update_settings():
"""
Update user's update tracking settings
Request Body:
{
"enable_artist_monitoring": true,
"check_frequency": "daily",
"auto_download_favorites": false,
"auto_download_followed": false,
"max_auto_downloads_per_week": 5,
"quality_preference": "flac",
"storage_limit_mb": 10240,
"notification_channels": {
"in_app": true,
"push": false,
"email": false,
"discord": false
},
"exclude_explicit": false,
"preferred_release_types": ["album", "ep", "single"]
}
"""
try:
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
# Validate settings
if 'check_frequency' in data and data['check_frequency'] not in ['hourly', 'daily', 'weekly']:
return error_response("Invalid check frequency", 400)
if 'quality_preference' in data and data['quality_preference'] not in ['flac', 'mp3_320', 'mp3_256', 'aac']:
return error_response("Invalid quality preference", 400)
if 'max_auto_downloads_per_week' in data:
max_downloads = data['max_auto_downloads_per_week']
if not isinstance(max_downloads, int) or max_downloads < 0 or max_downloads > 50:
return error_response("Invalid max auto downloads value", 400)
if 'storage_limit_mb' in data:
storage_limit = data['storage_limit_mb']
if not isinstance(storage_limit, int) or storage_limit < 100 or storage_limit > 102400:
return error_response("Invalid storage limit", 400)
success = await update_tracker.update_user_settings(get_current_user_id(), data)
if success:
return success_response({
'message': 'Settings updated successfully',
'settings': data
})
else:
return error_response("Failed to update settings", 500)
except Exception as e:
logger.error(f"Error updating settings: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/auto-download/<release_id>', methods=['POST'])
@login_required
async def auto_download_release(release_id):
"""
Trigger auto-download for a specific release
Path Parameters:
- release_id: Spotify release ID
"""
try:
if not validate_spotify_id(release_id):
return error_response("Invalid release ID format", 400)
success = await update_tracker.auto_download_release(get_current_user_id(), release_id)
if success:
return success_response({
'message': 'Download queued successfully',
'release_id': release_id
})
else:
return error_response("Failed to queue download", 500)
except Exception as e:
logger.error(f"Error auto-downloading release: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/stats', methods=['GET'])
@login_required
async def get_update_stats():
"""
Get user's update tracking statistics
"""
try:
stats = await update_tracker.get_user_stats(get_current_user_id())
return success_response(stats)
except Exception as e:
logger.error(f"Error getting stats: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/followed-artists', methods=['GET'])
@login_required
async def get_followed_artists():
"""
Get list of followed artists
Query Parameters:
- limit: Number of artists to return (default: 50, max: 200)
- offset: Offset for pagination (default: 0)
- follow_level: Filter by follow level (casual, followed, favorite)
"""
try:
limit = min(request.args.get('limit', 50, type=int), 200)
offset = request.args.get('offset', 0, type=int)
follow_level = request.args.get('follow_level')
# Validate follow level
if follow_level and follow_level not in ['casual', 'followed', 'favorite']:
return error_response("Invalid follow level", 400)
artists = await update_tracker.get_followed_artists(
get_current_user_id(),
limit=limit,
offset=offset,
follow_level=follow_level
)
return success_response({
'artists': artists,
'limit': limit,
'offset': offset,
'total': len(artists)
})
except Exception as e:
logger.error(f"Error getting followed artists: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/artist/<artist_id>/follow-status', methods=['GET'])
@login_required
async def get_artist_follow_status(artist_id):
"""
Get follow status for a specific artist
Path Parameters:
- artist_id: Spotify artist ID
"""
try:
if not validate_spotify_id(artist_id):
return error_response("Invalid artist ID format", 400)
status = await update_tracker.get_artist_follow_status(get_current_user_id(), artist_id)
if status:
return success_response(status)
else:
return success_response({
'is_following': False,
'artist_id': artist_id
})
except Exception as e:
logger.error(f"Error getting artist follow status: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/artist/<artist_id>', methods=['PUT'])
@login_required
async def update_artist_follow(artist_id):
"""
Update follow settings for an artist
Path Parameters:
- artist_id: Spotify artist ID
Request Body:
{
"follow_level": "followed|favorite|casual",
"auto_download": true,
"preferred_quality": "flac",
"notification_preferences": {
"in_app": true,
"push": false,
"email": false,
"discord": false
}
}
"""
try:
if not validate_spotify_id(artist_id):
return error_response("Invalid artist ID format", 400)
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
# Validate follow level
if 'follow_level' in data and data['follow_level'] not in ['casual', 'followed', 'favorite']:
return error_response("Invalid follow level", 400)
# Validate quality preference
if 'preferred_quality' in data and data['preferred_quality'] not in ['flac', 'mp3_320', 'mp3_256', 'aac']:
return error_response("Invalid quality preference", 400)
success = await update_tracker.update_artist_follow(
get_current_user_id(),
artist_id,
data
)
if success:
return success_response({
'message': 'Artist follow settings updated',
'artist_id': artist_id,
'settings': data
})
else:
return error_response("Failed to update artist follow settings", 500)
except Exception as e:
logger.error(f"Error updating artist follow: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/release/<release_id>', methods=['GET'])
@login_required
async def get_release_details(release_id):
"""
Get details for a specific release update
Path Parameters:
- release_id: Spotify release ID
"""
try:
if not validate_spotify_id(release_id):
return error_response("Invalid release ID format", 400)
release = await update_tracker.get_release_details(get_current_user_id(), release_id)
if release:
return success_response(release)
else:
return error_response("Release not found", 404)
except Exception as e:
logger.error(f"Error getting release details: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/release/<release_id>/mark-read', methods=['POST'])
@login_required
async def mark_release_read(release_id):
"""
Mark a release update as read
Path Parameters:
- release_id: Spotify release ID
"""
try:
if not validate_spotify_id(release_id):
return error_response("Invalid release ID format", 400)
success = await update_tracker.mark_release_read(get_current_user_id(), release_id)
if success:
return success_response({
'message': 'Release marked as read',
'release_id': release_id
})
else:
return error_response("Failed to mark release as read", 500)
except Exception as e:
logger.error(f"Error marking release as read: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/notifications', methods=['GET'])
@login_required
async def get_notifications():
"""
Get user's update notifications
Query Parameters:
- limit: Number of notifications to return (default: 20, max: 100)
- offset: Offset for pagination (default: 0)
- unread_only: Only return unread notifications (true/false)
"""
try:
limit = min(request.args.get('limit', 20, type=int), 100)
offset = request.args.get('offset', 0, type=int)
unread_only = request.args.get('unread_only', 'false').lower() == 'true'
notifications = await update_tracker.get_notifications(
get_current_user_id(),
limit=limit,
offset=offset,
unread_only=unread_only
)
return success_response({
'notifications': notifications,
'limit': limit,
'offset': offset,
'total': len(notifications)
})
except Exception as e:
logger.error(f"Error getting notifications: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/notifications/mark-all-read', methods=['POST'])
@login_required
async def mark_all_notifications_read():
"""
Mark all notifications as read for the user
"""
try:
success = await update_tracker.mark_all_notifications_read(get_current_user_id())
if success:
return success_response({
'message': 'All notifications marked as read'
})
else:
return error_response("Failed to mark notifications as read", 500)
except Exception as e:
logger.error(f"Error marking all notifications as read: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/search/artists', methods=['GET'])
@login_required
async def search_artists_to_follow():
"""
Search for artists to follow
Query Parameters:
- q: Search query
- limit: Number of results to return (default: 10, max: 50)
"""
try:
query = request.args.get('q')
if not query:
return error_response("Search query is required", 400)
limit = min(request.args.get('limit', 10, type=int), 50)
artists = await update_tracker.search_artists(query, limit)
return success_response({
'artists': artists,
'query': query,
'limit': limit,
'total': len(artists)
})
except Exception as e:
logger.error(f"Error searching artists: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/export/followed-artists', methods=['GET'])
@login_required
async def export_followed_artists():
"""
Export followed artists as JSON or CSV
Query Parameters:
- format: Export format (json|csv) - default: json
"""
try:
export_format = request.args.get('format', 'json').lower()
if export_format not in ['json', 'csv']:
return error_response("Invalid export format. Must be json or csv", 400)
data = await update_tracker.export_followed_artists(get_current_user_id(), export_format)
if export_format == 'csv':
from flask import Response
return Response(
data,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=followed_artists.csv'}
)
else:
return success_response({'followed_artists': data})
except Exception as e:
logger.error(f"Error exporting followed artists: {e}")
return error_response("Internal server error", 500)
# Error handlers
@update_tracking_bp.errorhandler(404)
def not_found(error):
return error_response("Endpoint not found", 404)
@update_tracking_bp.errorhandler(500)
def internal_error(error):
return error_response("Internal server error", 500)
-392
View File
@@ -1,392 +0,0 @@
"""
Contains all the file upload routes for manual music upload functionality.
"""
import os
import shutil
import pathlib
from pathlib import Path
from datetime import datetime
from typing import List, Optional
import tempfile
import mimetypes
from flask import request, jsonify
from flask_openapi3 import Tag
from pydantic import BaseModel, Field
from flask_openapi3 import APIBlueprint
from werkzeug.utils import secure_filename
from swingmusic import settings
from swingmusic.config import UserConfig
from swingmusic.db.libdata import TrackTable
from swingmusic.api.auth import admin_required
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.metadata import extract_metadata
from swingmusic.serializers.track import serialize_track
tag = Tag(name="Upload", description="Manual music file upload functionality")
api = APIBlueprint("upload", __name__, url_prefix="/upload", abp_tags=[tag])
# Allowed audio file extensions
ALLOWED_EXTENSIONS = {
'mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'wma', 'opus',
'aiff', 'au', 'ra', '3gp', 'amr', 'awb', 'dct', 'dvf',
'm4p', 'mmf', 'mpc', 'msv', 'nmf', 'nsf', 'ogg', 'qcp',
'ra', 'rm', 'sln', 'vox', 'wma', 'wv'
}
# Maximum file size (100MB)
MAX_FILE_SIZE = 100 * 1024 * 1024
def is_allowed_file(filename: str) -> bool:
"""Check if file has an allowed audio extension."""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def is_path_within_root_dirs(filepath: str) -> bool:
"""
Check if a filepath is within one of the configured root directories.
Prevents directory traversal attacks.
"""
config = UserConfig()
resolved_path = Path(filepath).resolve()
for root_dir in config.rootDirs:
if root_dir == "$home":
root_path = Path.home().resolve()
else:
root_path = Path(root_dir).resolve()
# Check if resolved_path is the root or a child of root
if resolved_path == root_path or root_path in resolved_path.parents:
return True
return False
class UploadResponse(BaseModel):
success: bool = Field(description="Whether the upload was successful")
message: str = Field(description="Status message")
track_id: Optional[str] = Field(None, description="ID of the added track")
filename: Optional[str] = Field(None, description="Name of the uploaded file")
class BatchUploadResponse(BaseModel):
success: bool = Field(description="Whether the batch upload was successful")
message: str = Field(description="Status message")
uploaded_files: List[UploadResponse] = Field(description="List of upload results")
failed_files: List[str] = Field(description="List of failed files")
@api.post("/single")
@admin_required()
def upload_single_file():
"""
Upload a single music file
Uploads a single music file to the configured music folder and adds it to the library.
Supports drag-and-drop and file selection.
"""
try:
if 'file' not in request.files:
return jsonify({
"success": False,
"message": "No file provided"
}), 400
file = request.files['file']
if file.filename == '':
return jsonify({
"success": False,
"message": "No file selected"
}), 400
# Check file extension
if not is_allowed_file(file.filename):
return jsonify({
"success": False,
"message": f"File type not allowed. Supported formats: {', '.join(sorted(ALLOWED_EXTENSIONS))}"
}), 400
# Check file size
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > MAX_FILE_SIZE:
return jsonify({
"success": False,
"message": f"File too large. Maximum size is {MAX_FILE_SIZE // (1024*1024)}MB"
}), 400
# Get upload directory from settings or use first root directory
config = UserConfig()
upload_dir = None
# Check if there's a specific upload directory configured
if hasattr(config, 'uploadDir') and config.uploadDir:
upload_dir = Path(config.uploadDir)
else:
# Use the first root directory as default
if config.rootDirs:
first_root = config.rootDirs[0]
if first_root == "$home":
upload_dir = Path.home() / "Music"
else:
upload_dir = Path(first_root)
else:
# Fallback to user's Music directory
upload_dir = Path.home() / "Music"
# Ensure upload directory exists
upload_dir.mkdir(parents=True, exist_ok=True)
# Secure the filename and create full path
filename = secure_filename(file.filename)
file_path = upload_dir / filename
# Handle filename conflicts
counter = 1
original_filename = filename
while file_path.exists():
name, ext = os.path.splitext(original_filename)
filename = f"{name}_{counter}{ext}"
file_path = upload_dir / filename
counter += 1
# Save the file
file.save(file_path)
# Extract metadata and add to library
try:
# This would trigger a library rescan for the specific file
# For now, we'll return the file info and let the frontend handle the refresh
track_info = {
"filepath": str(file_path),
"filename": filename,
"size": file_size
}
return jsonify({
"success": True,
"message": f"File '{filename}' uploaded successfully",
"filename": filename,
"filepath": str(file_path),
"track_info": track_info
})
except Exception as e:
# If metadata extraction fails, still return success for the upload
return jsonify({
"success": True,
"message": f"File '{filename}' uploaded successfully (metadata extraction failed)",
"filename": filename,
"filepath": str(file_path),
"warning": f"Metadata extraction failed: {str(e)}"
})
except Exception as e:
return jsonify({
"success": False,
"message": f"Upload failed: {str(e)}"
}), 500
@api.post("/batch")
@admin_required()
def upload_multiple_files():
"""
Upload multiple music files
Uploads multiple music files to the configured music folder and adds them to the library.
Supports drag-and-drop of multiple files.
"""
try:
if 'files' not in request.files:
return jsonify({
"success": False,
"message": "No files provided"
}), 400
files = request.files.getlist('files')
if not files:
return jsonify({
"success": False,
"message": "No files selected"
}), 400
uploaded_files = []
failed_files = []
# Get upload directory (same logic as single upload)
config = UserConfig()
upload_dir = None
if hasattr(config, 'uploadDir') and config.uploadDir:
upload_dir = Path(config.uploadDir)
else:
if config.rootDirs:
first_root = config.rootDirs[0]
if first_root == "$home":
upload_dir = Path.home() / "Music"
else:
upload_dir = Path(first_root)
else:
upload_dir = Path.home() / "Music"
upload_dir.mkdir(parents=True, exist_ok=True)
for file in files:
if file.filename == '':
continue
try:
# Check file extension
if not is_allowed_file(file.filename):
failed_files.append(f"{file.filename} - File type not allowed")
continue
# Check file size
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > MAX_FILE_SIZE:
failed_files.append(f"{file.filename} - File too large")
continue
# Secure filename and handle conflicts
filename = secure_filename(file.filename)
file_path = upload_dir / filename
counter = 1
original_filename = filename
while file_path.exists():
name, ext = os.path.splitext(original_filename)
filename = f"{name}_{counter}{ext}"
file_path = upload_dir / filename
counter += 1
# Save the file
file.save(file_path)
uploaded_files.append({
"success": True,
"message": f"File '{filename}' uploaded successfully",
"filename": filename,
"filepath": str(file_path),
"size": file_size
})
except Exception as e:
failed_files.append(f"{file.filename} - {str(e)}")
total_files = len(uploaded_files) + len(failed_files)
success_count = len(uploaded_files)
return jsonify({
"success": len(uploaded_files) > 0,
"message": f"Uploaded {success_count} of {total_files} files",
"uploaded_files": uploaded_files,
"failed_files": failed_files
})
except Exception as e:
return jsonify({
"success": False,
"message": f"Batch upload failed: {str(e)}"
}), 500
@api.get("/config")
def get_upload_config():
"""
Get upload configuration
Returns the current upload configuration including allowed file types,
maximum file size, and upload directory.
"""
config = UserConfig()
# Determine upload directory
upload_dir = None
if hasattr(config, 'uploadDir') and config.uploadDir:
upload_dir = config.uploadDir
elif config.rootDirs:
first_root = config.rootDirs[0]
if first_root == "$home":
upload_dir = str(Path.home() / "Music")
else:
upload_dir = first_root
else:
upload_dir = str(Path.home() / "Music")
return jsonify({
"allowed_extensions": sorted(list(ALLOWED_EXTENSIONS)),
"max_file_size": MAX_FILE_SIZE,
"max_file_size_mb": MAX_FILE_SIZE // (1024 * 1024),
"upload_directory": upload_dir,
"supported_formats": [
{"ext": ext, "description": get_format_description(ext)}
for ext in sorted(ALLOWED_EXTENSIONS)
]
})
def get_format_description(extension: str) -> str:
"""Get a user-friendly description for a file format."""
descriptions = {
'mp3': 'MP3 Audio',
'flac': 'FLAC Lossless Audio',
'wav': 'WAV Audio',
'aac': 'AAC Audio',
'm4a': 'M4A Audio',
'ogg': 'OGG Vorbis Audio',
'wma': 'WMA Audio',
'opus': 'Opus Audio',
'aiff': 'AIFF Audio',
'au': 'AU Audio',
'ra': 'RealAudio',
'3gp': '3GP Audio',
'amr': 'AMR Audio',
'awb': 'AWB Audio',
'dct': 'DCT Audio',
'dvf': 'DVF Audio',
'm4p': 'M4P Audio',
'mmf': 'MMF Audio',
'mpc': 'MPC Audio',
'msv': 'MSV Audio',
'nmf': 'NMF Audio',
'nsf': 'NSF Audio',
'qcp': 'QCP Audio',
'rm': 'RealMedia Audio',
'sln': 'SLN Audio',
'vox': 'VOX Audio',
'wv': 'WavPack Audio'
}
return descriptions.get(extension.lower(), f'{extension.upper()} Audio')
@api.post("/rescan")
@admin_required()
def trigger_library_rescan():
"""
Trigger library rescan
Triggers a library rescan to detect newly uploaded files.
"""
try:
# This would integrate with the existing library scanning system
# For now, return a success response
return jsonify({
"success": True,
"message": "Library rescan triggered successfully"
})
except Exception as e:
return jsonify({
"success": False,
"message": f"Failed to trigger library rescan: {str(e)}"
}), 500
-269
View File
@@ -1,269 +0,0 @@
import datetime as dt
import pathlib
import logging
from flask import Response, request
from flask_cors import CORS
from flask_compress import Compress
from flask_openapi3 import Info
from flask_openapi3 import OpenAPI
from flask_jwt_extended import JWTManager, create_access_token, get_jwt, get_jwt_identity, set_access_cookies, verify_jwt_in_request
from swingmusic import api as swing_api
from swingmusic.config import UserConfig
from swingmusic.db.userdata import UserTable
from swingmusic.settings import Metadata, Paths
from swingmusic.utils.paths import get_client_files_extensions
from swingmusic.api.plugins import lyrics as lyrics_plugin
from swingmusic.api.plugins import mixes as mixes_plugin
log = logging.getLogger(__name__)
# # # # # # # # # # # # # # # # # #
# Grouped configuration function #
# # # # # # # # # # # # # # # # # #
def config_app(web):
# CORS
CORS(web, origins="*", supports_credentials=True)
# RESPONSE COMPRESSION
# Only compress JSON responses
Compress(web)
web.config["COMPRESS_MIMETYPES"] = [
"application/json",
]
def config_jwt(web):
# JWT CONFIGS
web.config["JWT_VERIFY_SUB"] = False
web.config["JWT_SECRET_KEY"] = UserConfig().serverId
web.config["JWT_TOKEN_LOCATION"] = ["cookies", "headers"]
web.config["JWT_COOKIE_CSRF_PROTECT"] = False
web.config["JWT_SESSION_COOKIE"] = False
jwt_expiry = int(dt.timedelta(days=30).total_seconds())
web.config["JWT_ACCESS_TOKEN_EXPIRES"] = jwt_expiry
jwt = JWTManager(web)
@jwt.user_lookup_loader
def user_lookup_callback(_jwt_header, jwt_data):
identity = jwt_data["sub"]
userid = identity["id"]
user = UserTable.get_by_id(userid)
if user:
return user.todict()
def load_endpoints(web: OpenAPI):
# Register all the API blueprints
with web.app_context():
web.register_api(swing_api.album.api)
web.register_api(swing_api.artist.api)
web.register_api(swing_api.stream.api)
web.register_api(swing_api.search.api)
web.register_api(swing_api.folder.api)
web.register_api(swing_api.playlist.api)
web.register_api(swing_api.favorites.api)
web.register_api(swing_api.imgserver.api)
web.register_api(swing_api.settings.api)
web.register_api(swing_api.colors.api)
web.register_api(swing_api.lyrics.api)
web.register_api(swing_api.backup_and_restore.api)
web.register_api(swing_api.collections.api)
# Logger
web.register_api(swing_api.scrobble.api)
# Home
web.register_api(swing_api.home.api)
web.register_api(swing_api.getall.api)
# Auth
web.register_api(swing_api.auth.api)
# Spotify Downloader
web.register_api(swing_api.spotify.api)
web.register_api(swing_api.spotify_settings.api)
# Enhanced Search
from swingmusic.api.enhanced_search import register_enhanced_search_api
register_enhanced_search_api(web)
# Universal Music Downloader
from swingmusic.api.universal_downloader import register_universal_downloader_api
register_universal_downloader_api(web)
# Update Tracking
web.register_blueprint(swing_api.update_tracking.update_tracking_bp)
# Audio Quality Management
web.register_blueprint(swing_api.audio_quality.audio_quality_bp)
# Music Catalog Service
web.register_blueprint(swing_api.music_catalog.music_catalog_bp)
# Advanced UX Service
web.register_blueprint(swing_api.advanced_ux.advanced_ux_bp)
# Mobile Offline Service
web.register_blueprint(swing_api.mobile_offline.mobile_offline_bp)
def load_plugins(web: OpenAPI):
# TODO: rework plugin support
# Plugins
web.register_api(swing_api.plugins.api)
web.register_api(lyrics_plugin.api)
web.register_api(mixes_plugin.api)
# # # # # # # # # # #
# Create App object #
# # # # # # # # # # #
api_info = Info(
title="Swing Music",
version=f"v{Metadata.version}",
description="The REST API exposed by your Swing Music server",
)
app = OpenAPI(__name__, info=api_info, doc_prefix="/docs")
def check_auth_need() -> bool:
"""
Check if the current request is for a static file.
We do not need auth for index or static images of index.
:return: True if static file else False
"""
# INFO: Routes that don't need authentication
urls = {
"/auth/login",
"/auth/users",
"/auth/pair",
"/auth/logout",
"/auth/refresh",
"/docs",
}
files = {
".webp",
".jpg",
*get_client_files_extensions()
}
urls = tuple(urls)
files = tuple(files)
if request.path == "/" or request.path.endswith(files):
return True
# if request path starts with any of the blacklisted routes, don't verify jwt
if request.path.startswith(urls):
return True
return False
# # # # # # # # # # # # #
# global endpoint logic #
# # # # # # # # # # # # #
@app.route("/<path:path>")
def serve_client_files(path: str):
"""
Serves the static files in the client folder.
"""
# TODO: rule out possible double /client path.
# path sometimes prepended with /client like '/client/some.js' resolves to '/client/client/some.js'
js_or_css = path.endswith(".js") or path.endswith(".css")
if not js_or_css:
return app.send_static_file(path)
# INFO: Safari doesn't support gzip encoding
# See issue: https://github.com/swingmx/swingmusic/issues/155
user_agent = request.headers.get("User-Agent", "")
if "Safari" in user_agent and "Chrome" not in user_agent:
return app.send_static_file(path)
if "gzip" in request.headers.get("Accept-Encoding", ""):
gz_name = path + ".gz"
gzipped_path = pathlib.Path(app.static_folder or "") / gz_name
if gzipped_path.exists():
response = app.make_response(app.send_static_file(gz_name))
response.headers["Content-Encoding"] = "gzip"
return response
return app.send_static_file(path)
@app.route("/")
def serve_client():
"""
Serves the index.html file at `client/index.html`.
"""
return app.send_static_file("index.html")
def build() -> OpenAPI:
"""
Call this function to obtain the final flask/openapi object.
Do not import app directly as the static_folder can only be set
when cli args are parsed.
:return: OpenApi object with all config set
"""
# set late state config
app.static_folder = Paths().client_path
@app.before_request
def verify_auth():
"""
Verifies the JWT token before each request.
"""
if check_auth_need():
return
verify_jwt_in_request()
@app.after_request
def refresh_expiring_jwt(response: Response):
"""
Refreshes the cookies JWT token after each request.
"""
# INFO: If the request has an Authorization header, don't refresh the jwt
# Request is probably from the mobile client or a third party
if check_auth_need() or request.headers.get("Authorization"):
return response
try:
exp_timestamp = get_jwt()["exp"]
until = dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=7)
if until.timestamp() > exp_timestamp:
access_token = create_access_token(identity=get_jwt_identity())
set_access_cookies(response, access_token)
return response
except (RuntimeError, KeyError):
return response
config_app(app)
config_jwt(app)
load_endpoints(app)
load_plugins(app)
return app
-8
View File
@@ -1,8 +0,0 @@
<svg viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="defaultAlbumImage">
<g id="defaultAlbumImage_2">
<path id="Vector" d="M21 31.5C26.799 31.5 31.5 26.799 31.5 21C31.5 15.201 26.799 10.5 21 10.5C15.201 10.5 10.5 15.201 10.5 21C10.5 26.799 15.201 31.5 21 31.5Z" stroke="#78777F" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M21 23.5C22.3807 23.5 23.5 22.3807 23.5 21C23.5 19.6193 22.3807 18.5 21 18.5C19.6193 18.5 18.5 19.6193 18.5 21C18.5 22.3807 19.6193 23.5 21 23.5Z" stroke="#78777F" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 654 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

-13
View File
@@ -1,13 +0,0 @@
<svg viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="defaultPlaylistImage">
<g id="defaultPlaylistImage_2">
<g id="Group">
<path id="Vector" d="M14.1 29.3C15.6464 29.3 16.9 28.0464 16.9 26.5C16.9 24.9536 15.6464 23.7 14.1 23.7C12.5536 23.7 11.3 24.9536 11.3 26.5C11.3 28.0464 12.5536 29.3 14.1 29.3Z" stroke="#78777F" stroke-miterlimit="10" stroke-linecap="round"/>
<path id="Vector_2" d="M16.9 26.5V12.8" stroke="#78777F" stroke-miterlimit="10"/>
<path id="Vector_3" d="M21 24.2H29.3" stroke="#78777F" stroke-miterlimit="10"/>
<path id="Vector_4" d="M21 16.9H31.1" stroke="#78777F" stroke-miterlimit="10"/>
<path id="Vector_5" d="M21 20.5H30.2" stroke="#78777F" stroke-miterlimit="10"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 737 B

-162
View File
@@ -1,162 +0,0 @@
import json
from pathlib import Path
from typing import Any
from dataclasses import dataclass, asdict, field, InitVar
from swingmusic.data import ARTIST_SPLIT_IGNORE_LIST
from swingmusic.settings import Paths, Singleton
def load_artist_ignore_list_from_file(filepath: Path) -> set[str]:
"""
Loads artist names from a text file.
:params filepath: filepath to file
:returns: Lines with content as ``set``, else empty ``set``
"""
if filepath.exists():
text = filepath.read_text()
return set([line.strip() for line in text.splitlines() if line.strip()])
else:
return set()
def load_default_artist_ignore_list() -> set[str]:
"""
Loads the default artist-ignore-list from the text file.
Returns an empty set if the file doesn't exist.
"""
return ARTIST_SPLIT_IGNORE_LIST
def load_user_artist_ignore_list() -> set[str]:
"""
Loads the user-defined artist ignore list from the config directory.
Returns an empty set if the file doesn't exist.
"""
user_file = Paths().config_dir / "artist_split_ignore.txt"
if user_file.exists():
lines = user_file.read_text().splitlines()
return set([line.strip() for line in lines if line.strip()])
else:
return set()
@dataclass
class UserConfig(metaclass=Singleton):
_finished: bool = field(default=False, init=False) # if post init succesfully
_config_path: InitVar[Path] = Path("")
_artist_split_ignore_file_name: InitVar[str] = "artist_split_ignore.txt"
# NOTE: only auth stuff are used (the others are still reading/writing to db)
# TODO: Move the rest of the settings to the config file
# auth stuff
# NOTE: Don't expose the userId via the API
serverId: str = ""
usersOnLogin: bool = True
# lists
rootDirs: list[str] = field(default_factory=list)
excludeDirs: list[str] = field(default_factory=list)
artistSeparators: set[str] = field(default_factory=lambda: {";", "/"})
artistSplitIgnoreList: set[str] = field(
# TODO: in the future, maybe setup a server where users can contribute to the global ignore list?
default_factory=lambda: load_default_artist_ignore_list().union(
load_user_artist_ignore_list()
)
)
genreSeparators: set[str] = field(default_factory=lambda: {"/", ";", "&"})
# tracks
extractFeaturedArtists: bool = True
removeProdBy: bool = True
removeRemasterInfo: bool = True
# albums
mergeAlbums: bool = False
cleanAlbumTitle: bool = True
showAlbumsAsSingles: bool = False
# misc
enablePeriodicScans: bool = False
scanInterval: int = 10
enableWatchdog: bool = False
showPlaylistsInFolderView: bool = False
# plugins
enablePlugins: bool = True
lastfmApiKey: str = "0553005e93f9a4b4819d835182181806"
lastfmApiSecret: str = "5e5306fbf3e8e3bc92f039b6c6c4bd4e"
lastfmSessionKeys: dict[str, str] = field(default_factory=dict)
def __post_init__(self, _config_path, _artist_split_ignore_file_name):
"""
Loads the config file and sets the values to this instance
"""
# set config path locally to avoid writing to file
config_path = Paths().config_file_path
if config_path.exists():
config = self.load_config(config_path)
else:
self._config_path = config_path
return
# loop through the config file and set the values
for key, value in config.items():
if key == "artistSplitIgnoreList":
# Merge with default values and user file values instead of overwriting
default_values = load_default_artist_ignore_list()
user_values = load_user_artist_ignore_list()
setattr(self, key, default_values.union(user_values).union(value))
else:
setattr(self, key, value)
# finally, set the config path
self._config_path = config_path
self._finished = True
def setup_config_file(self) -> None:
"""
Creates the config file with the default settings
if it doesn't exist
"""
# if not exists, create the config file
config = Path(self._config_path)
if not config.exists():
self.write_to_file(asdict(self))
def load_config(self, path: Path) -> dict[str, Any]:
"""
Reads the settings from the config file.
Returns a dictget_root_dirs
"""
return json.loads(path.read_text())
def write_to_file(self, settings: dict[str, Any]):
"""
Writes the settings to the config file
"""
# remove internal attributes
settings = {k: v for k, v in settings.items() if not k.startswith("_")}
with self._config_path.open(mode="w") as f:
json.dump(settings, f, indent=4, default=list)
def __setattr__(self, key: str, value: Any) -> None:
"""
Writes to the config file whenever a value is set
"""
# protection.
# only write to file if post_init completed
if not self._finished:
super().__setattr__(key, value)
return
super().__setattr__(key, value)
# if is internal attribute, don't write to file
if key.startswith("_") or not self._config_path:
return
self.write_to_file(asdict(self))
-31
View File
@@ -1,31 +0,0 @@
import time
import schedule
from swingmusic.crons.mixes import Mixes
from swingmusic.lib.recipes.recents import RecentlyAdded, RecentlyPlayed
from swingmusic.lib.recipes.topstreamed import TopArtists
from swingmusic.utils.threading import background
@background
def start_cron_jobs():
"""
This is the function that triggers the cron jobs.
"""
# NOTE: RecentlyPlayed is not a CRON job, it's triggered here to
# populate the values for the very first time.
RecentlyPlayed()
RecentlyAdded()
# Initialized CRON jobs
TopArtists()
TopArtists(duration="week")
Mixes()
# Trigger all CRON jobs when the app is started.
schedule.run_all()
# Run all CRON jobs on a loop.
while True:
schedule.run_pending()
time.sleep(1)
-23
View File
@@ -1,23 +0,0 @@
import schedule
from abc import ABC, abstractmethod
class CronJob(ABC):
"""
A cron job that will be run on a regular interval.
"""
name: str
hours: int = 1
def __init__(self):
schedule.every(self.hours).hours.do(self.run)
@abstractmethod
def run(self):
"""
The function that will be called by the cron job.
"""
...
-25
View File
@@ -1,25 +0,0 @@
from swingmusic.crons.cron import CronJob
from swingmusic.lib.recipes.artistmixes import ArtistMixes
from swingmusic.lib.recipes.because import BecauseYouListened
class Mixes(CronJob):
"""
This cron job creates mixes displayed on the homepage.
"""
name: str = "mixes"
hours: int = 12
def __init__(self):
super().__init__()
def run(self):
"""
Creates the artist mixes
"""
ArtistMixes()
# INFO: Because you listened to artist items are generated using
# the artist mixes, so run them after the artist mixes are created.
BecauseYouListened()
-81
View File
@@ -1,81 +0,0 @@
ARTIST_SPLIT_IGNORE_LIST = {
"AC/DC",
"Sonny & Cher",
"Bob marley & the wailers",
"Crosby, Stills, Nash & Young",
"Booker T. & the M.G.'s",
"Maurice Williams & The Zodiacs",
"Frank DeVol and His Orchestra",
"Tommy James & the Shondells",
"Smith & Thell",
"Peter, Paul & Mary",
"Simon & Garfunkel",
"Judy & Mary",
"Florence & The Machine",
"Belle & Sebastian",
"C&C Music Factory",
"C & C Music Factory",
"FO&O",
"The Product G&B",
"Big Brother and the Holding Company",
"Tyler, The Creator",
"Eric B. & Rakim",
"DJ Jazzy Jeff & The Fresh Prince",
"Rob Base & DJ E-Z Rock",
"Nick Cave & the Bad Seeds",
"For King + Country",
"For King & Country",
"Robson & Jerome",
"Charles & Eddie",
"Womack & Womack",
"Pepsi & Shirlie",
"Mel & Kim",
"Wendy & Lisa",
"Tony! Toni! Toné!",
"Phillips, Craig & Dean",
"Crosby & Nash",
"Blood, Sweat & Tears",
"Mike + The Mechanics",
"Earth, Wind & Fire",
"Emerson, Lake & Palmer",
"Mumford & Sons",
"Hall & Oates",
"Tom Petty & The Heartbreakers",
"Sly & the Family Stone",
"Booker T. & the M.G.'s",
"KC & the Sunshine Band",
"Huey Lewis & the News",
"Joan Jett & the Blackhearts",
"Echo & the Bunnymen",
"Katrina & the Waves",
"Gladys Knight & the Pips",
"Martha & the Vandellas",
"Martha Reeves and the Vandellas",
"? and the Mysterians",
"Kool & the Gang",
"Josie & the Pussycats",
"Dan + Shay",
"Brooks & Dunn",
"Peaches & Herb",
"Seals & Crofts",
"Loggins & Messina",
"England Dan & John Ford Coley",
"Ashford & Simpson",
"Sam & Dave",
"Ike & Tina Turner",
"Sonny & Cher",
"Captain & Tennille",
"Hootie & the Blowfish",
"Diana Ross & the Supremes",
"Herb Alpert & the Tijuana Brass",
"The Mamas & the Papas",
"Gerry & the Pacemakers",
"Chloe x Halle",
"Durand Jones & The Indications",
"St. Paul & The Broken Bones",
"Nathaniel Rateliff & The Night Sweats",
"Aly & AJ",
"Maddie & Tae",
"Nico & Vinz",
"Yusuf / Cat Stevens"
}
-66
View File
@@ -1,66 +0,0 @@
from typing import Any
from sqlalchemy import (
delete,
func,
insert,
select,
)
from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass
from swingmusic.db.engine import DbEngine
class Base(MappedAsDataclass, DeclarativeBase):
"""
Base class for all database models.
It has methods common to all tables. eg. `insert_one`, `insert_many`, `remove_all`, `remove_one`, `all`, `count`.
"""
@classmethod
def execute(cls, stmt: Any, commit: bool = False):
with DbEngine.manager(commit=commit) as session:
result = session.execute(stmt.execution_options(yield_per=100))
if commit:
session.commit()
yield result
@classmethod
def insert_many(cls, items: list[dict[str, Any]]):
"""
Inserts multiple items into the database.
"""
return next(cls.execute(insert(cls).values(items), commit=True))
@classmethod
def insert_one(cls, item: dict[str, Any]):
"""
Inserts a single item into the database.
"""
return cls.insert_many([item])
@classmethod
def remove_all(cls):
return next(cls.execute(delete(cls), commit=True))
@classmethod
def remove_one(cls, id: int):
return next(cls.execute(delete(cls).where(cls.id == id), commit=True))
@classmethod
def all(cls):
return next(cls.execute(select(cls).execution_options(yield_per=100)))
@classmethod
def count(cls):
return next(cls.execute(select(func.count()).select_from(cls))).scalar()
def create_all_tables():
"""
Creates all the tables that build on the Base class.
"""
Base().metadata.create_all(DbEngine.engine)
-78
View File
@@ -1,78 +0,0 @@
from contextlib import contextmanager
from sqlalchemy import Engine, create_engine, event
from sqlalchemy.orm import sessionmaker
from swingmusic.settings import Paths
@event.listens_for(Engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA cache_size=10000")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA temp_store=FILE")
cursor.execute("PRAGMA mmap_size=0")
cursor.close()
class classproperty(property):
"""
A class property decorator.
"""
def __get__(self, owner_self, owner_cls):
if self.fget:
return self.fget(owner_cls)
class DbEngine:
"""
The database engine instance.
"""
_engine: Engine | None = None
@classproperty
def engine(cls) -> Engine:
if not cls._engine:
cls._engine = create_engine(
f"sqlite+pysqlite:///{Paths().app_db_path}",
echo=False,
max_overflow=20,
pool_size=10,
)
return cls._engine
@classmethod
@contextmanager
def manager(cls, commit: bool = False):
"""
This context manager manages access to the database.
When the context manager is entered, it returns a session object that can be used to execute SQL statements.
If the `commit` parameter is set to `True`, the context manager will commit the transaction when it exits.
"""
Session = sessionmaker(cls.engine)
try:
with Session() as session:
yield session
if commit:
session.commit()
# yield session.execution_options(preserve_rowcount=True, yield_per=100)
# yield conn.execution_options(preserve_rowcount=True, yield_per=100)
except Exception as e:
session.rollback()
raise e
finally:
if commit:
session.commit()
session.close()
# del conn
# cls.engine.clear_compiled_cache()
-81
View File
@@ -1,81 +0,0 @@
from swingmusic.config import UserConfig
from swingmusic.db import Base
from swingmusic.db.utils import track_to_dataclass, tracks_to_dataclasses
from swingmusic.db.engine import DbEngine
from sqlalchemy import JSON, Integer, String, delete, select
from sqlalchemy.orm import Mapped, mapped_column
from typing import Any, Optional
class TrackTable(Base):
__tablename__ = "track"
id: Mapped[int] = mapped_column(init=False, primary_key=True)
album: Mapped[str] = mapped_column(String())
albumartists: Mapped[str] = mapped_column(String())
albumhash: Mapped[str] = mapped_column(String(), index=True)
artists: Mapped[str] = mapped_column(String())
bitrate: Mapped[int] = mapped_column(Integer())
copyright: Mapped[Optional[str]] = mapped_column(String())
date: Mapped[int] = mapped_column(Integer(), nullable=True)
disc: Mapped[int] = mapped_column(Integer())
duration: Mapped[int] = mapped_column(Integer())
filepath: Mapped[str] = mapped_column(String(), index=True, unique=True)
folder: Mapped[str] = mapped_column(String(), index=True)
genres: Mapped[Optional[str]] = mapped_column(String())
last_mod: Mapped[float] = mapped_column(Integer())
title: Mapped[str] = mapped_column(String())
track: Mapped[int] = mapped_column(Integer())
trackhash: Mapped[str] = mapped_column(String(), index=True)
lastplayed: Mapped[int] = mapped_column(Integer(), default=0)
playcount: Mapped[int] = mapped_column(Integer(), default=0)
playduration: Mapped[int] = mapped_column(Integer(), default=0)
extra: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSON(), default_factory=dict
)
@classmethod
def get_all(cls):
with DbEngine.manager() as conn:
config = UserConfig()
result = conn.execute(select(cls).execution_options(yield_per=100))
for i in result.scalars():
d = i.__dict__
del d["_sa_instance_state"]
yield track_to_dataclass(d, config)
@classmethod
def get_tracks_by_filepaths(cls, filepaths: list[str]):
with DbEngine.manager() as conn:
result = conn.execute(
select(TrackTable)
.where(TrackTable.filepath.in_(filepaths))
.order_by(TrackTable.last_mod)
)
return tracks_to_dataclasses(result.fetchall())
@classmethod
def get_tracks_in_path(cls, path: str):
with DbEngine.manager() as conn:
result = conn.execute(
select(TrackTable)
.where(TrackTable.filepath.contains(path))
.order_by(TrackTable.last_mod)
)
clean = []
for row in result.fetchall():
d = row[0].__dict__
del d["_sa_instance_state"]
clean.append(d)
return tracks_to_dataclasses(clean)
@classmethod
def remove_tracks_by_filepaths(cls, filepaths: set[str]):
with DbEngine.manager(commit=True) as conn:
conn.execute(delete(TrackTable).where(TrackTable.filepath.in_(filepaths)))
-35
View File
@@ -1,35 +0,0 @@
from swingmusic.db import Base
from sqlalchemy import Integer, insert, select, update
from sqlalchemy.orm import Mapped, mapped_column
from swingmusic.db.engine import DbEngine
class MigrationTable(Base):
__tablename__ = "dbmigration"
id: Mapped[int] = mapped_column(primary_key=True)
version: Mapped[int] = mapped_column(Integer())
@classmethod
def set_version(cls, version: int):
with DbEngine.manager(commit=True) as conn:
result = conn.execute(
update(cls).where(cls.id == 1).values(version=version)
)
if result.rowcount == 0:
conn.execute(insert(cls).values(id=1, version=version))
@classmethod
def get_version(cls):
with DbEngine.manager() as conn:
result = conn.execute(select(cls.version).where(cls.id == 1))
result = result.fetchone()
if result:
return result[0]
return -1
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
"""
This module contains the functions to interact with the SQLite database.
"""
-31
View File
@@ -1,31 +0,0 @@
"""
Reads and saves the latest database migrations version.
"""
from swingmusic.db.sqlite.utils import SQLiteManager
class MigrationManager:
@staticmethod
def get_index() -> int:
"""
Returns the latest databases migrations index.
"""
sql = "SELECT * FROM dbmigrations"
with SQLiteManager() as cur:
cur.execute(sql)
ver = int(cur.fetchone()[1])
cur.close()
return ver
# 👇 Setters 👇
@staticmethod
def set_index(version: int):
"""
Updates the databases migrations index.
"""
sql = "UPDATE dbmigrations SET version = ? WHERE id = 1"
with SQLiteManager() as cur:
cur.execute(sql, (version,))
cur.close()
-122
View File
@@ -1,122 +0,0 @@
"""
Helper functions for use with the SQLite database.
"""
import sqlite3
from sqlite3 import Connection, Cursor
import time
from typing import Optional
from swingmusic.models import Album, Playlist, Track
from swingmusic import settings
def tuple_to_track(track: tuple):
"""
Takes a tuple and returns a Track object
"""
return Track(*track[1:]) # rowid is removed from the tuple
def tuples_to_tracks(tracks: list[tuple]):
"""
Takes a list of tuples and returns a generator that yields a Track object for each tuple
"""
for track in tracks:
yield tuple_to_track(track)
def tuple_to_album(album: tuple):
"""
Takes a tuple and returns an Album object
"""
return Album(*album[1:]) # rowid is removed from the tuple
def tuples_to_albums(albums: list[tuple]):
"""
Takes a list of tuples and returns a generator that yields an album object for each tuple
"""
for album in albums:
yield tuple_to_album(album)
def tuple_to_playlist(playlist: tuple):
"""
Takes a tuple and returns a Playlist object
"""
return Playlist(*playlist)
def tuples_to_playlists(playlists: list[tuple]):
"""
Takes a list of tuples and returns a list of Playlist objects
"""
for playlist in playlists:
yield tuple_to_playlist(playlist)
class SQLiteManager:
"""
This is a context manager that handles the connection and cursor
for you. It also commits and closes the connection when you're done.
"""
def __init__(
self,
conn: Optional[Connection] = None,
userdata_db=False,
test_db_path: str = None,
) -> None:
"""
When a connection is passed in, don't close the connection, because it's
a connection to the search database [in memory db].
"""
self.conn = conn
self.CLOSE_CONN = True
self.userdata_db = userdata_db
self.test_db_path = test_db_path
if conn:
self.conn = conn
self.CLOSE_CONN = False
def __enter__(self) -> Cursor:
if self.conn is not None:
cur = self.conn.cursor()
cur.execute("PRAGMA foreign_keys = ON")
return cur
if self.test_db_path:
db_path = self.test_db_path
else:
db_path = settings.Paths().app_db_path
if self.userdata_db:
db_path = settings.Paths().userdata_db_path
self.conn = sqlite3.connect(
db_path,
timeout=15,
)
cur = self.conn.cursor()
cur.execute("PRAGMA foreign_keys = ON")
return cur
def __exit__(self, exc_type, exc_value, exc_traceback):
trial_count = 0
while trial_count < 10:
try:
self.conn.commit()
if self.CLOSE_CONN:
self.conn.close()
return
except sqlite3.OperationalError:
trial_count += 1
time.sleep(3)
self.conn.close()
-748
View File
@@ -1,748 +0,0 @@
from dataclasses import asdict
import datetime
import json
from typing import Any, Iterable, Literal
from sqlalchemy import (
JSON,
Boolean,
ForeignKey,
Integer,
String,
and_,
delete,
func,
insert,
select,
update,
)
from sqlalchemy.orm import Mapped, mapped_column
from swingmusic.db.engine import DbEngine
from swingmusic.db.utils import (
favorite_to_dataclass,
favorites_to_dataclass,
playlist_to_dataclass,
plugin_to_dataclass,
similar_artist_to_dataclass,
tracklog_to_dataclass,
user_to_dataclass,
)
from swingmusic.db import Base
from swingmusic.models.mix import Mix
from swingmusic.utils.auth import get_current_userid, hash_password
class UserTable(Base):
__tablename__ = "user"
id: Mapped[int] = mapped_column(primary_key=True)
image: Mapped[str] = mapped_column(String(), nullable=True)
password: Mapped[str] = mapped_column(String())
username: Mapped[str] = mapped_column(String(), index=True)
roles: Mapped[list[str]] = mapped_column(JSON(), default_factory=lambda: [])
extra: Mapped[dict[str, Any]] = mapped_column(
JSON(), nullable=True, default_factory=dict
)
@classmethod
def get_all(cls):
result = cls.execute(select(cls))
for i in next(result).scalars():
yield user_to_dataclass(i)
@classmethod
def insert_default_user(cls):
user = {
"username": "admin",
"password": hash_password("admin"),
"roles": ["admin"],
}
return cls.insert_one(user)
@classmethod
def insert_guest_user(cls):
user = {
"username": "guest",
"password": hash_password("guest"),
"roles": ["guest"],
}
return cls.insert_one(user)
@classmethod
def get_by_id(cls, id: int):
result = cls.execute(select(cls).where(cls.id == id))
res = next(result).scalar()
if res:
return user_to_dataclass(res)
@classmethod
def get_by_username(cls, username: str):
res = cls.execute(select(cls).where(cls.username == username))
res = next(res).scalar()
if res:
return user_to_dataclass(res)
@classmethod
def update_one(cls, user: dict[str, Any]):
return next(
cls.execute(
update(cls).where(cls.id == user["id"]).values(user), commit=True
)
)
@classmethod
def remove_by_username(cls, username: str):
return next(
cls.execute(delete(cls).where(cls.username == username), commit=True)
)
class PluginTable(Base):
__tablename__ = "plugin"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(), unique=True)
active: Mapped[bool] = mapped_column(Boolean())
settings: Mapped[dict[str, Any]] = mapped_column(JSON())
extra: Mapped[dict[str, Any]] = mapped_column(JSON(), nullable=True)
@classmethod
def get_all(cls):
result = cls.execute(select(cls))
for i in next(result).scalars():
yield plugin_to_dataclass(i)
@classmethod
def activate(cls, name: str, value: bool):
return next(
cls.execute(
update(cls).where(cls.name == name).values(active=value), commit=True
)
)
@classmethod
def get_by_name(cls, name: str):
result = cls.execute(select(cls).where(cls.name == name))
res = next(result).scalar()
if res:
return plugin_to_dataclass(res)
@classmethod
def update_settings(cls, name: str, settings: dict[str, Any]):
return next(
cls.execute(
update(cls).where(cls.name == name).values(settings=settings),
commit=True,
)
)
class SimilarArtistTable(Base):
__tablename__ = "notlastfm_similar_artists"
id: Mapped[int] = mapped_column(Integer(), primary_key=True)
artisthash: Mapped[str] = mapped_column(String(), index=True)
similar_artists: Mapped[dict[str, str]] = mapped_column(JSON())
@classmethod
def get_all(cls):
result = cls.execute(select(cls).execution_options(yield_per=100))
for i in next(result).scalars():
yield similar_artist_to_dataclass(i)
@classmethod
def exists(cls, artisthash: str):
"""
Check whether an artisthash exists in the database.
"""
with DbEngine.manager() as conn:
result = conn.execute(
select(cls.artisthash)
.where(cls.artisthash == artisthash)
.execution_options(yield_per=100)
)
return len(result.scalars().all()) > 0
@classmethod
def get_by_hash(cls, artisthash: str):
"""
Get a single artist by hash.
"""
result = cls.execute(select(cls).where(cls.artisthash == artisthash))
res = next(result).scalar()
if res:
return similar_artist_to_dataclass(res)
class FavoritesTable(Base):
__tablename__ = "favorite"
id: Mapped[int] = mapped_column(primary_key=True)
hash: Mapped[str] = mapped_column(String(), unique=True)
type: Mapped[str] = mapped_column(String(), index=True)
timestamp: Mapped[int] = mapped_column(Integer(), index=True)
userid: Mapped[int] = mapped_column(
Integer(), ForeignKey("user.id", ondelete="cascade"), default=1, index=True
)
extra: Mapped[dict[str, Any]] = mapped_column(
JSON(), nullable=True, default_factory=dict
)
@classmethod
def get_all(cls, with_user: bool = False):
with DbEngine.manager() as conn:
if with_user:
result = conn.execute(
select(cls).where(cls.userid == get_current_userid())
)
else:
result = conn.execute(select(cls))
for i in result.scalars():
yield favorite_to_dataclass(i)
@classmethod
def insert_item(cls, item: dict[str, Any]):
# guard against hash collisions for different item types
item["hash"] = f"{item['type']}_{item['hash']}"
if item.get("timestamp") is None:
item["timestamp"] = int(datetime.datetime.now().timestamp())
if item.get("userid") is None:
item["userid"] = get_current_userid()
return next(cls.execute(insert(cls).values(item), commit=True))
@classmethod
def remove_item(cls, item: dict[str, Any]):
return next(
cls.execute(
delete(cls).where(
(cls.hash == item["hash"])
| (cls.hash == f"{item['type']}_{item['hash']}")
),
commit=True,
)
)
@classmethod
def check_exists(cls, hash: str, type: str):
result = cls.execute(
select(cls).where((cls.hash == hash) | (cls.hash == f"{type}_{hash}"))
)
return next(result).scalar() is not None
@classmethod
def get_by_hash(cls, hash: str, type: str):
result = cls.execute(
select(cls).where((cls.hash == hash) | (cls.hash == f"{type}_{hash}"))
)
return next(result).scalars().all()
@classmethod
def get_all_of_type(cls, type: str, start: int, limit: int):
result = cls.execute(
select(cls)
# .select_from(join(table, cls, field == cls.hash))
.where(and_(cls.type == type, cls.userid == get_current_userid()))
.order_by(cls.timestamp.desc())
.offset(start)
# INFO: If start is 0, fetch all so we can get the total count
.limit(limit if start != 0 else None)
)
res = next(result).scalars().all()
if start == 0:
# if limit == -1, return all
if limit == -1:
limit = len(res)
return res[:limit], len(res)
return res, -1
@classmethod
def get_fav_tracks(cls, start: int, limit: int):
result, total = cls.get_all_of_type("track", start, limit)
return favorites_to_dataclass(result), total
@classmethod
def get_fav_albums(cls, start: int, limit: int):
result, total = cls.get_all_of_type("album", start, limit)
return favorites_to_dataclass(result), total
@classmethod
def get_fav_artists(cls, start: int, limit: int):
result, total = cls.get_all_of_type("artist", start, limit)
return favorites_to_dataclass(result), total
@classmethod
def count_favs_in_period(cls, start_time: int, end_time: int):
result = cls.execute(
select(func.count(cls.id))
.where((cls.userid == get_current_userid()))
.where(and_(cls.timestamp >= start_time, cls.timestamp <= end_time))
)
res = next(result).scalar()
if res:
return res
return 0
@classmethod
def count_tracks(cls):
result = cls.execute(select(func.count(cls.id)).where(cls.type == "track"))
return next(result).scalar()
@classmethod
def get_last_trackhash(cls):
result = cls.execute(
select(cls.hash).where(cls.type == "track").order_by(cls.timestamp.desc())
)
return next(result).scalar()
class ScrobbleTable(Base):
__tablename__ = "scrobble"
id: Mapped[int] = mapped_column(primary_key=True)
trackhash: Mapped[str] = mapped_column(String(), index=True)
duration: Mapped[int] = mapped_column(Integer())
timestamp: Mapped[int] = mapped_column(Integer())
source: Mapped[str] = mapped_column(String())
userid: Mapped[int] = mapped_column(
Integer(), ForeignKey("user.id", ondelete="cascade"), index=True
)
extra: Mapped[dict[str, Any]] = mapped_column(
JSON(), nullable=True, default_factory=dict
)
@classmethod
def add(cls, item: dict[str, Any]):
if item.get("userid") is None:
item["userid"] = get_current_userid()
return cls.insert_one(item)
@classmethod
def get_all(cls, start: int, limit: int | None = None, userid: int | None = None):
result = cls.execute(
select(cls)
.where(cls.userid == (userid if userid else get_current_userid()))
.order_by(cls.timestamp.desc())
.offset(start)
.limit(limit)
.execution_options(yield_per=100)
)
for i in next(result).scalars():
yield tracklog_to_dataclass(i)
@classmethod
def get_all_in_period(cls, start_time: int, end_time: int, userid: int | None):
# UserId will be None if function is called from the API
# In that case, we use the request userid
if userid is None:
userid = get_current_userid()
result = cls.execute(
select(cls)
.where(cls.userid == userid)
.where(and_(cls.timestamp >= start_time, cls.timestamp <= end_time))
.order_by(cls.timestamp.desc())
.execution_options(yield_per=100)
)
for i in next(result).scalars():
yield tracklog_to_dataclass(i)
@classmethod
def get_last_entry(cls, userid: int):
result = cls.execute(
select(cls).where(cls.userid == userid).order_by(cls.timestamp.desc())
)
res = next(result).scalar()
if res:
return tracklog_to_dataclass(res)
class PlaylistTable(Base):
__tablename__ = "playlist"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(), index=True)
last_updated: Mapped[int] = mapped_column(Integer())
image: Mapped[str] = mapped_column(String(), nullable=True)
userid: Mapped[int] = mapped_column(
Integer(), ForeignKey("user.id", ondelete="cascade")
)
settings: Mapped[dict[str, Any]] = mapped_column(JSON())
trackhashes: Mapped[list[str]] = mapped_column(JSON(), default_factory=list)
extra: Mapped[dict[str, Any]] = mapped_column(
JSON(), nullable=True, default_factory=dict
)
@classmethod
def get_all(cls, current_user: bool = True):
if current_user:
result = cls.execute(
select(cls)
.where(cls.userid == get_current_userid())
.execution_options(yield_per=100)
)
else:
result = cls.execute(select(cls).execution_options(yield_per=100))
for i in next(result).scalars():
yield playlist_to_dataclass(i)
@classmethod
def add_one(cls, playlist: dict[str, Any]):
playlist["userid"] = get_current_userid()
result = cls.insert_one(playlist)
return result.lastrowid
@classmethod
def check_exists_by_name(cls, name: str):
result = cls.execute(
select(cls).where((cls.name == name) & (cls.userid == get_current_userid()))
)
return next(result).scalar() is not None
@classmethod
def append_to_playlist(cls, id: int, trackhashes: list[str]):
dbtrackhashes = cls.get_trackhashes(id) or []
trackhashes = list(set(dbtrackhashes).union(set(trackhashes)))
return next(
cls.execute(
update(cls)
.where((cls.id == id) & (cls.userid == get_current_userid()))
.values(trackhashes=trackhashes),
commit=True,
)
)
@classmethod
def get_trackhashes(cls, id: int):
result = cls.execute(
select(cls.trackhashes).where(
(cls.id == id) & (cls.userid == get_current_userid())
)
)
return next(result).scalar()
@classmethod
def remove_from_playlist(cls, id: int, trackhashes: list[dict[str, Any]]):
# INFO: Get db trackhashes
dbtrackhashes = cls.get_trackhashes(id)
if dbtrackhashes:
for item in trackhashes:
if dbtrackhashes.index(item["trackhash"]) == item["index"]:
dbtrackhashes.remove(item["trackhash"])
return next(
cls.execute(
update(cls)
.where((cls.id == id) & (cls.userid == get_current_userid()))
.values(trackhashes=dbtrackhashes),
commit=True,
)
)
@classmethod
def get_by_id(cls, id: int):
result = cls.execute(
select(cls).where((cls.id == id) & (cls.userid == get_current_userid()))
)
result = next(result).scalar()
if result:
return playlist_to_dataclass(result)
@classmethod
def update_one(cls, id: int, playlist: dict[str, Any]):
return next(
cls.execute(
update(cls)
.where((cls.id == id) & (cls.userid == get_current_userid()))
.values(playlist),
commit=True,
)
)
@classmethod
def update_settings(cls, id: int, settings: dict[str, Any]):
return next(
cls.execute(
update(cls)
.where((cls.id == id) & (cls.userid == get_current_userid()))
.values(settings=settings),
commit=True,
)
)
@classmethod
def remove_image(cls, id: int):
return next(
cls.execute(
update(cls)
.where((cls.id == id) & (cls.userid == get_current_userid()))
.values(image=None),
commit=True,
)
)
class LibDataTable(Base):
__tablename__ = "artistdata"
id: Mapped[int] = mapped_column(primary_key=True)
itemhash: Mapped[str] = mapped_column(String(), unique=True, index=True)
itemtype: Mapped[str] = mapped_column(String())
color: Mapped[str] = mapped_column(String(), nullable=True)
bio: Mapped[str] = mapped_column(String(), nullable=True)
info: Mapped[dict[str, Any]] = mapped_column(JSON(), nullable=True)
extra: Mapped[dict[str, Any]] = mapped_column(
JSON(), nullable=True, default_factory=dict
)
@classmethod
def update_one(cls, hash: str, data: dict[str, Any]):
return next(
cls.execute(
update(cls).where(cls.itemhash == hash).values(data), commit=True
)
)
@classmethod
def find_one(cls, hash: str, type: Literal["album", "artist"]):
result = cls.execute(
select(cls).where((cls.itemhash == type + hash) & (cls.itemtype == type))
)
return next(result).scalar()
@classmethod
def get_all_colors(cls, type: str) -> Iterable[dict[str, str]]:
result = cls.execute(select(cls).where(cls.itemtype == type))
for i in next(result).scalars():
yield {"itemhash": i.itemhash.replace(type, ""), "color": i.color}
class MixTable(Base):
__tablename__ = "mix"
id: Mapped[int] = mapped_column(primary_key=True)
mixid: Mapped[str] = mapped_column(String(), index=True)
title: Mapped[str] = mapped_column(String())
description: Mapped[str] = mapped_column(String())
timestamp: Mapped[int] = mapped_column(Integer())
sourcehash: Mapped[str] = mapped_column(String(), unique=True, index=True)
userid: Mapped[int] = mapped_column(
Integer(), ForeignKey("user.id", ondelete="cascade"), index=True
)
saved: Mapped[bool] = mapped_column(Boolean(), default=False)
tracks: Mapped[list[str]] = mapped_column(JSON(), default_factory=list)
extra: Mapped[dict[str, Any]] = mapped_column(
JSON(), nullable=True, default_factory=dict
)
@classmethod
def get_all(cls, with_userid: bool = False):
if with_userid:
result = cls.execute(
select(cls)
.where(cls.userid == get_current_userid())
.order_by(cls.timestamp.desc())
)
else:
result = cls.execute(select(cls).order_by(cls.timestamp.desc()))
for i in next(result).scalars():
yield Mix.mix_to_dataclass(i)
@classmethod
def get_by_sourcehash(cls, sourcehash: str):
result = cls.execute(select(cls).where(cls.sourcehash == sourcehash))
res = next(result).scalar()
if res:
return Mix.mix_to_dataclass(res)
@classmethod
def get_by_mixid(cls, mixid: str):
result = cls.execute(select(cls).where(cls.mixid == mixid))
res = next(result).scalar()
if res:
return Mix.mix_to_dataclass(res)
@classmethod
def insert_one(cls, mix: Mix):
mixdict = asdict(mix)
mixdict["mixid"] = mix.id
del mixdict["id"]
return next(cls.execute(insert(cls).values(mixdict), commit=True))
@classmethod
def update_one(cls, mixid: str, mix: Mix):
mixdict = asdict(mix)
mixdict["mixid"] = mix.id
del mixdict["id"]
return next(
cls.execute(
update(cls)
.where(
and_(
cls.mixid == mixid,
cls.sourcehash == mix.sourcehash,
cls.userid == get_current_userid(),
)
)
.values(mixdict),
commit=True,
)
)
@classmethod
def save_artist_mix(cls, sourcehash: str):
"""
Toggles the saved status of an artist mix.
"""
mix = cls.get_by_sourcehash(sourcehash)
if not mix:
return False
mix.saved = not mix.saved
cls.update_one(mix.id, mix)
return mix.saved
@classmethod
def get_saved_track_mixes(cls):
"""
Return all mixes that have the extra.trackmix_saved set to True.
"""
result = cls.execute(select(cls).where(cls.extra.c.trackmix_saved == True))
# return Mix.mixes_to_dataclasses(result.fetchall())
for i in next(result).scalars():
yield Mix.mix_to_dataclass(i)
@classmethod
def save_track_mix(cls, sourcehash: str):
"""
Toggles the property extra.trackmix_saved to True.
"""
mix = cls.get_by_sourcehash(sourcehash)
if not mix:
return False
mix.extra["trackmix_saved"] = not mix.extra.get("trackmix_saved", False)
cls.update_one(mix.id, mix)
return mix.extra["trackmix_saved"]
class CollectionTable(Base):
# INFO: table name was kept as page to avoid breaking existing data
__tablename__ = "page"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(), index=True)
userid: Mapped[int] = mapped_column(
Integer(), ForeignKey("user.id", ondelete="cascade"), index=True
)
items: Mapped[list[dict[str, Any]]] = mapped_column(JSON(), default_factory=list)
extra: Mapped[dict[str, Any]] = mapped_column(
JSON(), nullable=True, default_factory=dict
)
@classmethod
def to_dict(cls, entry: Any) -> dict[str, Any]:
d = entry.__dict__
del d["_sa_instance_state"]
return d
@classmethod
def get_all(cls):
result = cls.execute(select(cls).where(cls.userid == get_current_userid()))
for i in next(result).scalars():
yield cls.to_dict(i)
@classmethod
def get_by_id(cls, id: int):
result = cls.execute(
select(cls).where(and_(cls.id == id, cls.userid == get_current_userid()))
)
res = next(result).scalar()
if res:
return cls.to_dict(res)
@classmethod
def delete_by_id(cls, id: int):
return next(
cls.execute(
delete(cls).where(
and_(cls.id == id, cls.userid == get_current_userid())
),
commit=True,
)
)
@classmethod
def update_items(cls, id: int, items: list[dict[str, Any]]):
return next(
cls.execute(
update(cls)
.where(and_(cls.id == id, cls.userid == get_current_userid()))
.values(items=items),
commit=True,
)
)
@classmethod
def update_one(cls, payload: dict[str, Any]):
return next(
cls.execute(
update(cls)
.where(
and_(cls.id == payload["id"], cls.userid == get_current_userid())
)
.values(payload),
commit=True,
)
)
-98
View File
@@ -1,98 +0,0 @@
from typing import Any
from swingmusic.config import UserConfig
from swingmusic.models import Album as AlbumModel, Artist as ArtistModel, Track as TrackModel
from swingmusic.models.favorite import Favorite
from swingmusic.models.lastfm import SimilarArtist
from swingmusic.models.logger import TrackLog
from swingmusic.models.playlist import Playlist
from swingmusic.models.plugins import Plugin
from swingmusic.models.user import User
def row_to_dict(row: Any):
d = row.__dict__
del d["_sa_instance_state"]
return d
def track_to_dataclass(track: dict, config: UserConfig):
return TrackModel(**track, config=config)
def tracks_to_dataclasses(tracks: Any):
return [track_to_dataclass(track, UserConfig()) for track in tracks]
def album_to_dataclass(album: Any):
return AlbumModel(**album._asdict())
def albums_to_dataclasses(albums: Any):
return [album_to_dataclass(album) for album in albums]
def artist_to_dataclass(artist: Any):
return ArtistModel(**artist._asdict())
def artists_to_dataclasses(artists: Any):
return [artist_to_dataclass(artist) for artist in artists]
# SECTION: User data helpers
def similar_artist_to_dataclass(entry: Any):
entry_dict = row_to_dict(entry)
del entry_dict["id"]
return SimilarArtist(**entry_dict)
def similar_artists_to_dataclass(entries: Any):
return [similar_artist_to_dataclass(entry) for entry in entries]
def favorite_to_dataclass(entry: Any):
entry_dict = row_to_dict(entry)
del entry_dict["id"]
return Favorite(**entry_dict)
def favorites_to_dataclass(entries: Any):
return [favorite_to_dataclass(entry) for entry in entries]
def user_to_dataclass(entry: Any):
return User(**row_to_dict(entry))
# def user_to_dataclasses(entries: Any):
# return [user_to_dataclass(entry) for entry in entries]
def plugin_to_dataclass(entry: Any):
entry_dict = row_to_dict(entry)
del entry_dict["id"]
return Plugin(**entry_dict)
def plugin_to_dataclasses(entries: Any):
return [plugin_to_dataclass(entry) for entry in entries]
def tracklog_to_dataclass(entry: Any):
return TrackLog(**row_to_dict(entry))
def tracklog_to_dataclasses(entries: Any):
return [tracklog_to_dataclass(entry) for entry in entries]
def playlist_to_dataclass(entry: Any):
entry_dict = row_to_dict(entry)
return Playlist(**entry_dict)
def playlists_to_dataclasses(entries: Any):
return [playlist_to_dataclass(entry) for entry in entries]
-67
View File
@@ -1,67 +0,0 @@
from enum import Enum
class AlbumVersionEnum(Enum):
"""
Enum that registers supported album versions.
"""
Explicit = ("explicit",)
_360_AUDIO = ("360 audio",)
ANNIVERSARY_EDITION = ("anniversary",)
DIAMOND_EDITION = ("diamond",)
Centennial_EDITION = ("centennial",)
GOLDEN_EDITION = ("gold",)
PLATINUM_EDITION = ("platinum",)
SILVER_EDITION = ("silver",)
ULTIMATE_EDITION = ("ultimate",)
EXPANDED = ("expanded",)
EXTENDED = ("extended",)
DELUXE = ("deluxe",)
SUPER_DELUXE = ("super deluxe",)
COMPLETE = ("complete",)
LEGACY_EDITION = ("legacy",)
SPECIAL_EDITION = ("special",)
COLLECTORS_EDITION = ("collector",)
ARCHIVE_EDITION = ("archive",)
Acoustic = ("acoustic",)
instrumental = ("instrumental",)
DOUBLE_DISC = ("double disc", "double disk")
Unplugged = ("unplugged",)
SUMMER_EDITION = ("summer",)
WINTER_EDITION = ("winter",)
SPRING_EDITION = ("spring",)
FALL_EDITION = ("fall",)
BONUS_EDITION = ("bonus",)
BONUS_TRACK = ("bonus track",)
ORIGINAL = ("original", " og ", "og ")
INTL_VERSION = ("international",)
UK_VERSION = ("uk version",)
US_VERSION = ("us version",)
PARENTAL_ADVISORY = ("PA version",)
Limited_EDITION = ("limited",)
MONO = ("mono",)
STEREO = ("stereo",)
HI_RES = ("Hi-Res",)
RE_MIX = ("re-mix",)
RE_RECORDED = ("re-recorded", "rerecorded")
REISSUE = ("reissue",)
REMASTERED = ("remaster",)
def get_all_keywords():
"""
Returns a joint string of all album versions.
"""
return "|".join("|".join(i.value) for i in AlbumVersionEnum)
-91
View File
@@ -1,91 +0,0 @@
import os
import json
from typing import Any
from dataclasses import asdict, dataclass
@dataclass
class Jsoni:
_configpath: str = ""
_init_complete: bool = False
# @property
# def _configpath(self):
# """
# The path to the config file
# """
# return None
@property
def _config_as_dict(self):
all_keys = asdict(self)
# print("all_keys: ", all_keys)
# remove internal attributes (starting with __)
return {k: v for k, v in all_keys.items() if not k.startswith("_")}
def create_file(self):
# if not exists, create the config file
if not os.path.exists(self._configpath):
print("creating file")
self.write_to_file(self._config_as_dict)
def write_to_file(self, settings: dict[str, Any]):
print("writing to file")
print("settings: ", settings)
with open(self._configpath, "w") as f:
json.dump(settings, f, indent=4)
def __setattr__(self, name: str, value: Any):
if not self._init_complete:
print("setting local attr", "name: ", name, ", value: ", value)
super().__setattr__(name, value)
return
# if is internal attribute, set to instance
# but don't write to file
super().__setattr__(name, value)
if name.startswith("_"):
print("setting local internal attr", "name: ", name, ", value: ", value)
return
print("writing attr", "name: ", name, ", value: ", value)
self.write_to_file(self._config_as_dict)
def load_config(self):
with open(self._configpath, "r") as f:
settings: dict[str, Any] = json.load(f)
for key, value in settings.items():
setattr(self, key, value)
def __post_init__(self):
if not self._configpath:
raise AttributeError(
f"{self.__class__.__name__}: self._configpath is not set"
)
print("self: ", self)
self.create_file()
self.load_config()
self._init_complete = True
print("init complete!!!!")
@dataclass
class MyConfig(Jsoni):
age: int = 30
name: str = "John"
# _configpath: str = "notconfig.json"
@property
def _configpath(self):
return "notconfig.json"
config = MyConfig("notconfig.json")
print("config.name: ", config.name)
config.age = 45
print("config.name: ", config.name)
# config.create_file()
# config.name = "Jane"
-3
View File
@@ -1,3 +0,0 @@
"""
This module contains all the data processing and non-API libraries
"""
-30
View File
@@ -1,30 +0,0 @@
"""
Contains methods relating to albums.
"""
from swingmusic.models.track import Track
def remove_duplicate_on_merge_versions(tracks: list[Track]):
"""
Removes duplicate tracks when merging versions of the same album.
"""
# TODO!
pass
def sort_by_track_no(tracks: list[Track]) -> list[Track]:
"""
Sort tracks by track number.
Track numbers cannot be longer than three positions.
:param tracks: List of Tracks
:return: Sorted list of Tracks
"""
for t in tracks:
track = str(t.track).zfill(3)
t._pos = int(f"{t.disc}{track}")
tracks = sorted(tracks, key=lambda t: t._pos)
return tracks
-187
View File
@@ -1,187 +0,0 @@
import os
import time
import random
import urllib
import requests
from io import BytesIO
from pathlib import Path
from concurrent.futures import ProcessPoolExecutor
from PIL import Image, PngImagePlugin, UnidentifiedImageError
from requests.exceptions import ConnectionError as RequestConnectionError
from requests.exceptions import ReadTimeout
from swingmusic import settings
from swingmusic.models.artist import Artist
from swingmusic.store.artists import ArtistStore
# from swingmusic.db.libdata import ArtistTable
# from swingmusic.store import artists as artist_store
# from swingmusic.store.tracks import TrackStore
from swingmusic.utils.hashing import create_hash
from swingmusic.utils.progressbar import tqdm
LARGE_ENOUGH_NUMBER = 100
PngImagePlugin.MAX_TEXT_CHUNK = LARGE_ENOUGH_NUMBER * (1024**2)
# https://stackoverflow.com/a/61466412
def get_artist_image_link(artist: str):
"""
Returns an artist image url.
"""
response: requests.Response | None = None
def make_request():
query = urllib.parse.quote(artist) # type: ignore
url = f"https://api.deezer.com/search/artist?q={query}"
user_agents = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Safari/605.1.15",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0",
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1",
]
headers = {
"User-Agent": random.choice(user_agents),
"Accept": "application/json, text/plain, */*",
"Accept-Language": "en-US,en;q=0.9",
"Referer": "https://www.deezer.com/",
"Origin": "https://www.deezer.com",
}
return requests.get(url, headers=headers, timeout=30)
for attempt in range(5):
try:
response = make_request()
try:
data = response.json()
except requests.exceptions.JSONDecodeError:
return None
for res in data["data"]:
res_hash = create_hash(res["name"], decode=True)
artist_hash = create_hash(artist, decode=True)
if res_hash == artist_hash:
return str(res["picture_big"])
return None
except (RequestConnectionError, ReadTimeout, IndexError, KeyError):
if attempt == 4:
print("Failed to get artist image link ")
if attempt <= 4:
time.sleep(10)
else:
return None
# except (IndexError, KeyError):
# print(f"Encountered index/key error in attempt {attempt}")
# if response is not None:
# print(response.headers)
# return None
# TODO: Move network calls to utils/network.py
class DownloadImage:
def __init__(self, url: str, name: str) -> None:
img = self.download(url)
if img is None:
return
sm_path = settings.Paths().sm_artist_img_path / name
lg_path = settings.Paths().lg_artist_img_path / name
md_path = settings.Paths().md_artist_img_path / name
entries = [
(lg_path, None), # save in the original size
(sm_path, settings.Defaults.SM_ARTIST_IMG_SIZE),
(md_path, settings.Defaults.MD_ARTIST_IMG_SIZE),
]
self.save_img(img, entries)
@staticmethod
def download(url: str) -> Image.Image | None:
"""
Downloads the image from the url.
Retries after 10 seconds on a connection error.
"""
for attempt in range(2):
try:
response = requests.get(url, timeout=10)
return Image.open(BytesIO(response.content))
except (RequestConnectionError, requests.Timeout, ReadTimeout):
if attempt == 0:
time.sleep(10)
else:
return None
except UnidentifiedImageError:
return None
@staticmethod
def save_img(img: Image.Image, entries: list[tuple[Path, int | None]]):
"""
Saves the image to the destinations.
"""
ratio = img.width / img.height
for entry in entries:
path, size = entry
if size is None:
img.save(path, format="webp")
continue
img.resize((size, int(size / ratio)), Image.Resampling.LANCZOS).save(
path, format="webp"
)
class CheckArtistImages:
def __init__(self):
# read all files in the artist image folder
storeArtists = ArtistStore.get_flat_list()
path = settings.Paths().sm_artist_img_path
processed = set(i.replace(".webp", "") for i in os.listdir(path))
unprocessed = (
artist for artist in storeArtists if artist.artisthash not in processed
)
num_workers = max(1, (os.cpu_count() or 1) // 2)
with ProcessPoolExecutor(max_workers=num_workers) as executor:
res = list(
tqdm(
executor.map(self.download_image, unprocessed),
total=len(storeArtists) - len(processed),
desc="Downloading missing artist images",
)
)
list(res)
@staticmethod
def download_image(artist: Artist):
"""
Checks if an artist image exists and downloads it if not.
:param artist: The artist name
"""
img_path = (
settings.Paths().sm_artist_img_path / f"{artist.artisthash}.webp"
)
if img_path.exists():
return
url = get_artist_image_link(artist.name)
if url is not None:
return DownloadImage(url, name=f"{artist.artisthash}.webp")
-286
View File
@@ -1,286 +0,0 @@
"""
Contains everything that deals with image colour extraction.
"""
import logging
import os
import pathlib
import colorgram
from pathlib import Path
from typing import Generator
from swingmusic.utils.progressbar import tqdm
from concurrent.futures import ProcessPoolExecutor, as_completed
from swingmusic import settings
from swingmusic.store.albums import AlbumStore
from swingmusic.db.userdata import LibDataTable
from swingmusic.store.artists import ArtistStore
log = logging.getLogger(__name__)
def get_image_colors(image: pathlib.Path, count=1) -> list[str]:
"""
Extracts ``count`` numbers of the most dominant colours from an image.
:params image: Path to image.
:params count: How many colours should be extracted?
:returns: List["rgb(red, green, blue)", ...]
"""
if image.exists():
colors = sorted(colorgram.extract(image, count), key=lambda c: c.hsl.h)
else:
return []
formatted_colors = []
for color in colors:
color = f"rgb({color.rgb.r}, {color.rgb.g}, {color.rgb.b})"
formatted_colors.append(color)
return formatted_colors
def process_color(item_hash: str, is_album=True) -> list[str]:
"""
Parse colours from images associated with song
:param item_hash: hash of item for colour calculation
:param is_album: if item is an album
:return: list with colour strings
"""
if is_album:
path = settings.Paths().sm_thumb_path
else:
path = settings.Paths().sm_artist_img_path
path = path / (item_hash + ".webp")
if not path.exists():
return []
return get_image_colors(path)
def extract_color_worker(item_data: dict) -> dict:
"""
Generic worker function for extracting colours in parallel.
Returns data to main process for batch database operations.
Works for both albums and artists based on item_data configuration.
"""
hash_field: str = item_data["hash_field"]
path_func: Path = item_data["path_func"]
item_hash: str = item_data[hash_field]
path = path_func / (item_hash + ".webp")
if not path.exists():
return {hash_field: item_hash, "color": None, "error": "Image not found"}
colors = get_image_colors(path)
if not colors:
return {
hash_field: item_hash,
"color": None,
"error": "Color extraction failed",
}
return {hash_field: item_hash, "color": colors[0], "error": None}
class ColorProcessor:
"""
Generic color processor for extracting dominant colors from images.
Uses multiprocessing for parallel color extraction and batch database operations.
"""
def __init__(
self,
item_type: str,
store: AlbumStore | ArtistStore,
path_func: Path,
hash_field: str,
):
"""
Initialize the color processor.
Args:
item_type: Type of item ("album" or "artist")
store: Store object (AlbumStore or ArtistStore)
path_func: Function to get the image path
hash_field: Name of the hash field ("albumhash" or "artisthash")
"""
self.item_type = item_type
self.store = store
self.path_func = path_func
self.hash_field = hash_field
# Read existing colors from database to filter out already processed items
existing_colors = set()
for color_data in LibDataTable.get_all_colors(item_type):
if color_data["color"]:
existing_colors.add(color_data["itemhash"])
# Filter items that need color processing
items_needing_colors = self._get_items_needing_colors(existing_colors)
if not items_needing_colors:
return
self._process_colors_parallel(items_needing_colors)
def _get_items_needing_colors(
self, existing_colors: set
) -> Generator[dict, None, None]:
"""
Generator that yields items needing color processing.
"""
for item in self.store.get_flat_list():
# Skip if item already has color in memory store
if item.color:
continue
# Skip if item already has color in database
item_hash = getattr(item, self.hash_field)
if item_hash in existing_colors:
continue
yield {
self.hash_field: item_hash,
"item_type": self.item_type,
"path_func": self.path_func,
"hash_field": self.hash_field,
}
def _process_colors_parallel(self, items: Generator[dict, None, None]) -> None:
"""
Process colors using multiprocessing and batch database operations.
"""
items_list = list(items)
if not items_list:
return
cpus = max(1, (os.cpu_count() or 1) // 2)
batch_size = 20 # Process results in batches
with ProcessPoolExecutor(max_workers=cpus) as executor:
# Submit all jobs
future_to_item = {
executor.submit(extract_color_worker, item): item for item in items_list
}
batch = []
processed_count = 0
# Process results as they complete
progress_bar = tqdm(
as_completed(future_to_item),
total=len(items_list),
desc=f"Processing {self.item_type} colors",
)
for future in progress_bar:
try:
result = future.result()
if result["color"] is not None:
batch.append(result)
# Process batch when it reaches batch_size or we're done
if len(batch) >= batch_size or processed_count + 1 >= len(
items_list
):
if batch:
self._process_batch(batch)
batch = []
processed_count += 1
except Exception as e:
item_data = future_to_item[future]
item_hash = item_data[self.hash_field]
log.error(f"Error processing {self.item_type} {item_hash}: {e}")
def _process_batch(self, batch: list[dict]) -> None:
"""
Process a batch of color results - update database and memory stores.
"""
if not batch:
return
# Prepare database records
db_inserts = []
db_updates = []
for result in batch:
item_hash = result[self.hash_field]
color = result["color"]
# Check if record exists in database
existing_record = LibDataTable.find_one(item_hash, type=self.item_type)
if existing_record is None:
db_inserts.append(
{
"itemhash": self.item_type + item_hash,
"color": color,
"itemtype": self.item_type,
}
)
else:
db_updates.append(
{"itemhash": self.item_type + item_hash, "color": color}
)
# Batch database operations
if db_inserts:
LibDataTable.insert_many(db_inserts)
if db_updates:
for update_data in db_updates:
clean_hash = update_data["itemhash"].replace(self.item_type, "")
LibDataTable.update_one(clean_hash, {"color": update_data["color"]})
# Update in-memory store
store_map = getattr(self.store, f"{self.item_type}map")
for result in batch:
item_hash = result[self.hash_field]
color = result["color"]
item = store_map.get(item_hash)
if item:
item.set_color(color)
class ProcessAlbumColors:
"""
Extracts the most dominant color from the album art and saves it to the database.
Uses multiprocessing for parallel color extraction and batch database operations.
"""
def __init__(self) -> None:
ColorProcessor(
item_type="album",
store=AlbumStore,
path_func=settings.Paths().sm_thumb_path,
hash_field="albumhash",
)
class ProcessArtistColors:
"""
Extracts the most dominant colour from the artist art and saves it to the database.
Uses multiprocessing for parallel colour extraction and batch database operations.
"""
def __init__(self) -> None:
ColorProcessor(
item_type="artist",
store=ArtistStore,
path_func=settings.Paths().sm_artist_img_path,
hash_field="artisthash",
)
View File
-37
View File
@@ -1,37 +0,0 @@
from typing import Any
from swingmusic.store.albums import AlbumStore
from swingmusic.store.artists import ArtistStore
from swingmusic.store.tracks import TrackStore
def get_extra_info(hash: str, type: str):
"""
Generates extra info for a track, album or artist, which will be stored
in the database (in favorites, playlists and scrobble data) for backup and restore.
The extra info contains all the fields needed to reconstruct the itemhash. The track contains an additional filepath field which can be used to locate the file when restoring.
"""
extra: dict[str, Any] = {}
if type == "track":
trackentry = TrackStore.trackhashmap.get(hash)
if trackentry is not None:
track = trackentry.get_best()
extra["filepath"] = track.filepath
extra["title"] = track.title
extra["artists"] = [a["name"] for a in track.artists]
extra["album"] = track.albumhash
elif type == "album":
album = AlbumStore.get_album_by_hash(hash)
if album is not None:
extra["albumartists"] = [a["name"] for a in album.albumartists]
extra["title"] = album.title
elif type == "artist":
artist = ArtistStore.get_artist_by_hash(hash)
if artist is not None:
extra["name"] = artist.name
return extra
-154
View File
@@ -1,154 +0,0 @@
import pathlib
from pathlib import Path
import logging
from swingmusic.lib.sortlib import sort_folders, sort_tracks
from swingmusic.models import Folder
from swingmusic.serializers.track import serialize_tracks
from swingmusic.utils.filesystem import SUPPORTED_FILES
from swingmusic.store.folder import FolderStore
log = logging.getLogger(__name__)
def create_folder(path: str, trackcount=0) -> Folder:
"""
Creates a folder object from a path.
"""
folder = Path(path)
return Folder(
name=folder.name,
path=folder.as_posix() + "/",
is_sym=folder.is_symlink(),
trackcount=trackcount,
)
def get_folders(paths: list[str]):
"""
Filters out folders that don't have any tracks and
returns a list of folder objects.
"""
folders = FolderStore.count_tracks_containing_paths(paths)
return [
create_folder(f["path"], f["trackcount"])
for f in folders
if f["trackcount"] > 0
]
def get_files_and_dirs(
path: pathlib.Path,
start: int,
limit: int,
tracksortby: str,
foldersortby: str,
tracksort_reverse: bool,
foldersort_reverse: bool,
tracks_only: bool = False,
skip_empty_folders=True,
) -> dict[str: list|int|str]:
"""
Scan folder for files and folders.
Will only return files in `swingmusic.utils.filesystem.SUPPORTED_FILES`.
If `skip_empty_folders` is True
:param path:
:param start:
:param limit:
:param tracksortby:
:param foldersortby:
:param tracksort_reverse:
:param foldersort_reverse:
:param tracks_only: If True, will only return tracks with no folders
:param skip_empty_folders: If True, will call recursively and skip empty folders until >0 supported file found.
:returns: List of tracks and folders in that immediate path.
"""
path = pathlib.Path(path)
# if file or non-existent
if not path.exists() or not path.is_dir():
return {
"path": path.as_posix(),
"tracks": [],
"folders": [],
"total": 0
}
# iter through all folders
# add files with supported suffix
# ignore hidden folder
dirs, files = [], []
for entry in path.iterdir():
ext = entry.suffix.lower()
if entry.is_dir() and not entry.stem.startswith("."):
dirs.append((entry / "").as_posix())
# only append as posix for FolderStore and sort_folder function
# TODO: rework everything to support pathlib
# add a trailing slash to the folder path
# to avoid matching a folder starting with the same name as the root path
# eg. .../Music and .../Music VideosI
elif entry.is_file() and ext in SUPPORTED_FILES:
files.append(entry)
# sort files by most recent
# TODO: rework if realy needed.
files_with_mtime = []
for file in files:
try:
files_with_mtime.append(
{
"path": file.as_posix(),
"time": file.lstat().st_mtime,
}
)
except OSError as e:
log.error(e)
files_with_mtime.sort(key=lambda f: f["time"])
files = [f["path"] for f in files_with_mtime]
# if supported files were found
# convert files to tracks
tracks = []
if len(files) > 0:
if limit == -1:
limit = len(files)
# only return tracks already indexed by us
tracks = list(FolderStore.get_tracks_by_filepaths(files))
tracks = sort_tracks(tracks, tracksortby, tracksort_reverse)
tracks = tracks[start : start + limit]
folders = []
if not tracks_only:
folders = get_folders(dirs)
folders = sort_folders(folders, foldersortby, foldersort_reverse)
if skip_empty_folders and len(folders) == 1 and len(tracks) == 0:
# INFO: When we only have one folder and no tracks,
# skip through empty folders.
# Call recursively with the first folder in the list.
return get_files_and_dirs(
folders[0].path,
start=start,
limit=limit,
tracksortby=tracksortby,
foldersortby=foldersortby,
tracksort_reverse=tracksort_reverse,
foldersort_reverse=foldersort_reverse,
tracks_only=tracks_only,
skip_empty_folders=True,
)
return {
"path": path.as_posix(),
"tracks": serialize_tracks(tracks),
"folders": folders,
"total": len(files),
}
-30
View File
@@ -1,30 +0,0 @@
from swingmusic.db.userdata import MixTable
from swingmusic.plugins.mixes import MixesPlugin
def find_mix(mixid: str, sourcehash: str):
"""
Find a mix in the homepage store or the db.
"""
from swingmusic.store.homepage import HomepageStore
mixtype = "custom_mixes" if mixid[0] == "t" else "artist_mixes"
# INFO: Try getting the mix from the homepage store
mix = HomepageStore.get_mix(mixtype, mixid)
if mix and mix["sourcehash"] == sourcehash:
return mix
# INFO: Get the mix from the db
mix = MixTable.get_by_sourcehash(sourcehash)
if not mix:
return None
if mixtype == "custom_mixes":
mix = MixesPlugin.get_track_mix(mix)
if not mix:
return None
return mix.to_dict()
-170
View File
@@ -1,170 +0,0 @@
import os
import pathlib
from swingmusic.db.userdata import PlaylistTable
from swingmusic.lib.home import find_mix
from swingmusic.lib.home.recentlyadded import get_recently_added_playlist
from swingmusic.lib.home.recentlyplayed import get_recently_played_playlist
from swingmusic.models.logger import TrackLog
from swingmusic.store.albums import AlbumStore
from swingmusic.store.artists import ArtistStore
from swingmusic.store.tracks import TrackStore
def create_items(entries: list[TrackLog], limit: int):
"""
TODO: rework so that returns a dict with
{
"recently_played": ...,
"artist_mixes_for_you": ...
}
also keep in mind that the web-ui is beeing translated.
"""
custom_playlists = [
{"name": "recentlyadded", "handler": get_recently_added_playlist},
{"name": "recentlyplayed", "handler": get_recently_played_playlist},
]
items = []
added = set()
for entry in entries:
if len(items) >= limit:
break
if entry.source in added:
continue
added.add(entry.source)
if entry.type == "mix":
if not entry.type_src:
continue
splits = entry.type_src.split(".")
try:
mixid = splits[0]
sourcehash = splits[1]
except IndexError:
continue
# INFO: Get mix from homepage store
mix = find_mix(mixid, sourcehash)
if not mix:
continue
items.append(
{
"type": "mix",
"hash": entry.type_src,
"timestamp": entry.timestamp,
}
)
continue
if entry.type == "album":
album = AlbumStore.albummap.get(entry.type_src)
if album is None:
continue
item = {
"type": "album",
"hash": entry.type_src,
"timestamp": entry.timestamp,
}
items.append(item)
continue
if entry.type == "artist":
artist = ArtistStore.artistmap.get(entry.type_src)
if artist is None:
continue
items.append(
{
"type": "artist",
"hash": entry.type_src,
"timestamp": entry.timestamp,
}
)
continue
if entry.type == "folder":
folder = entry.type_src
if not folder:
continue
if not folder.endswith("/"):
folder += "/"
is_home_dir = entry.type_src == "$home"
if is_home_dir:
folder = os.path.expanduser("~")
if not pathlib.Path(folder).exists():
continue
item = {
"type": "folder",
"hash": folder,
"timestamp": entry.timestamp,
}
items.append(item)
continue
if entry.type == "playlist":
is_custom = entry.type_src in [i["name"] for i in custom_playlists]
if is_custom:
items.append(
{
"type": "playlist",
"hash": entry.type_src,
"timestamp": entry.timestamp,
"is_custom": True,
}
)
continue
playlist = PlaylistTable.get_by_id(entry.type_src)
if playlist is None:
continue
item = {
"type": "playlist",
"hash": entry.type_src,
"timestamp": entry.timestamp,
}
items.append(item)
continue
if entry.type == "favorite":
items.append(
{
"type": "favorite",
"timestamp": entry.timestamp,
}
)
continue
t = TrackStore.trackhashmap.get(entry.trackhash)
if t is None:
continue
item = {
"type": "track",
"hash": entry.trackhash,
"timestamp": entry.timestamp,
}
items.append(item)
return items
@@ -1,42 +0,0 @@
from swingmusic.db.userdata import ScrobbleTable
from swingmusic.lib.home.create_items import create_items
from swingmusic.models.logger import TrackLog
def get_recently_played(
limit: int, userid: int | None = None, _entries: list[TrackLog] = []
):
"""
Get the recently played items for the homepage.
Pass a list of track log entries to use a subset of the scrobble table.
"""
# TODO: Paginate this
items = []
BATCH_SIZE = 200
current_index = 0
if len(_entries):
entries = _entries
limit = 1
else:
entries = ScrobbleTable.get_all(0, BATCH_SIZE, userid=userid)
max_iterations = 20
iterations = 0
while len(items) < limit and iterations < max_iterations:
items.extend(create_items(entries, limit))
current_index += BATCH_SIZE
if len(items) < limit:
entries = ScrobbleTable.get_all(
start=current_index + 1, limit=BATCH_SIZE, userid=userid
)
if not entries:
break
iterations += 1
return items
-217
View File
@@ -1,217 +0,0 @@
import pathlib
from datetime import datetime
from swingmusic.lib.playlistlib import get_first_4_images
from swingmusic.models.playlist import Playlist
from swingmusic.models.track import Track
from swingmusic.store.tracks import TrackStore
from swingmusic.store.albums import AlbumStore
from swingmusic.store.artists import ArtistStore
from itertools import groupby
from swingmusic.utils import flatten
from swingmusic.utils.dates import (
create_new_date,
date_string_to_time_passed,
)
older_albums = set()
older_artists = set()
def calc_based_on_percent(items: list[str], total: int):
"""
Checks if items is more than 85% of total items. Returns a boolean and the most common item.
"""
most_common = max(items, key=items.count)
most_common_count = items.count(most_common)
return most_common_count / total >= 0.7, most_common, most_common_count
def check_is_album_folder(tracks: list[Track]):
albumhashes = [t.albumhash for t in tracks]
return calc_based_on_percent(albumhashes, len(tracks))
def check_is_artist_folder(tracks: list[Track]):
# INFO: flatten artist hashes using "-" as a separator
artisthashes = flatten([t.artisthashes for t in tracks])
return calc_based_on_percent(artisthashes, len(tracks))
def check_is_track_folder(tracks: list[Track]):
# INFO: is more of a playlist
if len(tracks) >= 3:
return False
return [create_track(t) for t in tracks]
def create_track(t: Track):
"""
Creates a recently added track entry.
"""
return {
"type": "track",
"hash": t.trackhash,
"timestamp": t.last_mod,
"help_text": "NEW TRACK",
}
# INFO: Keys: folder, tracks, time (timestamp)
# group_type = dict[str, str | list[Track] | float]
def check_folder_type(group_: dict):
# check if all tracks in group have the same albumhash
# if so, return "album"
key: str = group_["folder"]
tracks: list[Track] = group_["tracks"]
time: float = group_["time"]
existing_artist_hashes: set[str] = set(ArtistStore.artistmap.keys())
existing_album_hashes: set[str] = set(AlbumStore.albummap.keys())
if len(tracks) == 1:
entry = create_track(tracks[0])
entry["timestamp"] = time
return entry
is_album, albumhash, _ = check_is_album_folder(tracks)
if is_album:
# album = AlbumTable.get_album_by_albumhash(albumhash)
entry = AlbumStore.albummap.get(albumhash)
if entry is None:
return None
return {
"type": "album",
"hash": albumhash,
"timestamp": time,
"help_text": (
"NEW ALBUM" if albumhash in existing_album_hashes else "NEW TRACKS"
),
}
is_artist, artisthash, trackcount = check_is_artist_folder(tracks)
if is_artist:
entry = ArtistStore.artistmap.get(artisthash)
if entry is None:
return None
return {
"type": "artist",
"hash": artisthash,
"timestamp": time,
"help_text": (
"NEW ARTIST" if artisthash not in existing_artist_hashes else "NEW MUSIC"
),
}
is_track_folder = check_is_track_folder(tracks)
return (
is_track_folder
if is_track_folder
else {
"type": "folder",
"hash": key,
"timestamp": time,
"help_text": "NEW MUSIC",
}
)
def group_track_by_folders(tracks: list[Track], groups: dict[str, list[Track]]):
"""
Groups tracks by folder and returns a list of groups sorted by last modified date.
Uses generator expressions to avoid creating intermediate lists.
"""
# INFO: sort tracks by folder name, then group by folder name
tracks = sorted(tracks, key=lambda t: t.folder)
thisgroup = groupby(tracks, lambda t: t.folder)
for folder, thistracks in thisgroup:
groups.setdefault(folder, []).extend(thistracks)
return groups
def get_recently_added_items(limit: int = 7):
tracks = get_recently_added_tracks(start=0, limit=None)
groups = group_track_by_folders(tracks, {})
grouplist = []
# INFO: sort tracks by last modified date in descending order to get the most recent last modified date
for folder, trackgroup in groups.items():
if not pathlib.Path(folder).exists():
continue
trackgroup.sort(key=lambda t: t.last_mod, reverse=True)
grouplist.append(
{
"folder": folder,
"len": len(trackgroup),
"tracks": trackgroup,
"time": trackgroup[0].last_mod,
}
)
# sort groups by last modified date
grouplist = sorted(grouplist, key=lambda group: group["time"], reverse=True)
recent_items = []
for group in grouplist:
item = check_folder_type(group)
if item not in recent_items:
if not item:
continue
(
recent_items.append(item)
if type(item) == dict
else recent_items.extend(item)
)
if len(recent_items) >= limit:
break
return recent_items
def get_recently_added_playlist(limit: int = 100):
playlist = Playlist(
id="recentlyadded",
name="Recently Added",
image=None,
last_updated="Now",
settings={},
trackhashes=[],
)
tracks = get_recently_added_tracks(limit=limit)
try:
# Create date to show as last updated
date = datetime.fromtimestamp(tracks[0].last_mod)
except IndexError:
return playlist, []
playlist._last_updated = date_string_to_time_passed(create_new_date(date))
images = get_first_4_images(tracks=tracks)
playlist.images = images
playlist.duration = sum(t.duration for t in tracks)
playlist.count = len(tracks)
return playlist, tracks
def get_recently_added_tracks(start: int = 0, limit: int | None = 100):
return TrackStore.get_recently_added(start, limit)
-35
View File
@@ -1,35 +0,0 @@
from datetime import datetime
from swingmusic.db.userdata import ScrobbleTable
from swingmusic.models.playlist import Playlist
from swingmusic.lib.playlistlib import get_first_4_images
from swingmusic.utils.dates import (
create_new_date,
date_string_to_time_passed,
)
from swingmusic.store.tracks import TrackStore
def get_recently_played_playlist(limit: int = 100):
playlist = Playlist(
id="recentlyplayed",
name="Recently Played",
image=None,
last_updated="Now",
settings={},
trackhashes=[],
)
scrobbles = ScrobbleTable.get_all(None, 100)
tracks = TrackStore.get_tracks_by_trackhashes(
[scrobble.trackhash for scrobble in scrobbles]
)
date = datetime.fromtimestamp(tracks[0].lastplayed)
playlist._last_updated = date_string_to_time_passed(create_new_date(date))
images = get_first_4_images(tracks=tracks)
playlist.images = images
return playlist, tracks
-161
View File
@@ -1,161 +0,0 @@
from swingmusic.db.userdata import FavoritesTable, PlaylistTable
from swingmusic.lib.home import find_mix
from swingmusic.lib.home.recentlyadded import get_recently_added_playlist
from swingmusic.lib.home.recentlyplayed import get_recently_played_playlist
from swingmusic.lib.playlistlib import get_first_4_images
from swingmusic.serializers.album import album_serializer
from swingmusic.serializers.artist import serialize_for_card
from swingmusic.serializers.playlist import serialize_for_card as serialize_playlist
from swingmusic.serializers.track import serialize_track
from swingmusic.store.albums import AlbumStore
from swingmusic.store.artists import ArtistStore
from swingmusic.store.folder import FolderStore
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.dates import timestamp_to_time_passed
def recover_items(items: list[dict]):
custom_playlists = [
{"name": "recentlyadded", "handler": get_recently_added_playlist},
{"name": "recentlyplayed", "handler": get_recently_played_playlist},
]
recovered = []
for item in items:
recovered_item = None
if item["type"] == "album":
album = AlbumStore.get_album_by_hash(item["hash"])
if album is None:
continue
album = album_serializer(
album,
to_remove={
"genres",
"date",
"count",
"duration",
"albumartists_hashes",
"og_title",
},
)
recovered_item = {
"type": "album",
"item": album,
}
elif item["type"] == "artist":
artist = ArtistStore.get_artist_by_hash(item["hash"])
if artist is None:
continue
recovered_item = {
"type": "artist",
"item": serialize_for_card(artist),
}
elif item["type"] == "folder":
count = FolderStore.count_tracks_containing_paths([item["hash"]])
recovered_item = {
"type": "folder",
"item": {
"path": item["hash"],
"count": count[0]["trackcount"],
},
}
elif item["type"] == "playlist":
if item.get("is_custom"):
playlist, _ = next(
i["handler"]()
for i in custom_playlists
if i["name"] == item["hash"]
)
playlist.images = [i["image"] for i in playlist.images]
playlist = serialize_playlist(
playlist, to_remove={"settings", "duration"}
)
recovered_item = {
"type": "playlist",
"item": playlist,
}
else:
playlist = PlaylistTable.get_by_id(item["hash"])
if playlist is None:
continue
tracks = TrackStore.get_tracks_by_trackhashes(playlist.trackhashes)
playlist.clear_lists()
if not playlist.has_image:
images = get_first_4_images(tracks)
images = [i["image"] for i in images]
playlist.images = images
recovered_item = {
"type": "playlist",
"item": serialize_playlist(playlist),
}
elif item["type"] == "favorite":
image = None
last_trackhash = FavoritesTable.get_last_trackhash()
if last_trackhash:
trackhash = last_trackhash.replace("track_", "")
entry = TrackStore.trackhashmap.get(trackhash)
if entry:
image = entry.tracks[0].image
recovered_item = {
"type": "favorite",
"item": {
"count": FavoritesTable.count_tracks(),
"image": image,
},
}
elif item["type"] == "track":
track = TrackStore.trackhashmap.get(item["hash"])
if track is None:
continue
recovered_item = {
"type": "track",
"item": serialize_track(track.get_best()),
}
elif item["type"] == "mix":
try:
splits = item["hash"].split(".")
mixid = splits[0]
sourcehash = splits[1]
except IndexError:
continue
mix = find_mix(mixid, sourcehash)
if mix is None:
continue
recovered_item = {
"type": "mix",
"item": mix,
}
if recovered_item is not None:
helptext = item.get("help_text") or item.get("type")
secondary_text = item.get("secondary_text")
if "secondary_text" in item:
secondary_text = item["secondary_text"]
elif "timestamp" in item:
secondary_text = timestamp_to_time_passed(item["timestamp"])
if helptext:
recovered_item["item"]["help_text"] = helptext
if secondary_text:
recovered_item["item"]["time"] = secondary_text
recovered.append(recovered_item)
return recovered
-43
View File
@@ -1,43 +0,0 @@
import gc
import logging
from time import time
from swingmusic.lib.mapstuff import (
map_album_colors,
map_artist_colors,
map_favorites,
map_scrobble_data,
)
from swingmusic.lib.populate import CordinateMedia
from swingmusic.lib.recipes.recents import RecentlyAdded
from swingmusic.lib.tagger import IndexTracks
from swingmusic.store.albums import AlbumStore
from swingmusic.store.artists import ArtistStore
from swingmusic.store.folder import FolderStore
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.threading import background
log = logging.getLogger(__name__)
@background
def index_everything():
IndexTracks()
key = str(time())
TrackStore.load_all_tracks(key)
AlbumStore.load_albums(key)
ArtistStore.load_artists(key)
FolderStore.load_filepaths()
# NOTE: Rebuild recently added items on the homepage store
RecentlyAdded()
# map colors
map_album_colors()
map_artist_colors()
map_scrobble_data()
map_favorites()
CordinateMedia(instance_key=str(time()))
gc.collect()
log.info("Indexing completed")
-343
View File
@@ -1,343 +0,0 @@
import datetime
import pathlib
from pathlib import Path
from swingmusic.store.tracks import TrackStore
# # # # # # # # # # # # # # # # # # # #
# Functions for parsing lyrics lines #
# # # # # # # # # # # # # # # # # # # #
def parse_lyrics_lines(lyrics:str) -> list[dict]:
"""
Split lyrics into lines and determine there tag type.
Parses the tag if the following format is present: [tag]*[tags] <body>
else tag_type is unknown
tag-type and tags are lists combined by their index
:param lyrics: Full lyrics body
:return: {'tag_types', 'body', 'tags'}
"""
entries = []
for line in lyrics.splitlines():
data = {
"tag_types": [],
"tags": []
}
if line.startswith("["):
# loop until all tags are parsed in line
while True:
if "[" in line and "]" in line: # second tag
bracket_content, after_content = line.split("]", 1)
bracket_content = bracket_content.removeprefix("[")
data["tags"].append(bracket_content)
data["body"] = after_content
line = after_content
# check which tag type it is
if bracket_content[0].isnumeric():
data["tag_types"].append( "time" )
elif bracket_content[0].isalpha():
data["tag_types"].append( "meta" )
else:
# if no brackets inside the line, there is also no tag.
break
elif line.startswith("#"):
data["tag_types"].append("comment")
data["tags"] = ""
data["body"] = line
else:
data["tag_types"].append("unknown")
data["tags"] = "unknown"
data["body"] = line
entries.append(data)
return entries
def filter_parse_lyrics_lines(lines:list[dict], tag_types:list|str) -> list[dict]:
"""
filter all lyrics line to only contain given tags
:param lines: list returned by `parse_lyrics_lines`
:param tag_types: list or string of tags return should contain
"""
if isinstance(tag_types, str):
tag_types = [tag_types]
found_tags = []
# line = {"tags", "body", "tag_types"}
for line in lines:
group = {
"tag_types": [],
"tags": []
}
for (tag, tag_type) in zip(line["tags"], line["tag_types"]):
if tag_type in tag_types:
group["tag_types"].append(tag_type)
group["tags"].append(tag)
group["body"] = line["body"]
# filter out no match
if len(group["tags"]) > 0:
found_tags.append(group)
return found_tags
def parse_time_tag(lines:list[dict]) -> list[dict]:
"""
Filter time-tags from lines and parse them.
"""
# filter tag-type time
# format into dict with timestamps
parsed_times = []
time_tags = filter_parse_lyrics_lines(lines, "time")
# line = {"tags", "body", "tag_types"}
for line in time_tags:
for (tag, tag_type) in zip(line["tags"], line["tag_types"]):
minute, seconds = tag.split(":", 1)
parsed_times.append({
"minute": minute,
"seconds": seconds,
"body": line["body"],
})
return parsed_times
# # # # # # # # # # # # # # # # # # # #
# Lyrics class for simplified usage #
# # # # # # # # # # # # # # # # # # # #
class Lyrics:
SUPPORTED_METATAGS = {
"ti": "title",
"ar": "artist",
"al": "album",
"au": "author",
"lr": "lyricist",
"length": "length",
"by": "lrc_author",
"offset": "offset",
"re": "recorder",
"tool": "tool",
"ve": "version"
}
lyrics:str
parsed_lyrics:list[dict]
meta:dict = {}
is_synced:bool = False
def __init__(self, lyrics:str=""):
"""
:param lyrics: entire lyrics body
"""
if lyrics is None:
raise ValueError("Lyrics can not be None")
if isinstance(lyrics, list):
lyrics = lyrics[0]
lyrics = lyrics.replace("engdesc", "")
self.lyrics = lyrics
parsed = parse_lyrics_lines(lyrics)
# translate meta tags
meta = filter_parse_lyrics_lines(parsed, "meta")
for line in meta:
for tag in line["tags"]:
name, body = tag.split(":", 1)
name = name.lower()
dict_name = self.SUPPORTED_METATAGS.get(name, name)
self.meta[dict_name] = body
# check if synced or not.
# not fail-save:
# If even just one time tag in the entire lyrics gets flagged as synced
if len(filter_parse_lyrics_lines(parsed, "time")) > 0:
self.is_synced = True
self.parsed_lyrics = filter_parse_lyrics_lines(parsed, "time")
else:
self.is_synced = False
self.parsed_lyrics = filter_parse_lyrics_lines(parsed, "unknown")
# TODO: add support for multilanguage lyrics
def format_synced_lyrics(self):
"""
Formats synced lyrics into a list of dicts
"""
if not self.is_synced:
raise ValueError("Cannot format synced lyrics if no synced lyrics exist for track.\nPlease use `format_unsynced_lyrics()`")
lyrics = []
time_tags = parse_time_tag(self.parsed_lyrics)
for entry in time_tags:
minutes = entry["minute"]
if "." in entry["seconds"]:
seconds = entry["seconds"].split(".")[0]
milli = entry["seconds"].split(".")[-1]
else:
seconds = entry["seconds"]
milli = "0"
minutes = int(minutes)
seconds = int(seconds)
milli = int(milli)
seconds = datetime.timedelta(minutes=minutes, seconds=seconds, milliseconds=milli).total_seconds()
offset = 0
if "offset" in self.meta:
offset = int(self.meta["offset"]) # offset in milliseconds
milliseconds = seconds * 1000 - offset
lyrics.append({"time": milliseconds, "text": entry["body"]})
return lyrics
def format_unsynced_lyrics(self) -> list[str]:
"""
return unsynced lyrics.
If no lyrics provided return empty string.
"""
lyrics = [item["body"] for item in self.parsed_lyrics]
return lyrics
def __bool__(self):
"""
return True if contains anything
"""
return bool(self.parsed_lyrics)
# # # # # # # # # # # # # # # # # # # # # # # # # # #
# Path and parse function to get lyrics from track #
# # # # # # # # # # # # # # # # # # # # # # # # # # #
def get_lyrics_file(track_path: str|pathlib.Path) -> Lyrics:
"""
Try to get lyrics from a relative lrc file.
:param track_path: path of track
"""
track_path = Path(track_path)
lyrics_path = track_path.with_suffix(".lrc")
extended_path = track_path.with_suffix(".rlrc")
# check paths
if lyrics_path.exists():
lyrics = Lyrics(lyrics_path.read_text())
return lyrics
elif extended_path.exists():
lyrics = Lyrics(extended_path.read_text())
return lyrics
else:
return Lyrics()
def get_lyrics_from_duplicates(track_path: str, trackhash: str) -> Lyrics:
"""
Finds the lyrics from other duplicate tracks
:param track_path: path of track
:param trackhash: Track-hash value
"""
entry = TrackStore.trackhashmap.get(trackhash, None)
if entry is None:
return Lyrics()
for track in entry.tracks:
if track.trackhash == trackhash and track.filepath != track_path:
lyrics = get_lyrics_file(track.filepath)
if lyrics:
return lyrics
return Lyrics()
def get_lyrics_from_tags(trackhash: str) -> Lyrics:
"""
Gets the lyrics from the tags of the track
:param trackhash:
"""
entry = TrackStore.trackhashmap.get(trackhash, None)
if entry is None:
return Lyrics()
for track in entry.tracks:
if "lyrics" in track.extra:
lyrics = track.extra["lyrics"]
if lyrics:
return Lyrics(lyrics)
return Lyrics("")
def check_lyrics_file(filepath: str, trackhash: str):
"""
Checks if the lyrics file exists for a track
"""
lyrics_file = Path(filepath).with_suffix(".lrc")
if lyrics_file.exists:
return True
entry = TrackStore.trackhashmap.get(trackhash, None)
if entry is None:
return False
for track in entry.tracks:
if track.trackhash == trackhash and track.filepath != filepath:
lyrics_file = Path(track.filepath).with_suffix(".lrc")
if lyrics_file.exists():
return True
return False
-94
View File
@@ -1,94 +0,0 @@
from swingmusic.db.userdata import LibDataTable, FavoritesTable, ScrobbleTable
from swingmusic.store.albums import AlbumStore
from swingmusic.store.artists import ArtistStore
from swingmusic.store.tracks import TrackStore
from typing import Any
def map_scrobble_data():
"""
Maps scrobble data to the in-memory stores.
The scrobble data is loaded from the database and grouped by trackhash.
The album and artist scrobble data (for those tracks) are then incremented based on the data.
"""
records = ScrobbleTable.get_all(0, None)
# group records by trackhash
grouped: dict[str, dict[str, Any]] = {}
for record in records:
# aggregate playcount, playduration and lastplayed
item = grouped.setdefault(record.trackhash, {})
item["playcount"] = item.get("playcount", 0) + 1
item["playduration"] = item.get("playduration", 0) + record.duration
item["lastplayed"] = max(item.get("lastplayed", 0), record.timestamp)
# increment playcount, playduration and lastplayed for albums and artists
for trackhash, data in grouped.items():
track = TrackStore.trackhashmap.get(trackhash)
if track is None:
continue
track.increment_playcount(
data["playduration"], data["lastplayed"], data["playcount"]
)
album = AlbumStore.albummap.get(track.tracks[0].albumhash)
if album:
album.increment_playcount(
data["playduration"], data["lastplayed"], data["playcount"]
)
for artisthash in track.tracks[0].artisthashes:
artist = ArtistStore.artistmap.get(artisthash)
if artist:
artist.increment_playcount(
data["playduration"], data["lastplayed"], data["playcount"]
)
def map_favorites():
"""
Maps favorites data to the in-memory stores.
"""
favorites = FavoritesTable.get_all()
for entry in favorites:
if entry.type == "album":
album = AlbumStore.albummap.get(entry.hash)
if album:
album.toggle_favorite_user(entry.userid)
elif entry.type == "artist":
artist = ArtistStore.artistmap.get(entry.hash)
if artist:
artist.toggle_favorite_user(entry.userid)
elif entry.type == "track":
track = TrackStore.trackhashmap.get(entry.hash)
if track:
track.toggle_favorite_user(entry.userid)
def map_artist_colors():
colors = LibDataTable.get_all_colors(type="artist")
for color in colors:
artist = ArtistStore.artistmap.get(color["itemhash"])
if artist:
artist.set_color(color["color"])
def map_album_colors():
colors = LibDataTable.get_all_colors(type="album")
for color in colors:
album = AlbumStore.albummap.get(color["itemhash"])
if album:
album.set_color(color["color"])
-76
View File
@@ -1,76 +0,0 @@
import json
from typing import Any
from swingmusic.serializers.album import serialize_for_card
from swingmusic.serializers.artist import serialize_for_card as serialize_artist
from swingmusic.store.albums import AlbumStore
from swingmusic.store.artists import ArtistStore
from swingmusic.utils.hashing import create_hash
def validate_page_items(items: list[dict[str, str]], existing: list[dict[str, str]]):
"""
Validate the items in a page before adding them to the database.
"""
validated: list[dict[str, str]] = []
indexed = set(create_hash(json.dumps(item)) for item in existing)
for item in items:
if create_hash(json.dumps(item)) in indexed:
continue
if item["type"] == "album":
album = AlbumStore.albummap.get(item["hash"])
if album is not None:
validated.append(item)
elif item["type"] == "artist":
artist = ArtistStore.artistmap.get(item["hash"])
if artist is not None:
validated.append(item)
else:
raise ValueError(f"Invalid item type: {item['type']}")
return validated
def remove_page_items(existing: list[dict[str, str]], item: dict[str, str]):
return [
i
for i in existing
if create_hash(json.dumps(i)) != create_hash(json.dumps(item))
]
def recover_page_items(items: list[dict[str, str]], for_homepage: bool = False):
"""
Recover the items in a page.
"""
recovered: list[dict[str, Any]] = []
for item in items:
if item["type"] == "album":
album = AlbumStore.albummap.get(item["hash"])
if album is not None:
item = serialize_for_card(album.album)
if for_homepage:
del item["type"]
item = {"item": item, "type": "album"}
recovered.append(item)
elif item["type"] == "artist":
artist = ArtistStore.artistmap.get(item["hash"])
if artist is not None:
item = serialize_artist(artist.artist)
if for_homepage:
del item["type"]
item = {"item": item, "type": "artist"}
recovered.append(item)
recovered.reverse()
return recovered
-171
View File
@@ -1,171 +0,0 @@
"""
This library contains all the functions related to playlists.
"""
import random
import string
import logging
from PIL import Image, ImageSequence
from swingmusic import settings
from swingmusic.models.track import Track
from swingmusic.store.albums import AlbumStore
from swingmusic.store.tracks import TrackStore
logger = logging.getLogger(__name__)
def create_thumbnail(image: Image, img_name: str) -> str:
"""
Creates a 250 px high thumbnail from the Image.
It will keep the aspect ratio.
Images are saved in the playlist-img path
:param image: Image object.
:param img_name: Name of image.
:return: Filename of image.
"""
aspect_ratio = image.width / image.height
new_w = round(250 * aspect_ratio)
thumb = image.resize((new_w, 250), Image.Resampling.LANCZOS)
thumb_filename = "thumb_" + img_name
thumb_path = settings.Paths().playlist_img_path / thumb_filename
thumb.save(thumb_path, "webp")
return thumb_filename
def create_gif_thumbnail(image: Image, img_name: str):
"""
Creates a 250 px high thumbnail from the provided GIF.
Keeps the aspect ratio.
Images are saved in the playlist-img path
:param image: Image object.
:param img_name: Name of image.
:return: Filename of image.
"""
thumb_name = "thumb_" + img_name
thumb_path = settings.Paths().playlist_img_path / thumb_name
frames = []
for frame in ImageSequence.Iterator(image):
aspect_ratio = frame.width / frame.height
new_w = round(250 * aspect_ratio)
thumb = frame.resize((new_w, 250), Image.Resampling.LANCZOS)
frames.append(thumb)
frames[0].save(thumb_path, save_all=True, append_images=frames[1:])
return thumb_name
def save_p_image(img: Image, pid: int, content_type: str = None, filename: str = None) -> str:
"""
Saves a playlist banner image and returns the filepath.
"""
# img = Image.open(file)
random_str = "".join(random.choices(string.ascii_letters + string.digits, k=5))
if not filename:
filename = str(pid) + str(random_str) + ".webp"
full_img_path = settings.Paths().playlist_img_path / filename
if content_type == "image/gif":
frames = []
for frame in ImageSequence.Iterator(img):
frames.append(frame.copy())
frames[0].save(full_img_path, save_all=True, append_images=frames[1:])
create_gif_thumbnail(img, img_path=filename)
return filename
img.save(full_img_path, "webp")
create_thumbnail(img, img_name=filename)
return filename
def duplicate_images(images: list):
if len(images) == 1:
images *= 4
elif len(images) == 2:
images += list(reversed(images))
elif len(images) == 3:
images = images + images[:1]
return images
# TODO: mutable var in param.
def get_first_4_images(
tracks: list[Track] = [],
trackhashes: list[str] = []
) -> list[dict["str", str]]:
"""
Returns images of the first 4 albums that appear in the track list.
When tracks are not passed, trackhashes need to be passed.
Tracks are then resolved from the store.
"""
if len(trackhashes) > 0:
tracks = TrackStore.get_tracks_by_trackhashes(trackhashes)
albums = []
for track in tracks:
if track.albumhash not in albums:
albums.append(track.albumhash)
if len(albums) == 4:
break
albums = AlbumStore.get_albums_by_hashes(albums)
images = [
{
"image": album.image,
"color": album.color,
}
for album in albums
]
if len(images) == 4:
return images
return duplicate_images(images)
def cleanup_playlist_images() -> None:
"""
Deletes all unlinked files in playlist-img folder.
All files not present in the PlaylistTable will get deleted
"""
# Import here to avoid circular import
from swingmusic.db.userdata import PlaylistTable
playlists = PlaylistTable.get_all()
linked_images = {p.image for p in playlists if p.image and p.image != "None"}
playlist_dir = settings.Paths().playlist_img_path
# Find unlinked images (including thumbnails)
for file in playlist_dir.iterdir():
if not file.isfile:
continue
name = file.name # not stem. PlaylistTable saves with extension
if file not in linked_images:
if name.removeprefix("thumb_") not in linked_images:
continue
try:
file.unlink(missing_ok=True)
except OSError as e:
logger.exception("could not delete file", exc_info=e)
-176
View File
@@ -1,176 +0,0 @@
import functools
import os
from dataclasses import asdict
import multiprocessing as mp
from requests import ReadTimeout
from concurrent.futures import ProcessPoolExecutor
from requests import ConnectionError as RequestConnectionError
import logging
from swingmusic import settings
from swingmusic.lib.artistlib import CheckArtistImages
from swingmusic.lib.taglib import extract_thumb
from swingmusic.models import Album, Artist
from swingmusic.models.lastfm import SimilarArtist
from swingmusic.models.track import Track
from swingmusic.store.albums import AlbumStore
from swingmusic.store.artists import ArtistStore
from swingmusic.utils.network import has_connection
from swingmusic.utils.progressbar import tqdm
from swingmusic.request.artists import fetch_similar_artists
from swingmusic.lib.colorlib import ProcessAlbumColors, ProcessArtistColors
from swingmusic.db.userdata import SimilarArtistTable
log = logging.getLogger(__name__)
class CordinateMedia:
"""
Cordinates the extracting of thumbnails
"""
def __init__(self, instance_key: str):
ProcessTrackThumbnails()
ProcessAlbumColors()
ProcessArtistColors()
tried_to_download_new_images = False
if has_connection():
tried_to_download_new_images = True
try:
CheckArtistImages()
except (RequestConnectionError, ReadTimeout) as e:
log.error(
"Internet connection lost. Downloading artist images suspended."
)
log.error(e) # REVIEW More informations = good
else:
log.warning("No internet connection. Downloading artist images suspended!")
# Re-process the new artist images.
if tried_to_download_new_images:
ProcessArtistColors()
if has_connection():
print("Attempting to download similar artists...")
FetchSimilarArtistsLastFM()
def get_image(tracks: list[Track], paths=None):
"""
The function retrieves an image from a list of tracks by extracting the thumbnail from the first track that has one.
:param tracks: A list of Track objects to extract the image from.
:type tracks: list[Track]
:return: None
"""
for track in tracks:
extracted = extract_thumb(track.filepath, track.albumhash + ".webp", paths)
if extracted:
return
class ProcessTrackThumbnails:
"""
Extracts the album art from all albums in album store.
"""
def extract(self, albums: list[Album]):
"""
Extracts the album art with platform-specific logic.
"""
cpus = max(1, os.cpu_count() // 2)
albumsMap = ( AlbumStore.get_album_tracks(album.albumhash) for album in albums )
# Create process pool with worker function
with mp.Pool(processes=cpus) as pool:
worker = functools.partial(get_image, paths=settings.Paths())
# Process files and track progress
results = list(
tqdm(
pool.imap_unordered(worker, albumsMap),
total=len(albums),
desc="Extracting track images",
)
)
list(results)
def __init__(self) -> None:
"""
Filters out albums that already have thumbnails and
extracts the thumbnail for the other albums.
"""
path = settings.Paths().sm_thumb_path
# read all the files in the thumbnail directory
processed = set(file.stem for file in path.iterdir())
# filter out albums that already have thumbnails
albums = filter(
lambda album: album.albumhash not in processed,
AlbumStore.get_flat_list(),
)
albums = list(albums)
self.extract(albums)
def save_similar_artists(artist: Artist):
"""
Downloads and saves similar artists to the database.
"""
if SimilarArtistTable.exists(artist.artisthash):
return
artists = fetch_similar_artists(artist.name)
# INFO: Nones mean there was a connection error
if artists is None:
return
artist_ = SimilarArtist(artist.artisthash, artists)
SimilarArtistTable.insert_one(asdict(artist_))
class FetchSimilarArtistsLastFM:
"""
Fetches similar artists from LastFM using a thread pool.
"""
def __init__(self) -> None:
# read all artists from db
storeArtists = ArtistStore.get_flat_list()
processed = set(a.artisthash for a in SimilarArtistTable.get_all())
# filter out artists that already have similar artists using generator
def artist_generator():
for artist in storeArtists:
if artist.artisthash in processed:
yield artist
artists = list(artist_generator())
cpus = max(1, os.cpu_count() // 2)
with ProcessPoolExecutor(max_workers=cpus) as executor:
try:
# TODO: fix negative total length
results = list(
tqdm(
executor.map(save_similar_artists, artist_generator()),
total=len(artists),
desc="Fetching similar artists",
)
)
list(results)
# any exception that can be raised by the pool
except Exception as e:
log.warning(e)
return
-17
View File
@@ -1,17 +0,0 @@
### Steps to reproduce
### Expected behavior
Tell us what should happen
### Actual behavior
Tell us what happens instead
### Your System configuration
- Python version:
- Pydub version:
- ffmpeg or avlib?:
- ffmpeg/avlib version:
### Is there an audio file you can include to help us reproduce?
You can include the audio file in this issue - just put it in a zip file and drag/drop the zip file into the github issue.
-19
View File
@@ -1,19 +0,0 @@
os: linux
dist: bionic # focal
language: python
before_install:
- sudo apt-get update --fix-missing
install:
- sudo apt-get install -y ffmpeg libopus-dev python-scipy python3-scipy
python:
- "2.7"
- "3.6"
- "3.7"
- "3.8"
- "3.9"
- "pypy2"
- "pypy3"
script:
- python test/test.py
after_script:
- pip install pylama && python -m pylama -i W,E501 pydub/ || true
-693
View File
@@ -1,693 +0,0 @@
# API Documentation
This document is a work in progress.
If you're looking for some functionality in particular, it's a good idea to take a look at the [source code](https://github.com/jiaaro/pydub). Core functionality is mostly in `pydub/audio_segment.py` a number of `AudioSegment` methods are in the `pydub/effects.py` module, and added to `AudioSegment` via the effect registration process (the `register_pydub_effect()` decorator function)
Currently Undocumented:
- Playback (`pydub.playback`)
- Signal Processing (compression, EQ, normalize, speed change - `pydub.effects`, `pydub.scipy_effects`)
- Signal generators (Sine, Square, Sawtooth, Whitenoise, etc - `pydub.generators`)
- Effect registration system (basically the `pydub.utils.register_pydub_effect` decorator)
## AudioSegment()
`AudioSegment` objects are immutable, and support a number of operators.
```python
from pydub import AudioSegment
sound1 = AudioSegment.from_file("/path/to/sound.wav", format="wav")
sound2 = AudioSegment.from_file("/path/to/another_sound.wav", format="wav")
# sound1 6 dB louder, then 3.5 dB quieter
louder = sound1 + 6
quieter = sound1 - 3.5
# sound1, with sound2 appended
combined = sound1 + sound2
# sound1 repeated 3 times
repeated = sound1 * 3
# duration
duration_in_milliseconds = len(sound1)
# first 5 seconds of sound1
beginning = sound1[:5000]
# last 5 seconds of sound1
end = sound1[-5000:]
# split sound1 in 5-second slices
slices = sound1[::5000]
# Advanced usage, if you have raw audio data:
sound = AudioSegment(
# raw audio data (bytes)
data=b'',
# 2 byte (16 bit) samples
sample_width=2,
# 44.1 kHz frame rate
frame_rate=44100,
# stereo
channels=2
)
```
Any operations that combine multiple `AudioSegment` objects in *any* way will first ensure that they have the same number of channels, frame rate, sample rate, bit depth, etc. When these things do not match, the lower quality sound is modified to match the quality of the higher quality sound so that quality is not lost: mono is converted to stereo, bit depth and frame rate/sample rate are increased as needed. If you do not want this behavior, you may explicitly reduce the number of channels, bits, etc using the appropriate `AudioSegment` methods.
### AudioSegment(…).from_file()
Open an audio file as an `AudioSegment` instance and return it. there are also a number of wrappers provided for convenience, but you should probably just use this directly.
```python
from pydub import AudioSegment
# wave and raw dont use ffmpeg
wav_audio = AudioSegment.from_file("/path/to/sound.wav", format="wav")
raw_audio = AudioSegment.from_file("/path/to/sound.raw", format="raw",
frame_rate=44100, channels=2, sample_width=2)
# all other formats use ffmpeg
mp3_audio = AudioSegment.from_file("/path/to/sound.mp3", format="mp3")
# use a file you've already opened (advanced …ish)
with open("/path/to/sound.wav", "rb") as wav_file:
audio_segment = AudioSegment.from_file(wav_file, format="wav")
# also supports the os.PathLike protocol for python >= 3.6
from pathlib import Path
wav_path = Path("path/to/sound.wav")
wav_audio = AudioSegment.from_file(wav_path)
```
The first argument is the path (as a string) of the file to read, **or** a file handle to read from.
**Supported keyword arguments**:
- `format` | example: `"aif"` | default: autodetected
Format of the output file. Supports `"wav"` and `"raw"` natively, requires ffmpeg for all other formats. `"raw"` files require 3 additional keyword arguments, `sample_width`, `frame_rate`, and `channels`, denoted below with: **`raw` only**. This extra info is required because raw audio files do not have headers to include this info in the file itself like wav files do.
- `sample_width` | example: `2`
**`raw` only** — Use `1` for 8-bit audio `2` for 16-bit (CD quality) and `4` for 32-bit. Its the number of bytes per sample.
- `channels` | example: `1`
**`raw` only** — `1` for mono, `2` for stereo.
- `frame_rate` | example: `2`
**`raw` only** — Also known as sample rate, common values are `44100` (44.1kHz - CD audio), and `48000` (48kHz - DVD audio)
- `start_second` | example: `2.0` | default: `None`
Offset (in seconds) to start loading the audio file. If `None`, the audio will start loading from the beginning.
- `duration` | example: `2.5` | default: `None`
Number of seconds to be loaded. If `None`, full audio will be loaded.
### AudioSegment(…).export()
Write the `AudioSegment` object to a file returns a file handle of the output file (you don't have to do anything with it, though).
```python
from pydub import AudioSegment
sound = AudioSegment.from_file("/path/to/sound.wav", format="wav")
# simple export
file_handle = sound.export("/path/to/output.mp3", format="mp3")
# more complex export
file_handle = sound.export("/path/to/output.mp3",
format="mp3",
bitrate="192k",
tags={"album": "The Bends", "artist": "Radiohead"},
cover="/path/to/albumcovers/radioheadthebends.jpg")
# split sound in 5-second slices and export
for i, chunk in enumerate(sound[::5000]):
with open("sound-%s.mp3" % i, "wb") as f:
chunk.export(f, format="mp3")
```
The first argument is the location (as a string) to write the output, **or** a file handle to write to. If you do not pass an output file or path, a temporary file is generated.
**Supported keyword arguments**:
- `format` | example: `"aif"` | default: `"mp3"`
Format of the output file. Supports `"wav"` and `"raw"` natively, requires ffmpeg for all other formats.
- `codec` | example: `"libvorbis"`
For formats that may contain content encoded with different codecs, you can specify the codec you'd like the encoder to use. For example, the "ogg" format is often used with the "libvorbis" codec. (requires ffmpeg)
- `bitrate` | example: `"128k"`
For compressed formats, you can pass the bitrate you'd like the encoder to use (requires ffmpeg). Each codec accepts different bitrate arguments so take a look at the [ffmpeg documentation](https://www.ffmpeg.org/ffmpeg-codecs.html#Audio-Encoders) for details (bitrate usually shown as `-b`, `-ba` or `-a:b`).
- `tags` | example: `{"album": "1989", "artist": "Taylor Swift"}`
Allows you to supply media info tags for the encoder (requires ffmpeg). Not all formats can receive tags (mp3 can).
- `parameters` | example: `["-ac", "2"]`
Pass additional [command line parameters](https://www.ffmpeg.org/ffmpeg.html) to the ffmpeg call. These are added to the end of the call (in the output file section).
- `id3v2_version` | example: `"3"` | default: `"4"`
Set the ID3v2 version used by ffmpeg to add tags to the output file. If you want Windows Exlorer to display tags, use `"3"` here ([source](http://superuser.com/a/453133)).
- `cover` | example: `"/path/to/imgfile.png"`
Allows you to supply a cover image (path to the image file). Currently, only MP3 files allow this keyword argument. Cover image must be a jpeg, png, bmp, or tiff file.
### AudioSegment.empty()
Creates a zero-duration `AudioSegment`.
```python
from pydub import AudioSegment
empty = AudioSegment.empty()
len(empty) == 0
```
This is useful for aggregation loops:
```python
from pydub import AudioSegment
sounds = [
AudioSegment.from_wav("sound1.wav"),
AudioSegment.from_wav("sound2.wav"),
AudioSegment.from_wav("sound3.wav"),
]
playlist = AudioSegment.empty()
for sound in sounds:
playlist += sound
```
### AudioSegment.silent()
Creates a silent audiosegment, which can be used as a placeholder, spacer, or as a canvas to overlay other sounds on top of.
```python
from pydub import AudioSegment
ten_second_silence = AudioSegment.silent(duration=10000)
```
**Supported keyword arguments**:
- `duration` | example: `3000` | default: `1000` (1 second)
Length of the silent `AudioSegment`, in milliseconds
- `frame_rate` | example `44100` | default: `11025` (11.025 kHz)
Frame rate (i.e., sample rate) of the silent `AudioSegment` in Hz
### AudioSegment.from_mono_audiosegments()
Creates a multi-channel audiosegment out of multiple mono audiosegments (two or more). Each mono audiosegment passed in should be exactly the same length, down to the frame count.
```python
from pydub import AudioSegment
left_channel = AudioSegment.from_wav("sound1.wav")
right_channel = AudioSegment.from_wav("sound1.wav")
stereo_sound = AudioSegment.from_mono_audiosegments(left_channel, right_channel)
```
### AudioSegment(…).dBFS
Returns the loudness of the `AudioSegment` in dBFS (db relative to the maximum possible loudness). A Square wave at maximum amplitude will be roughly 0 dBFS (maximum loudness), whereas a Sine Wave at maximum amplitude will be roughly -3 dBFS.
```python
from pydub import AudioSegment
sound = AudioSegment.from_file("sound1.wav")
loudness = sound.dBFS
```
### AudioSegment(…).channels
Number of channels in this audio segment (1 means mono, 2 means stereo)
```python
from pydub import AudioSegment
sound = AudioSegment.from_file("sound1.wav")
channel_count = sound.channels
```
### AudioSegment(…).sample_width
Number of bytes in each sample (1 means 8 bit, 2 means 16 bit, etc). CD Audio is 16 bit, (sample width of 2 bytes).
```python
from pydub import AudioSegment
sound = AudioSegment.from_file("sound1.wav")
bytes_per_sample = sound.sample_width
```
### AudioSegment(…).frame_rate
CD Audio has a 44.1kHz sample rate, which means `frame_rate` will be `44100` (same as sample rate, see `frame_width`). Common values are `44100` (CD), `48000` (DVD), `22050`, `24000`, `12000` and `11025`.
```python
from pydub import AudioSegment
sound = AudioSegment.from_file("sound1.wav")
frames_per_second = sound.frame_rate
```
### AudioSegment(…).frame_width
Number of bytes for each "frame". A frame contains a sample for each channel (so for stereo you have 2 samples per frame, which are played simultaneously). `frame_width` is equal to `channels * sample_width`. For CD Audio it'll be `4` (2 channels times 2 bytes per sample).
```python
from pydub import AudioSegment
sound = AudioSegment.from_file("sound1.wav")
bytes_per_frame = sound.frame_width
```
### AudioSegment(…).rms
A measure of loudness. Used to compute dBFS, which is what you should use in most cases. Loudness is logarithmic (rms is not), which makes dB a much more natural scale.
```python
from pydub import AudioSegment
sound = AudioSegment.from_file("sound1.wav")
loudness = sound.rms
```
### AudioSegment(…).max
The highest amplitude of any sample in the `AudioSegment`. Useful for things like normalization (which is provided in `pydub.effects.normalize`).
```python
from pydub import AudioSegment
sound = AudioSegment.from_file("sound1.wav")
peak_amplitude = sound.max
```
### AudioSegment(…).max_dBFS
The highest amplitude of any sample in the `AudioSegment`, in dBFS (relative to the highest possible amplitude value). Useful for things like normalization (which is provided in `pydub.effects.normalize`).
```python
from pydub import AudioSegment
sound = AudioSegment.from_file("sound1.wav")
normalized_sound = sound.apply_gain(-sound.max_dBFS)
```
### AudioSegment(…).duration_seconds
Returns the duration of the `AudioSegment` in seconds (`len(sound)` returns milliseconds). This is provided for convenience; it calls `len()` internally.
```python
from pydub import AudioSegment
sound = AudioSegment.from_file("sound1.wav")
assert sound.duration_seconds == (len(sound) / 1000.0)
```
### AudioSegment(…).raw_data
The raw audio data of the AudioSegment. Useful for interacting with other audio libraries or weird APIs that want audio data in the form of a bytestring. Also comes in handy if youre implementing effects or other direct signal processing.
You probably dont need this, but if you do… youll know.
```python
from pydub import AudioSegment
sound = AudioSegment.from_file("sound1.wav")
raw_audio_data = sound.raw_data
```
### AudioSegment(…).frame_count()
Returns the number of frames in the `AudioSegment`. Optionally you may pass in a `ms` keywork argument to retrieve the number of frames in that number of milliseconds of audio in the `AudioSegment` (useful for slicing, etc).
```python
from pydub import AudioSegment
sound = AudioSegment.from_file("sound1.wav")
number_of_frames_in_sound = sound.frame_count()
number_of_frames_in_200ms_of_sound = sound.frame_count(ms=200)
```
**Supported keyword arguments**:
- `ms` | example: `3000` | default: `None` (entire duration of `AudioSegment`)
When specified, method returns number of frames in X milliseconds of the `AudioSegment`
### AudioSegment(…).append()
Returns a new `AudioSegment`, created by appending another `AudioSegment` to this one (i.e., adding it to the end), Optionally using a crossfade. `AudioSegment(…).append()` is used internally when adding `AudioSegment` objects together with the `+` operator.
By default a 100ms (0.1 second) crossfade is used to eliminate pops and crackles.
```python
from pydub import AudioSegment
sound1 = AudioSegment.from_file("sound1.wav")
sound2 = AudioSegment.from_file("sound2.wav")
# default 100 ms crossfade
combined = sound1.append(sound2)
# 5000 ms crossfade
combined_with_5_sec_crossfade = sound1.append(sound2, crossfade=5000)
# no crossfade
no_crossfade1 = sound1.append(sound2, crossfade=0)
# no crossfade
no_crossfade2 = sound1 + sound2
```
**Supported keyword arguments**:
- `crossfade` | example: `3000` | default: `100` (entire duration of `AudioSegment`)
When specified, method returns number of frames in X milliseconds of the `AudioSegment`
### AudioSegment(…).overlay()
Overlays an `AudioSegment` onto this one. In the resulting `AudioSegment` they will play simultaneously. If the overlaid `AudioSegment` is longer than this one, the result will be truncated (so the end of the overlaid sound will be cut off). The result is always the same length as this `AudioSegment` even when using the `loop`, and `times` keyword arguments.
Since `AudioSegment` objects are immutable, you can get around this by overlaying the shorter sound on the longer one, or by creating a silent `AudioSegment` with the appropriate duration, and overlaying both sounds on to that one.
```python
from pydub import AudioSegment
sound1 = AudioSegment.from_file("sound1.wav")
sound2 = AudioSegment.from_file("sound2.wav")
played_togther = sound1.overlay(sound2)
sound2_starts_after_delay = sound1.overlay(sound2, position=5000)
volume_of_sound1_reduced_during_overlay = sound1.overlay(sound2, gain_during_overlay=-8)
sound2_repeats_until_sound1_ends = sound1.overlay(sound2, loop=true)
sound2_plays_twice = sound1.overlay(sound2, times=2)
# assume sound1 is 30 sec long and sound2 is 5 sec long:
sound2_plays_a_lot = sound1.overlay(sound2, times=10000)
len(sound1) == len(sound2_plays_a_lot)
```
**Supported keyword arguments**:
- `position` | example: `3000` | default: `0` (beginning of this `AudioSegment`)
The overlaid `AudioSegment` will not begin until X milliseconds have passed
- `loop` | example: `True` | default: `False` (entire duration of `AudioSegment`)
The overlaid `AudioSegment` will repeat (starting at `position`) until the end of this `AudioSegment`
- `times` | example: `4` | default: `1` (entire duration of `AudioSegment`)
The overlaid `AudioSegment` will repeat X times (starting at `position`) but will still be truncated to the length of this `AudioSegment`
- `gain_during_overlay` | example: `-6.0` | default: `0` (no change in volume during overlay)
Change the original audio by this many dB while overlaying audio. This can be used to make the original audio quieter while the overlaid audio plays.
### AudioSegment(…).apply_gain(`gain`)
Change the amplitude (generally, loudness) of the `AudioSegment`. Gain is specified in dB. This method is used internally by the `+` operator.
```python
from pydub import AudioSegment
sound1 = AudioSegment.from_file("sound1.wav")
# make sound1 louder by 3.5 dB
louder_via_method = sound1.apply_gain(+3.5)
louder_via_operator = sound1 + 3.5
# make sound1 quieter by 5.7 dB
quieter_via_method = sound1.apply_gain(-5.7)
quieter_via_operator = sound1 - 5.7
```
### AudioSegment(…).fade()
A more general (more flexible) fade method. You may specify `start` and `end`, or one of the two along with duration (e.g., `start` and `duration`).
```python
from pydub import AudioSegment
sound1 = AudioSegment.from_file("sound1.wav")
fade_louder_for_3_seconds_in_middle = sound1.fade(to_gain=+6.0, start=7500, duration=3000)
fade_quieter_beteen_2_and_3_seconds = sound1.fade(to_gain=-3.5, start=2000, end=3000)
# easy way is to use the .fade_in() convenience method. note: -120dB is basically silent.
fade_in_the_hard_way = sound1.fade(from_gain=-120.0, start=0, duration=5000)
fade_out_the_hard_way = sound1.fade(to_gain=-120.0, end=0, duration=5000)
```
**Supported keyword arguments**:
- `to_gain` | example: `-3.0` | default: `0` (0dB, no change)
Resulting change at the end of the fade. `-6.0` means fade will be be from 0dB (no change) to -6dB, and everything after the fade will be -6dB.
- `from_gain` | example: `-3.0` | default: `0` (0dB, no change)
Change at the beginning of the fade. `-6.0` means fade (and all audio before it) will be be at -6dB will fade up to 0dB the rest of the audio after the fade will be at 0dB (i.e., unchanged).
- `start` | example: `7500` | NO DEFAULT
Position to begin fading (in milliseconds). `5500` means fade will begin after 5.5 seconds.
- `end` | example: `4` | NO DEFAULT
The overlaid `AudioSegment` will repeat X times (starting at `position`) but will still be truncated to the length of this `AudioSegment`
- `duration` | example: `4` | NO DEFAULT
You can use `start` or `end` with duration, instead of specifying both - provided as a convenience.
### AudioSegment(…).fade_out()
Fade out (to silent) the end of this `AudioSegment`. Uses `.fade()` internally.
**Supported keyword arguments**:
- `duration` | example: `5000` | NO DEFAULT
How long (in milliseconds) the fade should last. Passed directly to `.fade()` internally
### AudioSegment(…).fade_in()
Fade in (from silent) the beginning of this `AudioSegment`. Uses `.fade()` internally.
**Supported keyword arguments**:
- `duration` | example: `5000` | NO DEFAULT
How long (in milliseconds) the fade should last. Passed directly to `.fade()` internally
### AudioSegment(…).reverse()
Make a copy of this `AudioSegment` that plays backwards. Useful for Pink Floyd, screwing around, and some audio processing algorithms.
### AudioSegment(…).set_sample_width()
Creates an equivalent version of this `AudioSegment` with the specified sample width (in bytes). Increasing this value does not generally cause a reduction in quality. Reducing it *definitely* does cause a loss in quality. Higher Sample width means more dynamic range.
### AudioSegment(…).set_frame_rate()
Creates an equivalent version of this `AudioSegment` with the specified frame rate (in Hz). Increasing this value does not generally cause a reduction in quality. Reducing it *definitely does* cause a loss in quality. Higher frame rate means larger frequency response (higher frequencies can be represented).
### AudioSegment(…).set_channels()
Creates an equivalent version of this `AudioSegment` with the specified number of channels (1 is Mono, 2 is Stereo). Converting from mono to stereo does not cause any audible change. Converting from stereo to mono may result in loss of quality (but only if the left and right chanels differ).
### AudioSegment(…).split_to_mono()
Splits a stereo `AudioSegment` into two, one for each channel (Left/Right). Returns a list with the new `AudioSegment` objects with the left channel at index 0 and the right channel at index 1.
### AudioSegment(…).apply_gain_stereo()
```python
from pydub import AudioSegment
sound1 = AudioSegment.from_file("sound1.wav")
# make left channel 6dB quieter and right channe 2dB louder
stereo_balance_adjusted = sound1.apply_gain_stereo(-6, +2)
```
Apply gain to the left and right channel of a stereo `AudioSegment`. If the `AudioSegment` is mono, it will be converted to stereo before applying the gain.
Both gain arguments are specified in dB.
### AudioSegment(…).pan()
```python
from pydub import AudioSegment
sound1 = AudioSegment.from_file("sound1.wav")
# pan the sound 15% to the right
panned_right = sound1.pan(+0.15)
# pan the sound 50% to the left
panned_left = sound1.pan(-0.50)
```
Takes one positional argument, *pan amount*, which should be between -1.0 (100% left) and +1.0 (100% right)
When pan_amount == 0.0 the left/right balance is not changed.
Panning does not alter the *perceived* loundness, but since loudness
is decreasing on one side, the other side needs to get louder to
compensate. When panned hard left, the left channel will be 3dB louder and
the right channel will be silent (and vice versa).
### AudioSegment(…).get_array_of_samples()
Returns the raw audio data as an array of (numeric) samples. Note: if the audio has multiple channels, the samples for each channel will be serialized  for example, stereo audio would look like `[sample_1_L, sample_1_R, sample_2_L, sample_2_R, …]`.
This method is mainly for use in implementing effects, and other processing.
```python
from pydub import AudioSegment
sound = AudioSegment.from_file(sound1.wav)
samples = sound.get_array_of_samples()
# then modify samples...
new_sound = sound._spawn(samples)
```
note that when using numpy or scipy you will need to convert back to an array before you spawn:
```python
import array
import numpy as np
from pydub import AudioSegment
sound = AudioSegment.from_file(sound1.wav)
samples = sound.get_array_of_samples()
# Example operation on audio data
shifted_samples = np.right_shift(samples, 1)
# now you have to convert back to an array.array
shifted_samples_array = array.array(sound.array_type, shifted_samples)
new_sound = sound._spawn(shifted_samples_array)
```
Here's how to convert to a numpy float32 array:
```python
import numpy as np
from pydub import AudioSegment
sound = AudioSegment.from_file("sound1.wav")
sound = sound.set_frame_rate(16000)
channel_sounds = sound.split_to_mono()
samples = [s.get_array_of_samples() for s in channel_sounds]
fp_arr = np.array(samples).T.astype(np.float32)
fp_arr /= np.iinfo(samples[0].typecode).max
```
And how to convert it back to an AudioSegment:
```python
import io
import scipy.io.wavfile
wav_io = io.BytesIO()
scipy.io.wavfile.write(wav_io, 16000, fp_arr)
wav_io.seek(0)
sound = pydub.AudioSegment.from_wav(wav_io)
```
### AudioSegment(…).get_dc_offset()
Returns a value between -1.0 and 1.0 representing the DC offset of a channel. This is calculated using `audioop.avg()` and normalizing the result by samples max value.
**Supported keyword arguments**:
- `channel` | example: `2` | default: `1`
Selects left (1) or right (2) channel to calculate DC offset. If segment is mono, this value is ignored.
### AudioSegment(…).remove_dc_offset()
Removes DC offset from channel(s). This is done by using `audioop.bias()`, so watch out for overflows.
**Supported keyword arguments**:
- `channel` | example: `2` | default: None
Selects left (1) or right (2) channel remove DC offset. If value if None, removes from all available channels. If segment is mono, this value is ignored.
- `offset` | example: `-0.1` | default: None
Offset to be removed from channel(s). Calculates offset if it's None. Offset values must be between -1.0 and 1.0.
## Effects
Collection of DSP effects that are implemented by `AudioSegment` objects.
### AudioSegment(…).invert_phase()
Make a copy of this `AudioSegment` and inverts the phase of the signal. Can generate anti-phase waves for noise suppression or cancellation.
## Silence
Various functions for finding/manipulating silence in AudioSegments. For creating silent AudioSegments, see AudioSegment.silent().
### silence.detect_silence()
Returns a list of all silent sections [start, end] in milliseconds of audio_segment. Inverse of detect_nonsilent(). Can be very slow since it has to iterate over the whole segment.
```python
from pydub import AudioSegment, silence
print(silence.detect_silence(AudioSegment.silent(2000)))
# [[0, 2000]]
```
**Supported keyword arguments**:
- `min_silence_len` | example: `500` | default: 1000
The minimum length for silent sections in milliseconds. If it is greater than the length of the audio segment an empty list will be returned.
- `silence_thresh` | example: `-20` | default: -16
The upper bound for how quiet is silent in dBFS.
- `seek_step` | example: `5` | default: 1
Size of the step for checking for silence in milliseconds. Smaller is more precise. Must be a positive whole number.
### silence.detect_nonsilent()
Returns a list of all silent sections [start, end] in milliseconds of audio_segment. Inverse of detect_silence() and has all the same arguments. Can be very slow since it has to iterate over the whole segment.
**Supported keyword arguments**:
- `min_silence_len` | example: `500` | default: 1000
The minimum length for silent sections in milliseconds. If it is greater than the length of the audio segment an empty list will be returned.
- `silence_thresh` | example: `-20` | default: -16
The upper bound for how quiet is silent in dBFS.
- `seek_step` | example: `5` | default: 1
Size of the step for checking for silence in milliseconds. Smaller is more precise. Must be a positive whole number.
### silence.split_on_silence()
Returns list of audio segments from splitting audio_segment on silent sections.
**Supported keyword arguments**:
- `min_silence_len` | example: `500` | default: 1000
The minimum length for silent sections in milliseconds. If it is greater than the length of the audio segment an empty list will be returned.
- `silence_thresh` | example: `-20` | default: -16
The upper bound for how quiet is silent in dBFS.
- `seek_step` | example: `5` | default: 1
Size of the step for checking for silence in milliseconds. Smaller is more precise. Must be a positive whole number.
- `keep_silence` ~ example: True | default: 100
How much silence to keep in ms or a bool. leave some silence at the beginning and end of the chunks. Keeps the sound from sounding like it is abruptly cut off.
When the length of the silence is less than the keep_silence duration it is split evenly between the preceding and following non-silent segments.
If True is specified, all the silence is kept, if False none is kept.
### silence.detect_leading_silence()
Returns the millisecond/index that the leading silence ends. If there is no end it will return the length of the audio_segment.
```python
from pydub import AudioSegment, silence
print(silence.detect_silence(AudioSegment.silent(2000)))
# 2000
```
**Supported keyword arguments**:
- `silence_thresh` | example: `-20` | default: -50
The upper bound for how quiet is silent in dBFS.
- `chunk_size` | example: `5` | default: 10
Size of the step for checking for silence in milliseconds. Smaller is more precise. Must be a positive whole number.
-101
View File
@@ -1,101 +0,0 @@
James Robert
github: jiaaro
twitter: @jiaaro
web: jiaaro.com
email: pydub@jiaaro.com
Marc Webbie
github: marcwebbie
Jean-philippe Serafin
github: jeanphix
Anurag Ramdasan
github: AnuragRamdasan
Choongmin Lee
github: clee704
Patrick Pittman
github: ptpittman
Hunter Lang
github: hunterlang
Alexey
github: nihisil
Jaymz Campbell
github: jaymzcd
Ross McFarland
github: ross
John McMellen
github: jmcmellen
Johan Lövgren
github: dashj
Joachim Krüger
github: jkrgr
Shichao An
github: shichao-an
Michael Bortnyck
github: mbortnyck
André Cloete
github: aj-cloete
David Acacio
github: dacacioa
Thiago Abdnur
github: bolaum
Aurélien Ooms
github: aureooms
Mike Mattozzi
github: mmattozzi
Marcio Mazza
github: marciomazza
Sungsu Lim
github: proflim
Evandro Myller
github: emyller
Sérgio Agostinho
github: SergioRAgostinho
Antonio Larrosa
github: antlarr
Aaron Craig
github: craigthelinguist
Carlos del Castillo
github: greyalien502
Yudong Sun
github: sunjerry019
Jorge Perianez
github: JPery
Chendi Luo
github: Creonalia
Daniel Lefevre
gitHub: dplefevre
Grzegorz Kotfis
github: gkotfis
Pål Orby
github: orby
-168
View File
@@ -1,168 +0,0 @@
# v0.25.1
- Fix crashing bug in new scipy-powered EQ effects
# v0.25.0
- Don't show a runtime warning about the optional ffplay dependency being missing until someone trys to use it
- Documentation improvements
- Python 3.9 support
- Improved efficiency of loading wave files with `pydub.AudioSegment.from_file()`
- Ensure `pydub.AudioSegment().export()` always retuns files with a seek position at the beginning of the file
- Added more EQ effects to `pydub.scipy_effects` (requires scipy to be installed)
- Fix a packaging bug where the LICENSE file was not included in the source distribution
- Add a way to instantiate a `pydub.AudioSegment()` with a portion of an audio file via `pydub.AudioSegment().from_file()`
# v0.24.1
- Fix bug where ffmpeg errors in Python 3 are illegible
- Fix bug where `split_on_silence` fails when there are one or fewer nonsilent segments
- Fix bug in fallback audioop implementation
# v0.24.0
- Fix inconsistent handling of 8-bit audio
- Fix bug where certain files will fail to parse
- Fix bug where pyaudio stream is not closed on error
- Allow codecs and parameters in wav and raw export
- Fix bug in `pydub.AudioSegment.from_file` where supplied codec is ignored
- Allow `pydub.silence.split_on_silence` to take a boolean for `keep_silence`
- Fix bug where `pydub.silence.split_on_silence` sometimes adds non-silence from adjacent segments
- Fix bug where `pydub.AudioSegment.extract_wav_headers` fails on empty wav files
- Add new function `pydub.silence.detect_leading_silence`
- Support conversion between an arbitrary number of channels and mono in `pydub.AudioSegment.set_channels`
- Fix several issues related to reading from filelike objects
# v0.23.1
- Fix bug in passing ffmpeg/avconv parameters for `pydub.AudioSegment.from_mp3()`, `pydub.AudioSegment.from_flv()`, `pydub.AudioSegment.from_ogg()`, and `pydub.AudioSegment.from_wav()`
- Fix logic bug in `pydub.effects.strip_silence()`
# v0.23.0
- Add support for playback via simpleaudio
- Allow users to override the type in `pydub.AudioSegment().get_array_of_samples()` (PR #313)
- Fix a bug where the wrong codec was used for 8-bit audio (PR #309 - issue #308)
# v0.22.1
- Fix `pydub.utils.mediainfo_json()` to work with newer, backwards-incompatible versions of ffprobe/avprobe
# v0.22.0
- Adds support for audio with frame rates (sample rates) of 48k and higher (requires scipy) (PR #262, fixes #134, #237, #209)
- Adds support for PEP 519 File Path protocol (PR #252)
- Fixes a few places where handles to temporary files are kept open (PR #280)
- Add the license file to the python package to aid other packaging projects (PR #279, fixes #274)
- Big fix for `pydub.silence.detect_silence()` (PR #263)
# v0.21.0
- NOTE: Semi-counterintuitive change: using the a stride when slicing AudioSegment instances (for example, `sound[::5000]`) will return chunks of 5000ms (not 1ms chunks every 5000ms) (#222)
- Debug output from ffmpeg/avlib is no longer printed to the console unless you set up logging (see README for how to set up logging for your converter) (#223)
- All pydub exceptions are now subclasses of `pydub.exceptions.PydubException` (PR #244)
- The utilities in `pydub.silence` now accept a `seek_step`argument which can optionally be passed to improve the performance of silence detection (#211)
- Fix to `pydub.silence` utilities which allow you to detect perfect silence (#233)
- Fix a bug where threaded code screws up your terminal session due to ffmpeg inheriting the stdin from the parent process. (#231)
- Fix a bug where a crashing programs using pydub would leave behind their temporary files (#206)
# v0.20.0
- Add new parameter `gain_during_overlay` to `pydub.AudioSegment.overlay` which allows users to adjust the volume of the target AudioSegment during the portion of the segment which is overlaid with the additional AudioSegment.
- `pydub.playback.play()` No longer displays the (very verbose) playback "banner" when using ffplay
- Fix a confusing error message when using invalid crossfade durations (issue #193)
# v0.19.0
- Allow codec and ffmpeg/avconv parameters to be set in the `pydub.AudioSegment.from_file()` for more control while decoding audio files
- Allow `AudioSegment` objects with more than two channels to be split using `pydub.AudioSegment().split_to_mono()`
- Add support for inverting the phase of only one channel in a multi-channel `pydub.AudioSegment` object
- Fix a bug with the latest avprobe that broke `pydub.utils.mediainfo()`
- Add tests for webm encoding/decoding
# v0.18.0
- Add a new constructor: `pydub.AudioSegment.from_mono_audiosegments()` which allows users to create a multi-channel audiosegment out of multiple mono ones.
- Refactor `pydub.AudioSegment._sync()` to support an arbitrary number of audiosegment arguments.
# v0.17.0
- Add the ability to add a cover image to MP3 exports via the `cover` keyword argument to `pydub.AudioSegment().export()`
- Add `pydub.AudioSegment().get_dc_offset()` and `pydub.AudioSegment().remove_dc_offset()` which allow detection and removal of DC offset in audio files.
- Minor fixes for windows users
# v0.16.7
- Make `pydub.AudioSegment()._spawn()` accept array.array instances containing audio samples
# v0.16.6
- Make `pydub.AudioSegment()` objects playable inline in ipython notebooks.
- Add scipy powered high pass, low pass, and band pass filters, which can be high order filters (they take `order` as a keyword argument). They are used for `pydub.AudioSegment().high_pass_filter()`, `pydub.AudioSegment().low_pass_filter()`, `pydub.AudioSegment().band_pass_filter()` when the `pydub.scipy_effects` module is imported.
- Fix minor bug in `pydub.silence.detect_silence()`
# v0.16.5
- Update `pydub.AudioSegment()._spawn()` method to allow user subclassing of `pydub.AudioSegment`
- Add a workaround for incorrect duration reporting of some mp3 files on macOS
# v0.16.4
- Add support for radd (basically, allow `sum()` to operate on an iterable of `pydub.AudioSegment()` objects)
- Fix bug in 24-bit wav support (understatement. It didn't work right at all the first time)
# v0.16.3
- Add support for python 3.5 (overstatement. We just added python 3.5 to CI and it worked 😄)
- Add native support for 24-bit wav files (ffmpeg/avconv not required)
# v0.16.2
- Fix bug where you couldn't directly instantiate `pydub.AudioSegment` with `bytes` data in python 3
# v0.16.1
- pydub will use any ffmpeg/avconv binary that's in the current directory (as reported by `os.getcwd()`) before searching for a system install
# v0.16.0
- Make it easier to instantiate `pydub.AudioSegment()` directly when creating audio segments from raw audio data (without having to write it to a file first)
- Add `pydub.AudioSegment().get_array_of_samples()` method which returns the samples which make up an audio segment (you should usually prefer this over `pydub.AudioSegment().raw_data`)
- Add `pydub.AudioSegment().raw_data` property which returns the raw audio data for an audio segment as a bytes (python 3) or a bytestring (python 3)
- Allow users to specify frame rate in `pydub.AudioSegment.silent()` constructor
# v0.15.0
- Add support for RAW audio (basically WAV format, but without wave headers)
- Add a new exception `pydub.exceptions.CouldntDecodeError` to indicate a failure of ffmpeg/avconv to decode a file (as indicated by ffmpeg/avconv exit code)
# v0.14.2
- Fix a bug in python 3.4 which failed to read wave files with no audio data (should have been audio segments with a duration of 0 ms)
# v0.14.1
- Fix a bug in `pydub.utils.mediainfo()` that caused inputs containing unescaped characters to raise a runtime error (inputs are not supposed to require escaping)
# v0.14.0
- Rename `pydub.AudioSegment().set_gain()` to `pydub.AudioSegment().apply_gain_stereo()` to better reflect it's place in the world (as a counterpart to `pydub.AudioSegment().apply_gain()`)
# v0.13.0
- Add `pydub.AudioSegment().pan()` which returns a new stereo audio segment panned left/right as specified.
# v0.12.0
- Add a logger, `"pydub.converter"` which logs the ffmpeg commands being run by pydub.
- Add `pydub.AudioSegment().split_to_mono()` method which returns a list of mono audio segments. One for each channel in the original audio segment.
- Fix a bug in `pydub.silence.detect_silence()` which caused the function to break when a silent audio segment was equal in length to the minimum silence length. It should report a single span of silence covering the whole silent audio segment. Now it does.
- Fix a bug where uncommon wav formats (those not supported by the stdlib wave module) would throw an exception rather than converting to a more common format via ffmpeg/avconv
# v0.11.0
- Add `pydub.AudioSegment().max_dBFS` which reports the loudness (in dBFS) of the loudest point (i.e., highest amplitude sample) of an audio segment
# v0.10.0
- Overhaul Documentation
- Improve performance of `pydub.AudioSegment().overlay()`
- Add `pydub.AudioSegment().invert_phase()` which (shocker) inverts the phase of an audio segment
- Fix a type error in `pydub.AudioSegment.get_sample_slice()`
# v0.9.5
- Add `pydub.generators` module containing simple signal generation functions (white noise, sine, square wave, etc)
- Add a `loops` keyword argument to `pydub.AudioSegment().overlay()` which allows users to specify that the overlaid audio should be repeated (i.e., looped) a certain number of times, or indefinitely
# 0.9.4
- Fix a bug in db_to_float() where all values were off by a factor of 2
# 0.9.3
- Allow users to set the location of their converter by setting `pydub.AudioSegment.converter = "/path/to/ffmpeg"` and added a shim to support the old method of assigning to `pydub.AudioSegment.ffmpeg` (which is deprecated now that we support avconv)
# v0.9.2
- Add support for Python 3.4
- Audio files opened with format "wave" are treated as "wav" and "m4a" are treated as "mp4"
- Add `pydub.silence` module with simple utilities for detecting and removing silence.
- Fix a bug affecting auto-detection of ffmpeg/avconv on windows.
- Fix a bug that caused pydub to only work when ffmpeg/avconv is present (it should be able to work with WAV data without any dependencies)
# v0.9.1
- Add a runtime warning when ffmpeg/avconv cannot be found to aid debugging
# v0.9.0
- Added support for pypy (by reimplementing audioop in python). Also, we contributed our implementation to the pypy project, so that's 💯
- Add support for avconv as an alternative to ffmpeg
- Add a new helper module `pydub.playback` which allows you to quickly listen to an audio segment using ffplay (or avplay)
- Add new function `pydub.utils.mediainfo('/path/to/audio/file.ext')` which reports back the results of ffprobe (or avprobe) including codec, bitrate, channels, etc
-45
View File
@@ -1,45 +0,0 @@
Pydub loves user contributions.
We are happy to merge Pull Requests for features and bug fixes, of course. But, also spelling corrections, PEP 8 conformity, and platform-specific fixes.
Don't be shy!
### How to contribute:
1. Fork [pydub on github](https://github.com/jiaaro/pydub)
2. Commit changes
3. Send a Pull Request
you did it!
don't forget to append your name to the AUTHORS file ;)
There _are_ a few things that will make your Pull Request more likely to be merged:
1. Maintain backward compatibility
2. Avoid new dependencies
3. Include tests (and make sure they pass)
4. Write a short description of **what** is changed and **why**
5. Keep your Pull Request small, and focused on fixing one thing.
Smaller is easier to review, and easier to understand.
If you want to fix spelling and PEP 8 violations, send two pull requests :)
### Want to pitch in?
Take a look at our issue tracker for anything tagged [`bug`][bugs] or [`todo`][todos] - these are goals of the project and your improvements are _very_ likely to be merged!
That being said, there are many possible contributions we haven't thought of already. Those are welcome too!
Here are some general topics of interest for future development:
- Make it easier to get started with pydub
- More/better audio effects
- Support more audio formats
- Improve handling of large audio files
- Make things faster and use less memory.
[bugs]: https://github.com/jiaaro/pydub/issues?q=is%3Aissue+is%3Aopen+label%3Abug
[todos]: https://github.com/jiaaro/pydub/issues?q=is%3Aissue+is%3Aopen+label%3Atodo
-20
View File
@@ -1,20 +0,0 @@
Copyright (c) 2011 James Robert, http://jiaaro.com
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-1
View File
@@ -1 +0,0 @@
include LICENSE
-333
View File
@@ -1,333 +0,0 @@
# Pydub [![Build Status](https://travis-ci.org/jiaaro/pydub.svg?branch=master)](https://travis-ci.org/jiaaro/pydub) [![Build status](https://ci.appveyor.com/api/projects/status/gy1ucp9o5khq7fqi/branch/master?svg=true)](https://ci.appveyor.com/project/jiaaro/pydub/branch/master)
Pydub lets you do stuff to audio in a way that isn't stupid.
**Stuff you might be looking for**:
- [Installing Pydub](https://github.com/jiaaro/pydub#installation)
- [API Documentation](https://github.com/jiaaro/pydub/blob/master/API.markdown)
- [Dependencies](https://github.com/jiaaro/pydub#dependencies)
- [Playback](https://github.com/jiaaro/pydub#playback)
- [Setting up ffmpeg](https://github.com/jiaaro/pydub#getting-ffmpeg-set-up)
- [Questions/Bugs](https://github.com/jiaaro/pydub#bugs--questions)
## Quickstart
Open a WAV file
```python
from pydub import AudioSegment
song = AudioSegment.from_wav("never_gonna_give_you_up.wav")
```
...or a mp3
```python
song = AudioSegment.from_mp3("never_gonna_give_you_up.mp3")
```
... or an ogg, or flv, or [anything else ffmpeg supports](http://www.ffmpeg.org/general.html#File-Formats)
```python
ogg_version = AudioSegment.from_ogg("never_gonna_give_you_up.ogg")
flv_version = AudioSegment.from_flv("never_gonna_give_you_up.flv")
mp4_version = AudioSegment.from_file("never_gonna_give_you_up.mp4", "mp4")
wma_version = AudioSegment.from_file("never_gonna_give_you_up.wma", "wma")
aac_version = AudioSegment.from_file("never_gonna_give_you_up.aiff", "aac")
```
Slice audio:
```python
# pydub does things in milliseconds
ten_seconds = 10 * 1000
first_10_seconds = song[:ten_seconds]
last_5_seconds = song[-5000:]
```
Make the beginning louder and the end quieter
```python
# boost volume by 6dB
beginning = first_10_seconds + 6
# reduce volume by 3dB
end = last_5_seconds - 3
```
Concatenate audio (add one file to the end of another)
```python
without_the_middle = beginning + end
```
How long is it?
```python
without_the_middle.duration_seconds == 15.0
```
AudioSegments are immutable
```python
# song is not modified
backwards = song.reverse()
```
Crossfade (again, beginning and end are not modified)
```python
# 1.5 second crossfade
with_style = beginning.append(end, crossfade=1500)
```
Repeat
```python
# repeat the clip twice
do_it_over = with_style * 2
```
Fade (note that you can chain operations because everything returns
an AudioSegment)
```python
# 2 sec fade in, 3 sec fade out
awesome = do_it_over.fade_in(2000).fade_out(3000)
```
Save the results (again whatever ffmpeg supports)
```python
awesome.export("mashup.mp3", format="mp3")
```
Save the results with tags (metadata)
```python
awesome.export("mashup.mp3", format="mp3", tags={'artist': 'Various artists', 'album': 'Best of 2011', 'comments': 'This album is awesome!'})
```
You can pass an optional bitrate argument to export using any syntax ffmpeg
supports.
```python
awesome.export("mashup.mp3", format="mp3", bitrate="192k")
```
Any further arguments supported by ffmpeg can be passed as a list in a
'parameters' argument, with switch first, argument second. Note that no
validation takes place on these parameters, and you may be limited by what
your particular build of ffmpeg/avlib supports.
```python
# Use preset mp3 quality 0 (equivalent to lame V0)
awesome.export("mashup.mp3", format="mp3", parameters=["-q:a", "0"])
# Mix down to two channels and set hard output volume
awesome.export("mashup.mp3", format="mp3", parameters=["-ac", "2", "-vol", "150"])
```
## Debugging
Most issues people run into are related to converting between formats using
ffmpeg/avlib. Pydub provides a logger that outputs the subprocess calls to
help you track down issues:
```python
>>> import logging
>>> l = logging.getLogger("pydub.converter")
>>> l.setLevel(logging.DEBUG)
>>> l.addHandler(logging.StreamHandler())
>>> AudioSegment.from_file("./test/data/test1.mp3")
subprocess.call(['ffmpeg', '-y', '-i', '/var/folders/71/42k8g72x4pq09tfp920d033r0000gn/T/tmpeZTgMy', '-vn', '-f', 'wav', '/var/folders/71/42k8g72x4pq09tfp920d033r0000gn/T/tmpK5aLcZ'])
<pydub.audio_segment.AudioSegment object at 0x101b43e10>
```
Don't worry about the temporary files used in the conversion. They're cleaned up
automatically.
## Bugs & Questions
You can file bugs in our [github issues tracker](https://github.com/jiaaro/pydub/issues),
and ask any technical questions on
[Stack Overflow using the pydub tag](http://stackoverflow.com/questions/ask?tags=pydub).
We keep an eye on both.
## Installation
Installing pydub is easy, but don't forget to install ffmpeg/avlib (the next section in this doc)
pip install pydub
Or install the latest dev version from github (or replace `@master` with a [release version like `@v0.12.0`](https://github.com/jiaaro/pydub/releases))…
pip install git+https://github.com/jiaaro/pydub.git@master
-OR-
git clone https://github.com/jiaaro/pydub.git
-OR-
Copy the pydub directory into your python path. Zip
[here](https://github.com/jiaaro/pydub/zipball/master)
## Dependencies
You can open and save WAV files with pure python. For opening and saving non-wav
files like mp3 you'll need [ffmpeg](http://www.ffmpeg.org/) or
[libav](http://libav.org/).
### Playback
You can play audio if you have one of these installed (simpleaudio _strongly_ recommended, even if you are installing ffmpeg/libav):
- [simpleaudio](https://simpleaudio.readthedocs.io/en/latest/)
- [pyaudio](https://people.csail.mit.edu/hubert/pyaudio/docs/#)
- ffplay (usually bundled with ffmpeg, see the next section)
- avplay (usually bundled with libav, see the next section)
```python
from pydub import AudioSegment
from pydub.playback import play
sound = AudioSegment.from_file("mysound.wav", format="wav")
play(sound)
```
## Getting ffmpeg set up
You may use **libav or ffmpeg**.
Mac (using [homebrew](http://brew.sh)):
```bash
# libav
brew install libav
#### OR #####
# ffmpeg
brew install ffmpeg
```
Linux (using aptitude):
```bash
# libav
apt-get install libav-tools libavcodec-extra
#### OR #####
# ffmpeg
apt-get install ffmpeg libavcodec-extra
```
Windows:
1. Download and extract libav from [Windows binaries provided here](http://builds.libav.org/windows/).
2. Add the libav `/bin` folder to your PATH envvar
3. `pip install pydub`
## Important Notes
`AudioSegment` objects are [immutable](http://www.devshed.com/c/a/Python/String-and-List-Python-Object-Types/1/)
### Ogg exporting and default codecs
The Ogg specification ([http://tools.ietf.org/html/rfc5334](rfc5334)) does not specify
the codec to use, this choice is left up to the user. Vorbis and Theora are just
some of a number of potential codecs (see page 3 of the rfc) that can be used for the
encapsulated data.
When no codec is specified exporting to `ogg` will _default_ to using `vorbis`
as a convenience. That is:
```python
from pydub import AudioSegment
song = AudioSegment.from_mp3("test/data/test1.mp3")
song.export("out.ogg", format="ogg") # Is the same as:
song.export("out.ogg", format="ogg", codec="libvorbis")
```
## Example Use
Suppose you have a directory filled with *mp4* and *flv* videos and you want to convert all of them to *mp3* so you can listen to them on your mp3 player.
```python
import os
import glob
from pydub import AudioSegment
video_dir = '/home/johndoe/downloaded_videos/' # Path where the videos are located
extension_list = ('*.mp4', '*.flv')
os.chdir(video_dir)
for extension in extension_list:
for video in glob.glob(extension):
mp3_filename = os.path.splitext(os.path.basename(video))[0] + '.mp3'
AudioSegment.from_file(video).export(mp3_filename, format='mp3')
```
### How about another example?
```python
from glob import glob
from pydub import AudioSegment
playlist_songs = [AudioSegment.from_mp3(mp3_file) for mp3_file in glob("*.mp3")]
first_song = playlist_songs.pop(0)
# let's just include the first 30 seconds of the first song (slicing
# is done by milliseconds)
beginning_of_song = first_song[:30*1000]
playlist = beginning_of_song
for song in playlist_songs:
# We don't want an abrupt stop at the end, so let's do a 10 second crossfades
playlist = playlist.append(song, crossfade=(10 * 1000))
# let's fade out the end of the last song
playlist = playlist.fade_out(30)
# hmm I wonder how long it is... ( len(audio_segment) returns milliseconds )
playlist_length = len(playlist) / (1000*60)
# lets save it!
with open("%s_minute_playlist.mp3" % playlist_length, 'wb') as out_f:
playlist.export(out_f, format='mp3')
```
## License ([MIT License](http://opensource.org/licenses/mit-license.php))
Copyright © 2011 James Robert, http://jiaaro.com
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-30
View File
@@ -1,30 +0,0 @@
build: false
environment:
matrix:
- PYTHON: "C:/Python27"
FFMPEG: "4.2.3"
- PYTHON: "C:/Python34"
FFMPEG: "4.2.3"
- PYTHON: "C:/Python35"
FFMPEG: "4.2.3"
- PYTHON: "C:/Python36"
FFMPEG: "4.2.3"
- PYTHON: "C:/Python36"
FFMPEG: "latest"
matrix:
allow_failures:
- FFMPEG: "latest"
init:
- "ECHO %PYTHON%"
- ps: "ls C:/Python*"
install:
- "%PYTHON%/python.exe -m pip install wheel"
- "%PYTHON%/python.exe -m pip install -e ."
# Install ffmpeg
- ps: Start-FileDownload ('https://github.com/advancedfx/ffmpeg.zeranoe.com-builds-mirror/releases/download/20200915/ffmpeg-' + $env:FFMPEG + '-win64-shared.zip') ffmpeg-shared.zip
- 7z x ffmpeg-shared.zip > NULL
- "SET PATH=%cd%\\ffmpeg-%FFMPEG%-win64-shared\\bin;%PATH%"
# check ffmpeg installation (also shows version)
- "ffmpeg.exe -version"
test_script:
- "%PYTHON%/python.exe test/test.py"
@@ -1 +0,0 @@
from .audio_segment import AudioSegment
File diff suppressed because it is too large Load Diff
-341
View File
@@ -1,341 +0,0 @@
import sys
import math
import array
from .utils import (
db_to_float,
ratio_to_db,
register_pydub_effect,
make_chunks,
audioop,
get_min_max_value
)
from .silence import split_on_silence
from .exceptions import TooManyMissingFrames, InvalidDuration
if sys.version_info >= (3, 0):
xrange = range
@register_pydub_effect
def apply_mono_filter_to_each_channel(seg, filter_fn):
n_channels = seg.channels
channel_segs = seg.split_to_mono()
channel_segs = [filter_fn(channel_seg) for channel_seg in channel_segs]
out_data = seg.get_array_of_samples()
for channel_i, channel_seg in enumerate(channel_segs):
for sample_i, sample in enumerate(channel_seg.get_array_of_samples()):
index = (sample_i * n_channels) + channel_i
out_data[index] = sample
return seg._spawn(out_data)
@register_pydub_effect
def normalize(seg, headroom=0.1):
"""
headroom is how close to the maximum volume to boost the signal up to (specified in dB)
"""
peak_sample_val = seg.max
# if the max is 0, this audio segment is silent, and can't be normalized
if peak_sample_val == 0:
return seg
target_peak = seg.max_possible_amplitude * db_to_float(-headroom)
needed_boost = ratio_to_db(target_peak / peak_sample_val)
return seg.apply_gain(needed_boost)
@register_pydub_effect
def speedup(seg, playback_speed=1.5, chunk_size=150, crossfade=25):
# we will keep audio in 150ms chunks since one waveform at 20Hz is 50ms long
# (20 Hz is the lowest frequency audible to humans)
# portion of AUDIO TO KEEP. if playback speed is 1.25 we keep 80% (0.8) and
# discard 20% (0.2)
atk = 1.0 / playback_speed
if playback_speed < 2.0:
# throwing out more than half the audio - keep 50ms chunks
ms_to_remove_per_chunk = int(chunk_size * (1 - atk) / atk)
else:
# throwing out less than half the audio - throw out 50ms chunks
ms_to_remove_per_chunk = int(chunk_size)
chunk_size = int(atk * chunk_size / (1 - atk))
# the crossfade cannot be longer than the amount of audio we're removing
crossfade = min(crossfade, ms_to_remove_per_chunk - 1)
# DEBUG
#print("chunk: {0}, rm: {1}".format(chunk_size, ms_to_remove_per_chunk))
chunks = make_chunks(seg, chunk_size + ms_to_remove_per_chunk)
if len(chunks) < 2:
raise Exception("Could not speed up AudioSegment, it was too short {2:0.2f}s for the current settings:\n{0}ms chunks at {1:0.1f}x speedup".format(
chunk_size, playback_speed, seg.duration_seconds))
# we'll actually truncate a bit less than we calculated to make up for the
# crossfade between chunks
ms_to_remove_per_chunk -= crossfade
# we don't want to truncate the last chunk since it is not guaranteed to be
# the full chunk length
last_chunk = chunks[-1]
chunks = [chunk[:-ms_to_remove_per_chunk] for chunk in chunks[:-1]]
out = chunks[0]
for chunk in chunks[1:]:
out = out.append(chunk, crossfade=crossfade)
out += last_chunk
return out
@register_pydub_effect
def strip_silence(seg, silence_len=1000, silence_thresh=-16, padding=100):
if padding > silence_len:
raise InvalidDuration("padding cannot be longer than silence_len")
chunks = split_on_silence(seg, silence_len, silence_thresh, padding)
crossfade = padding / 2
if not len(chunks):
return seg[0:0]
seg = chunks[0]
for chunk in chunks[1:]:
seg = seg.append(chunk, crossfade=crossfade)
return seg
@register_pydub_effect
def compress_dynamic_range(seg, threshold=-20.0, ratio=4.0, attack=5.0, release=50.0):
"""
Keyword Arguments:
threshold - default: -20.0
Threshold in dBFS. default of -20.0 means -20dB relative to the
maximum possible volume. 0dBFS is the maximum possible value so
all values for this argument sould be negative.
ratio - default: 4.0
Compression ratio. Audio louder than the threshold will be
reduced to 1/ratio the volume. A ratio of 4.0 is equivalent to
a setting of 4:1 in a pro-audio compressor like the Waves C1.
attack - default: 5.0
Attack in milliseconds. How long it should take for the compressor
to kick in once the audio has exceeded the threshold.
release - default: 50.0
Release in milliseconds. How long it should take for the compressor
to stop compressing after the audio has falled below the threshold.
For an overview of Dynamic Range Compression, and more detailed explanation
of the related terminology, see:
http://en.wikipedia.org/wiki/Dynamic_range_compression
"""
thresh_rms = seg.max_possible_amplitude * db_to_float(threshold)
look_frames = int(seg.frame_count(ms=attack))
def rms_at(frame_i):
return seg.get_sample_slice(frame_i - look_frames, frame_i).rms
def db_over_threshold(rms):
if rms == 0: return 0.0
db = ratio_to_db(rms / thresh_rms)
return max(db, 0)
output = []
# amount to reduce the volume of the audio by (in dB)
attenuation = 0.0
attack_frames = seg.frame_count(ms=attack)
release_frames = seg.frame_count(ms=release)
for i in xrange(int(seg.frame_count())):
rms_now = rms_at(i)
# with a ratio of 4.0 this means the volume will exceed the threshold by
# 1/4 the amount (of dB) that it would otherwise
max_attenuation = (1 - (1.0 / ratio)) * db_over_threshold(rms_now)
attenuation_inc = max_attenuation / attack_frames
attenuation_dec = max_attenuation / release_frames
if rms_now > thresh_rms and attenuation <= max_attenuation:
attenuation += attenuation_inc
attenuation = min(attenuation, max_attenuation)
else:
attenuation -= attenuation_dec
attenuation = max(attenuation, 0)
frame = seg.get_frame(i)
if attenuation != 0.0:
frame = audioop.mul(frame,
seg.sample_width,
db_to_float(-attenuation))
output.append(frame)
return seg._spawn(data=b''.join(output))
# Invert the phase of the signal.
@register_pydub_effect
def invert_phase(seg, channels=(1, 1)):
"""
channels- specifies which channel (left or right) to reverse the phase of.
Note that mono AudioSegments will become stereo.
"""
if channels == (1, 1):
inverted = audioop.mul(seg._data, seg.sample_width, -1.0)
return seg._spawn(data=inverted)
else:
if seg.channels == 2:
left, right = seg.split_to_mono()
else:
raise Exception("Can't implicitly convert an AudioSegment with " + str(seg.channels) + " channels to stereo.")
if channels == (1, 0):
left = left.invert_phase()
else:
right = right.invert_phase()
return seg.from_mono_audiosegments(left, right)
# High and low pass filters based on implementation found on Stack Overflow:
# http://stackoverflow.com/questions/13882038/implementing-simple-high-and-low-pass-filters-in-c
@register_pydub_effect
def low_pass_filter(seg, cutoff):
"""
cutoff - Frequency (in Hz) where higher frequency signal will begin to
be reduced by 6dB per octave (doubling in frequency) above this point
"""
RC = 1.0 / (cutoff * 2 * math.pi)
dt = 1.0 / seg.frame_rate
alpha = dt / (RC + dt)
original = seg.get_array_of_samples()
filteredArray = array.array(seg.array_type, original)
frame_count = int(seg.frame_count())
last_val = [0] * seg.channels
for i in range(seg.channels):
last_val[i] = filteredArray[i] = original[i]
for i in range(1, frame_count):
for j in range(seg.channels):
offset = (i * seg.channels) + j
last_val[j] = last_val[j] + (alpha * (original[offset] - last_val[j]))
filteredArray[offset] = int(last_val[j])
return seg._spawn(data=filteredArray)
@register_pydub_effect
def high_pass_filter(seg, cutoff):
"""
cutoff - Frequency (in Hz) where lower frequency signal will begin to
be reduced by 6dB per octave (doubling in frequency) below this point
"""
RC = 1.0 / (cutoff * 2 * math.pi)
dt = 1.0 / seg.frame_rate
alpha = RC / (RC + dt)
minval, maxval = get_min_max_value(seg.sample_width * 8)
original = seg.get_array_of_samples()
filteredArray = array.array(seg.array_type, original)
frame_count = int(seg.frame_count())
last_val = [0] * seg.channels
for i in range(seg.channels):
last_val[i] = filteredArray[i] = original[i]
for i in range(1, frame_count):
for j in range(seg.channels):
offset = (i * seg.channels) + j
offset_minus_1 = ((i-1) * seg.channels) + j
last_val[j] = alpha * (last_val[j] + original[offset] - original[offset_minus_1])
filteredArray[offset] = int(min(max(last_val[j], minval), maxval))
return seg._spawn(data=filteredArray)
@register_pydub_effect
def pan(seg, pan_amount):
"""
pan_amount should be between -1.0 (100% left) and +1.0 (100% right)
When pan_amount == 0.0 the left/right balance is not changed.
Panning does not alter the *perceived* loundness, but since loudness
is decreasing on one side, the other side needs to get louder to
compensate. When panned hard left, the left channel will be 3dB louder.
"""
if not -1.0 <= pan_amount <= 1.0:
raise ValueError("pan_amount should be between -1.0 (100% left) and +1.0 (100% right)")
max_boost_db = ratio_to_db(2.0)
boost_db = abs(pan_amount) * max_boost_db
boost_factor = db_to_float(boost_db)
reduce_factor = db_to_float(max_boost_db) - boost_factor
reduce_db = ratio_to_db(reduce_factor)
# Cut boost in half (max boost== 3dB) - in reality 2 speakers
# do not sum to a full 6 dB.
boost_db = boost_db / 2.0
if pan_amount < 0:
return seg.apply_gain_stereo(boost_db, reduce_db)
else:
return seg.apply_gain_stereo(reduce_db, boost_db)
@register_pydub_effect
def apply_gain_stereo(seg, left_gain=0.0, right_gain=0.0):
"""
left_gain - amount of gain to apply to the left channel (in dB)
right_gain - amount of gain to apply to the right channel (in dB)
note: mono audio segments will be converted to stereo
"""
if seg.channels == 1:
left = right = seg
elif seg.channels == 2:
left, right = seg.split_to_mono()
l_mult_factor = db_to_float(left_gain)
r_mult_factor = db_to_float(right_gain)
left_data = audioop.mul(left._data, left.sample_width, l_mult_factor)
left_data = audioop.tostereo(left_data, left.sample_width, 1, 0)
right_data = audioop.mul(right._data, right.sample_width, r_mult_factor)
right_data = audioop.tostereo(right_data, right.sample_width, 0, 1)
output = audioop.add(left_data, right_data, seg.sample_width)
return seg._spawn(data=output,
overrides={'channels': 2,
'frame_width': 2 * seg.sample_width})
@@ -1,32 +0,0 @@
class PydubException(Exception):
"""
Base class for any Pydub exception
"""
class TooManyMissingFrames(PydubException):
pass
class InvalidDuration(PydubException):
pass
class InvalidTag(PydubException):
pass
class InvalidID3TagVersion(PydubException):
pass
class CouldntDecodeError(PydubException):
pass
class CouldntEncodeError(PydubException):
pass
class MissingAudioParameter(PydubException):
pass
@@ -1,142 +0,0 @@
"""
Each generator will return float samples from -1.0 to 1.0, which can be
converted to actual audio with 8, 16, 24, or 32 bit depth using the
SiganlGenerator.to_audio_segment() method (on any of it's subclasses).
See Wikipedia's "waveform" page for info on some of the generators included
here: http://en.wikipedia.org/wiki/Waveform
"""
import math
import array
import itertools
import random
from .audio_segment import AudioSegment
from .utils import (
db_to_float,
get_frame_width,
get_array_type,
get_min_max_value
)
class SignalGenerator(object):
def __init__(self, sample_rate=44100, bit_depth=16):
self.sample_rate = sample_rate
self.bit_depth = bit_depth
def to_audio_segment(self, duration=1000.0, volume=0.0):
"""
Duration in milliseconds
(default: 1 second)
Volume in DB relative to maximum amplitude
(default 0.0 dBFS, which is the maximum value)
"""
minval, maxval = get_min_max_value(self.bit_depth)
sample_width = get_frame_width(self.bit_depth)
array_type = get_array_type(self.bit_depth)
gain = db_to_float(volume)
sample_count = int(self.sample_rate * (duration / 1000.0))
sample_data = (int(val * maxval * gain) for val in self.generate())
sample_data = itertools.islice(sample_data, 0, sample_count)
data = array.array(array_type, sample_data)
try:
data = data.tobytes()
except:
data = data.tostring()
return AudioSegment(data=data, metadata={
"channels": 1,
"sample_width": sample_width,
"frame_rate": self.sample_rate,
"frame_width": sample_width,
})
def generate(self):
raise NotImplementedError("SignalGenerator subclasses must implement the generate() method, and *should not* call the superclass implementation.")
class Sine(SignalGenerator):
def __init__(self, freq, **kwargs):
super(Sine, self).__init__(**kwargs)
self.freq = freq
def generate(self):
sine_of = (self.freq * 2 * math.pi) / self.sample_rate
sample_n = 0
while True:
yield math.sin(sine_of * sample_n)
sample_n += 1
class Pulse(SignalGenerator):
def __init__(self, freq, duty_cycle=0.5, **kwargs):
super(Pulse, self).__init__(**kwargs)
self.freq = freq
self.duty_cycle = duty_cycle
def generate(self):
sample_n = 0
# in samples
cycle_length = self.sample_rate / float(self.freq)
pulse_length = cycle_length * self.duty_cycle
while True:
if (sample_n % cycle_length) < pulse_length:
yield 1.0
else:
yield -1.0
sample_n += 1
class Square(Pulse):
def __init__(self, freq, **kwargs):
kwargs['duty_cycle'] = 0.5
super(Square, self).__init__(freq, **kwargs)
class Sawtooth(SignalGenerator):
def __init__(self, freq, duty_cycle=1.0, **kwargs):
super(Sawtooth, self).__init__(**kwargs)
self.freq = freq
self.duty_cycle = duty_cycle
def generate(self):
sample_n = 0
# in samples
cycle_length = self.sample_rate / float(self.freq)
midpoint = cycle_length * self.duty_cycle
ascend_length = midpoint
descend_length = cycle_length - ascend_length
while True:
cycle_position = sample_n % cycle_length
if cycle_position < midpoint:
yield (2 * cycle_position / ascend_length) - 1.0
else:
yield 1.0 - (2 * (cycle_position - midpoint) / descend_length)
sample_n += 1
class Triangle(Sawtooth):
def __init__(self, freq, **kwargs):
kwargs['duty_cycle'] = 0.5
super(Triangle, self).__init__(freq, **kwargs)
class WhiteNoise(SignalGenerator):
def generate(self):
while True:
yield (random.random() * 2) - 1.0
@@ -1,14 +0,0 @@
"""
"""
import logging
converter_logger = logging.getLogger("swingmusic.pydub.converter")
def log_conversion(conversion_command):
converter_logger.debug("subprocess.call(%s)", repr(conversion_command))
def log_subprocess_output(output):
if output:
for line in output.rstrip().splitlines():
converter_logger.debug('subprocess output: %s', line.rstrip())
@@ -1,71 +0,0 @@
"""
Support for playing AudioSegments. Pyaudio will be used if it's installed,
otherwise will fallback to ffplay. Pyaudio is a *much* nicer solution, but
is tricky to install. See my notes on installing pyaudio in a virtualenv (on
OSX 10.10): https://gist.github.com/jiaaro/9767512210a1d80a8a0d
"""
import subprocess
from tempfile import NamedTemporaryFile
from .utils import get_player_name, make_chunks
def _play_with_ffplay(seg):
PLAYER = get_player_name()
with NamedTemporaryFile("w+b", suffix=".wav") as f:
seg.export(f.name, "wav")
subprocess.call([PLAYER, "-nodisp", "-autoexit", "-hide_banner", f.name])
def _play_with_pyaudio(seg):
import pyaudio
p = pyaudio.PyAudio()
stream = p.open(format=p.get_format_from_width(seg.sample_width),
channels=seg.channels,
rate=seg.frame_rate,
output=True)
# Just in case there were any exceptions/interrupts, we release the resource
# So as not to raise OSError: Device Unavailable should play() be used again
try:
# break audio into half-second chunks (to allows keyboard interrupts)
for chunk in make_chunks(seg, 500):
stream.write(chunk._data)
finally:
stream.stop_stream()
stream.close()
p.terminate()
def _play_with_simpleaudio(seg):
import simpleaudio
return simpleaudio.play_buffer(
seg.raw_data,
num_channels=seg.channels,
bytes_per_sample=seg.sample_width,
sample_rate=seg.frame_rate
)
def play(audio_segment):
try:
playback = _play_with_simpleaudio(audio_segment)
try:
playback.wait_done()
except KeyboardInterrupt:
playback.stop()
except ImportError:
pass
else:
return
try:
_play_with_pyaudio(audio_segment)
return
except ImportError:
pass
else:
return
_play_with_ffplay(audio_segment)
-553
View File
@@ -1,553 +0,0 @@
try:
from __builtin__ import max as builtin_max
from __builtin__ import min as builtin_min
except ImportError:
from builtins import max as builtin_max
from builtins import min as builtin_min
import math
import struct
try:
from fractions import gcd
except ImportError: # Python 3.9+
from math import gcd
from ctypes import create_string_buffer
class error(Exception):
pass
def _check_size(size):
if size != 1 and size != 2 and size != 4:
raise error("Size should be 1, 2 or 4")
def _check_params(length, size):
_check_size(size)
if length % size != 0:
raise error("not a whole number of frames")
def _sample_count(cp, size):
return len(cp) / size
def _get_samples(cp, size, signed=True):
for i in range(_sample_count(cp, size)):
yield _get_sample(cp, size, i, signed)
def _struct_format(size, signed):
if size == 1:
return "b" if signed else "B"
elif size == 2:
return "h" if signed else "H"
elif size == 4:
return "i" if signed else "I"
def _get_sample(cp, size, i, signed=True):
fmt = _struct_format(size, signed)
start = i * size
end = start + size
return struct.unpack_from(fmt, buffer(cp)[start:end])[0]
def _put_sample(cp, size, i, val, signed=True):
fmt = _struct_format(size, signed)
struct.pack_into(fmt, cp, i * size, val)
def _get_maxval(size, signed=True):
if signed and size == 1:
return 0x7f
elif size == 1:
return 0xff
elif signed and size == 2:
return 0x7fff
elif size == 2:
return 0xffff
elif signed and size == 4:
return 0x7fffffff
elif size == 4:
return 0xffffffff
def _get_minval(size, signed=True):
if not signed:
return 0
elif size == 1:
return -0x80
elif size == 2:
return -0x8000
elif size == 4:
return -0x80000000
def _get_clipfn(size, signed=True):
maxval = _get_maxval(size, signed)
minval = _get_minval(size, signed)
return lambda val: builtin_max(min(val, maxval), minval)
def _overflow(val, size, signed=True):
minval = _get_minval(size, signed)
maxval = _get_maxval(size, signed)
if minval <= val <= maxval:
return val
bits = size * 8
if signed:
offset = 2**(bits-1)
return ((val + offset) % (2**bits)) - offset
else:
return val % (2**bits)
def getsample(cp, size, i):
_check_params(len(cp), size)
if not (0 <= i < len(cp) / size):
raise error("Index out of range")
return _get_sample(cp, size, i)
def max(cp, size):
_check_params(len(cp), size)
if len(cp) == 0:
return 0
return builtin_max(abs(sample) for sample in _get_samples(cp, size))
def minmax(cp, size):
_check_params(len(cp), size)
max_sample, min_sample = 0, 0
for sample in _get_samples(cp, size):
max_sample = builtin_max(sample, max_sample)
min_sample = builtin_min(sample, min_sample)
return min_sample, max_sample
def avg(cp, size):
_check_params(len(cp), size)
sample_count = _sample_count(cp, size)
if sample_count == 0:
return 0
return sum(_get_samples(cp, size)) / sample_count
def rms(cp, size):
_check_params(len(cp), size)
sample_count = _sample_count(cp, size)
if sample_count == 0:
return 0
sum_squares = sum(sample**2 for sample in _get_samples(cp, size))
return int(math.sqrt(sum_squares / sample_count))
def _sum2(cp1, cp2, length):
size = 2
total = 0
for i in range(length):
total += getsample(cp1, size, i) * getsample(cp2, size, i)
return total
def findfit(cp1, cp2):
size = 2
if len(cp1) % 2 != 0 or len(cp2) % 2 != 0:
raise error("Strings should be even-sized")
if len(cp1) < len(cp2):
raise error("First sample should be longer")
len1 = _sample_count(cp1, size)
len2 = _sample_count(cp2, size)
sum_ri_2 = _sum2(cp2, cp2, len2)
sum_aij_2 = _sum2(cp1, cp1, len2)
sum_aij_ri = _sum2(cp1, cp2, len2)
result = (sum_ri_2 * sum_aij_2 - sum_aij_ri * sum_aij_ri) / sum_aij_2
best_result = result
best_i = 0
for i in range(1, len1 - len2 + 1):
aj_m1 = _get_sample(cp1, size, i - 1)
aj_lm1 = _get_sample(cp1, size, i + len2 - 1)
sum_aij_2 += aj_lm1**2 - aj_m1**2
sum_aij_ri = _sum2(buffer(cp1)[i*size:], cp2, len2)
result = (sum_ri_2 * sum_aij_2 - sum_aij_ri * sum_aij_ri) / sum_aij_2
if result < best_result:
best_result = result
best_i = i
factor = _sum2(buffer(cp1)[best_i*size:], cp2, len2) / sum_ri_2
return best_i, factor
def findfactor(cp1, cp2):
size = 2
if len(cp1) % 2 != 0:
raise error("Strings should be even-sized")
if len(cp1) != len(cp2):
raise error("Samples should be same size")
sample_count = _sample_count(cp1, size)
sum_ri_2 = _sum2(cp2, cp2, sample_count)
sum_aij_ri = _sum2(cp1, cp2, sample_count)
return sum_aij_ri / sum_ri_2
def findmax(cp, len2):
size = 2
sample_count = _sample_count(cp, size)
if len(cp) % 2 != 0:
raise error("Strings should be even-sized")
if len2 < 0 or sample_count < len2:
raise error("Input sample should be longer")
if sample_count == 0:
return 0
result = _sum2(cp, cp, len2)
best_result = result
best_i = 0
for i in range(1, sample_count - len2 + 1):
sample_leaving_window = getsample(cp, size, i - 1)
sample_entering_window = getsample(cp, size, i + len2 - 1)
result -= sample_leaving_window**2
result += sample_entering_window**2
if result > best_result:
best_result = result
best_i = i
return best_i
def avgpp(cp, size):
_check_params(len(cp), size)
sample_count = _sample_count(cp, size)
prevextremevalid = False
prevextreme = None
avg = 0
nextreme = 0
prevval = getsample(cp, size, 0)
val = getsample(cp, size, 1)
prevdiff = val - prevval
for i in range(1, sample_count):
val = getsample(cp, size, i)
diff = val - prevval
if diff * prevdiff < 0:
if prevextremevalid:
avg += abs(prevval - prevextreme)
nextreme += 1
prevextremevalid = True
prevextreme = prevval
prevval = val
if diff != 0:
prevdiff = diff
if nextreme == 0:
return 0
return avg / nextreme
def maxpp(cp, size):
_check_params(len(cp), size)
sample_count = _sample_count(cp, size)
prevextremevalid = False
prevextreme = None
max = 0
prevval = getsample(cp, size, 0)
val = getsample(cp, size, 1)
prevdiff = val - prevval
for i in range(1, sample_count):
val = getsample(cp, size, i)
diff = val - prevval
if diff * prevdiff < 0:
if prevextremevalid:
extremediff = abs(prevval - prevextreme)
if extremediff > max:
max = extremediff
prevextremevalid = True
prevextreme = prevval
prevval = val
if diff != 0:
prevdiff = diff
return max
def cross(cp, size):
_check_params(len(cp), size)
crossings = 0
last_sample = 0
for sample in _get_samples(cp, size):
if sample <= 0 < last_sample or sample >= 0 > last_sample:
crossings += 1
last_sample = sample
return crossings
def mul(cp, size, factor):
_check_params(len(cp), size)
clip = _get_clipfn(size)
result = create_string_buffer(len(cp))
for i, sample in enumerate(_get_samples(cp, size)):
sample = clip(int(sample * factor))
_put_sample(result, size, i, sample)
return result.raw
def tomono(cp, size, fac1, fac2):
_check_params(len(cp), size)
clip = _get_clipfn(size)
sample_count = _sample_count(cp, size)
result = create_string_buffer(len(cp) / 2)
for i in range(0, sample_count, 2):
l_sample = getsample(cp, size, i)
r_sample = getsample(cp, size, i + 1)
sample = (l_sample * fac1) + (r_sample * fac2)
sample = clip(sample)
_put_sample(result, size, i / 2, sample)
return result.raw
def tostereo(cp, size, fac1, fac2):
_check_params(len(cp), size)
sample_count = _sample_count(cp, size)
result = create_string_buffer(len(cp) * 2)
clip = _get_clipfn(size)
for i in range(sample_count):
sample = _get_sample(cp, size, i)
l_sample = clip(sample * fac1)
r_sample = clip(sample * fac2)
_put_sample(result, size, i * 2, l_sample)
_put_sample(result, size, i * 2 + 1, r_sample)
return result.raw
def add(cp1, cp2, size):
_check_params(len(cp1), size)
if len(cp1) != len(cp2):
raise error("Lengths should be the same")
clip = _get_clipfn(size)
sample_count = _sample_count(cp1, size)
result = create_string_buffer(len(cp1))
for i in range(sample_count):
sample1 = getsample(cp1, size, i)
sample2 = getsample(cp2, size, i)
sample = clip(sample1 + sample2)
_put_sample(result, size, i, sample)
return result.raw
def bias(cp, size, bias):
_check_params(len(cp), size)
result = create_string_buffer(len(cp))
for i, sample in enumerate(_get_samples(cp, size)):
sample = _overflow(sample + bias, size)
_put_sample(result, size, i, sample)
return result.raw
def reverse(cp, size):
_check_params(len(cp), size)
sample_count = _sample_count(cp, size)
result = create_string_buffer(len(cp))
for i, sample in enumerate(_get_samples(cp, size)):
_put_sample(result, size, sample_count - i - 1, sample)
return result.raw
def lin2lin(cp, size, size2):
_check_params(len(cp), size)
_check_size(size2)
if size == size2:
return cp
new_len = (len(cp) / size) * size2
result = create_string_buffer(new_len)
for i in range(_sample_count(cp, size)):
sample = _get_sample(cp, size, i)
if size < size2:
sample = sample << (4 * size2 / size)
elif size > size2:
sample = sample >> (4 * size / size2)
sample = _overflow(sample, size2)
_put_sample(result, size2, i, sample)
return result.raw
def ratecv(cp, size, nchannels, inrate, outrate, state, weightA=1, weightB=0):
_check_params(len(cp), size)
if nchannels < 1:
raise error("# of channels should be >= 1")
bytes_per_frame = size * nchannels
frame_count = len(cp) / bytes_per_frame
if bytes_per_frame / nchannels != size:
raise OverflowError("width * nchannels too big for a C int")
if weightA < 1 or weightB < 0:
raise error("weightA should be >= 1, weightB should be >= 0")
if len(cp) % bytes_per_frame != 0:
raise error("not a whole number of frames")
if inrate <= 0 or outrate <= 0:
raise error("sampling rate not > 0")
d = gcd(inrate, outrate)
inrate /= d
outrate /= d
prev_i = [0] * nchannels
cur_i = [0] * nchannels
if state is None:
d = -outrate
else:
d, samps = state
if len(samps) != nchannels:
raise error("illegal state argument")
prev_i, cur_i = zip(*samps)
prev_i, cur_i = list(prev_i), list(cur_i)
q = frame_count / inrate
ceiling = (q + 1) * outrate
nbytes = ceiling * bytes_per_frame
result = create_string_buffer(nbytes)
samples = _get_samples(cp, size)
out_i = 0
while True:
while d < 0:
if frame_count == 0:
samps = zip(prev_i, cur_i)
retval = result.raw
# slice off extra bytes
trim_index = (out_i * bytes_per_frame) - len(retval)
retval = buffer(retval)[:trim_index]
return (retval, (d, tuple(samps)))
for chan in range(nchannels):
prev_i[chan] = cur_i[chan]
cur_i[chan] = samples.next()
cur_i[chan] = (
(weightA * cur_i[chan] + weightB * prev_i[chan])
/ (weightA + weightB)
)
frame_count -= 1
d += outrate
while d >= 0:
for chan in range(nchannels):
cur_o = (
(prev_i[chan] * d + cur_i[chan] * (outrate - d))
/ outrate
)
_put_sample(result, size, out_i, _overflow(cur_o, size))
out_i += 1
d -= inrate
def lin2ulaw(cp, size):
raise NotImplementedError()
def ulaw2lin(cp, size):
raise NotImplementedError()
def lin2alaw(cp, size):
raise NotImplementedError()
def alaw2lin(cp, size):
raise NotImplementedError()
def lin2adpcm(cp, size, state):
raise NotImplementedError()
def adpcm2lin(cp, size, state):
raise NotImplementedError()
@@ -1,175 +0,0 @@
"""
This module provides scipy versions of high_pass_filter, and low_pass_filter
as well as an additional band_pass_filter.
Of course, you will need to install scipy for these to work.
When this module is imported the high and low pass filters from this module
will be used when calling audio_segment.high_pass_filter() and
audio_segment.high_pass_filter() instead of the slower, less powerful versions
provided by pydub.effects.
"""
from scipy.signal import butter, sosfilt
from .utils import (register_pydub_effect,stereo_to_ms,ms_to_stereo)
def _mk_butter_filter(freq, type, order):
"""
Args:
freq: The cutoff frequency for highpass and lowpass filters. For
band filters, a list of [low_cutoff, high_cutoff]
type: "lowpass", "highpass", or "band"
order: nth order butterworth filter (default: 5th order). The
attenuation is -6dB/octave beyond the cutoff frequency (for 1st
order). A Higher order filter will have more attenuation, each level
adding an additional -6dB (so a 3rd order butterworth filter would
be -18dB/octave).
Returns:
function which can filter a mono audio segment
"""
def filter_fn(seg):
assert seg.channels == 1
nyq = 0.5 * seg.frame_rate
try:
freqs = [f / nyq for f in freq]
except TypeError:
freqs = freq / nyq
sos = butter(order, freqs, btype=type, output='sos')
y = sosfilt(sos, seg.get_array_of_samples())
return seg._spawn(y.astype(seg.array_type))
return filter_fn
@register_pydub_effect
def band_pass_filter(seg, low_cutoff_freq, high_cutoff_freq, order=5):
filter_fn = _mk_butter_filter([low_cutoff_freq, high_cutoff_freq], 'band', order=order)
return seg.apply_mono_filter_to_each_channel(filter_fn)
@register_pydub_effect
def high_pass_filter(seg, cutoff_freq, order=5):
filter_fn = _mk_butter_filter(cutoff_freq, 'highpass', order=order)
return seg.apply_mono_filter_to_each_channel(filter_fn)
@register_pydub_effect
def low_pass_filter(seg, cutoff_freq, order=5):
filter_fn = _mk_butter_filter(cutoff_freq, 'lowpass', order=order)
return seg.apply_mono_filter_to_each_channel(filter_fn)
@register_pydub_effect
def _eq(seg, focus_freq, bandwidth=100, mode="peak", gain_dB=0, order=2):
"""
Args:
focus_freq - middle frequency or known frequency of band (in Hz)
bandwidth - range of the equalizer band
mode - Mode of Equalization(Peak/Notch(Bell Curve),High Shelf, Low Shelf)
order - Rolloff factor(1 - 6dB/Octave 2 - 12dB/Octave)
Returns:
Equalized/Filtered AudioSegment
"""
filt_mode = ["peak", "low_shelf", "high_shelf"]
if mode not in filt_mode:
raise ValueError("Incorrect Mode Selection")
if gain_dB >= 0:
if mode == "peak":
sec = band_pass_filter(seg, focus_freq - bandwidth/2, focus_freq + bandwidth/2, order = order)
seg = seg.overlay(sec - (3 - gain_dB))
return seg
if mode == "low_shelf":
sec = low_pass_filter(seg, focus_freq, order=order)
seg = seg.overlay(sec - (3 - gain_dB))
return seg
if mode == "high_shelf":
sec = high_pass_filter(seg, focus_freq, order=order)
seg = seg.overlay(sec - (3 - gain_dB))
return seg
if gain_dB < 0:
if mode == "peak":
sec = high_pass_filter(seg, focus_freq - bandwidth/2, order=order)
seg = seg.overlay(sec - (3 + gain_dB)) + gain_dB
sec = low_pass_filter(seg, focus_freq + bandwidth/2, order=order)
seg = seg.overlay(sec - (3 + gain_dB)) + gain_dB
return seg
if mode == "low_shelf":
sec = high_pass_filter(seg, focus_freq, order=order)
seg = seg.overlay(sec - (3 + gain_dB)) + gain_dB
return seg
if mode=="high_shelf":
sec=low_pass_filter(seg, focus_freq, order=order)
seg=seg.overlay(sec - (3 + gain_dB)) +gain_dB
return seg
@register_pydub_effect
def eq(seg, focus_freq, bandwidth=100, channel_mode="L+R", filter_mode="peak", gain_dB=0, order=2):
"""
Args:
focus_freq - middle frequency or known frequency of band (in Hz)
bandwidth - range of the equalizer band
channel_mode - Select Channels to be affected by the filter.
L+R - Standard Stereo Filter
L - Only Left Channel is Filtered
R - Only Right Channel is Filtered
M+S - Blumlien Stereo Filter(Mid-Side)
M - Only Mid Channel is Filtered
S - Only Side Channel is Filtered
Mono Audio Segments are completely filtered.
filter_mode - Mode of Equalization(Peak/Notch(Bell Curve),High Shelf, Low Shelf)
order - Rolloff factor(1 - 6dB/Octave 2 - 12dB/Octave)
Returns:
Equalized/Filtered AudioSegment
"""
channel_modes = ["L+R", "M+S", "L", "R", "M", "S"]
if channel_mode not in channel_modes:
raise ValueError("Incorrect Channel Mode Selection")
if seg.channels == 1:
return _eq(seg, focus_freq, bandwidth, filter_mode, gain_dB, order)
if channel_mode == "L+R":
return _eq(seg, focus_freq, bandwidth, filter_mode, gain_dB, order)
if channel_mode == "L":
seg = seg.split_to_mono()
seg = [_eq(seg[0], focus_freq, bandwidth, filter_mode, gain_dB, order), seg[1]]
return AudioSegment.from_mono_audio_segements(seg[0], seg[1])
if channel_mode == "R":
seg = seg.split_to_mono()
seg = [seg[0], _eq(seg[1], focus_freq, bandwidth, filter_mode, gain_dB, order)]
return AudioSegment.from_mono_audio_segements(seg[0], seg[1])
if channel_mode == "M+S":
seg = stereo_to_ms(seg)
seg = _eq(seg, focus_freq, bandwidth, filter_mode, gain_dB, order)
return ms_to_stereo(seg)
if channel_mode == "M":
seg = stereo_to_ms(seg).split_to_mono()
seg = [_eq(seg[0], focus_freq, bandwidth, filter_mode, gain_dB, order), seg[1]]
seg = AudioSegment.from_mono_audio_segements(seg[0], seg[1])
return ms_to_stereo(seg)
if channel_mode == "S":
seg = stereo_to_ms(seg).split_to_mono()
seg = [seg[0], _eq(seg[1], focus_freq, bandwidth, filter_mode, gain_dB, order)]
seg = AudioSegment.from_mono_audio_segements(seg[0], seg[1])
return ms_to_stereo(seg)
-182
View File
@@ -1,182 +0,0 @@
"""
Various functions for finding/manipulating silence in AudioSegments
"""
import itertools
from .utils import db_to_float
def detect_silence(audio_segment, min_silence_len=1000, silence_thresh=-16, seek_step=1):
"""
Returns a list of all silent sections [start, end] in milliseconds of audio_segment.
Inverse of detect_nonsilent()
audio_segment - the segment to find silence in
min_silence_len - the minimum length for any silent section
silence_thresh - the upper bound for how quiet is silent in dFBS
seek_step - step size for interating over the segment in ms
"""
seg_len = len(audio_segment)
# you can't have a silent portion of a sound that is longer than the sound
if seg_len < min_silence_len:
return []
# convert silence threshold to a float value (so we can compare it to rms)
silence_thresh = db_to_float(silence_thresh) * audio_segment.max_possible_amplitude
# find silence and add start and end indicies to the to_cut list
silence_starts = []
# check successive (1 sec by default) chunk of sound for silence
# try a chunk at every "seek step" (or every chunk for a seek step == 1)
last_slice_start = seg_len - min_silence_len
slice_starts = range(0, last_slice_start + 1, seek_step)
# guarantee last_slice_start is included in the range
# to make sure the last portion of the audio is searched
if last_slice_start % seek_step:
slice_starts = itertools.chain(slice_starts, [last_slice_start])
for i in slice_starts:
audio_slice = audio_segment[i:i + min_silence_len]
if audio_slice.rms <= silence_thresh:
silence_starts.append(i)
# short circuit when there is no silence
if not silence_starts:
return []
# combine the silence we detected into ranges (start ms - end ms)
silent_ranges = []
prev_i = silence_starts.pop(0)
current_range_start = prev_i
for silence_start_i in silence_starts:
continuous = (silence_start_i == prev_i + seek_step)
# sometimes two small blips are enough for one particular slice to be
# non-silent, despite the silence all running together. Just combine
# the two overlapping silent ranges.
silence_has_gap = silence_start_i > (prev_i + min_silence_len)
if not continuous and silence_has_gap:
silent_ranges.append([current_range_start,
prev_i + min_silence_len])
current_range_start = silence_start_i
prev_i = silence_start_i
silent_ranges.append([current_range_start,
prev_i + min_silence_len])
return silent_ranges
def detect_nonsilent(audio_segment, min_silence_len=1000, silence_thresh=-16, seek_step=1):
"""
Returns a list of all nonsilent sections [start, end] in milliseconds of audio_segment.
Inverse of detect_silent()
audio_segment - the segment to find silence in
min_silence_len - the minimum length for any silent section
silence_thresh - the upper bound for how quiet is silent in dFBS
seek_step - step size for interating over the segment in ms
"""
silent_ranges = detect_silence(audio_segment, min_silence_len, silence_thresh, seek_step)
len_seg = len(audio_segment)
# if there is no silence, the whole thing is nonsilent
if not silent_ranges:
return [[0, len_seg]]
# short circuit when the whole audio segment is silent
if silent_ranges[0][0] == 0 and silent_ranges[0][1] == len_seg:
return []
prev_end_i = 0
nonsilent_ranges = []
for start_i, end_i in silent_ranges:
nonsilent_ranges.append([prev_end_i, start_i])
prev_end_i = end_i
if end_i != len_seg:
nonsilent_ranges.append([prev_end_i, len_seg])
if nonsilent_ranges[0] == [0, 0]:
nonsilent_ranges.pop(0)
return nonsilent_ranges
def split_on_silence(audio_segment, min_silence_len=1000, silence_thresh=-16, keep_silence=100,
seek_step=1):
"""
Returns list of audio segments from splitting audio_segment on silent sections
audio_segment - original pydub.AudioSegment() object
min_silence_len - (in ms) minimum length of a silence to be used for
a split. default: 1000ms
silence_thresh - (in dBFS) anything quieter than this will be
considered silence. default: -16dBFS
keep_silence - (in ms or True/False) leave some silence at the beginning
and end of the chunks. Keeps the sound from sounding like it
is abruptly cut off.
When the length of the silence is less than the keep_silence duration
it is split evenly between the preceding and following non-silent
segments.
If True is specified, all the silence is kept, if False none is kept.
default: 100ms
seek_step - step size for interating over the segment in ms
"""
# from the itertools documentation
def pairwise(iterable):
"s -> (s0,s1), (s1,s2), (s2, s3), ..."
a, b = itertools.tee(iterable)
next(b, None)
return zip(a, b)
if isinstance(keep_silence, bool):
keep_silence = len(audio_segment) if keep_silence else 0
output_ranges = [
[ start - keep_silence, end + keep_silence ]
for (start,end)
in detect_nonsilent(audio_segment, min_silence_len, silence_thresh, seek_step)
]
for range_i, range_ii in pairwise(output_ranges):
last_end = range_i[1]
next_start = range_ii[0]
if next_start < last_end:
range_i[1] = (last_end+next_start)//2
range_ii[0] = range_i[1]
return [
audio_segment[ max(start,0) : min(end,len(audio_segment)) ]
for start,end in output_ranges
]
def detect_leading_silence(sound, silence_threshold=-50.0, chunk_size=10):
"""
Returns the millisecond/index that the leading silence ends.
audio_segment - the segment to find silence in
silence_threshold - the upper bound for how quiet is silent in dFBS
chunk_size - chunk size for interating over the segment in ms
"""
trim_ms = 0 # ms
assert chunk_size > 0 # to avoid infinite loop
while sound[trim_ms:trim_ms+chunk_size].dBFS < silence_threshold and trim_ms < len(sound):
trim_ms += chunk_size
# if there is no end it should return the length of the segment
return min(trim_ms, len(sound))
-452
View File
@@ -1,452 +0,0 @@
from __future__ import division
from io import BufferedReader
import json
import os
import re
import sys
from subprocess import Popen, PIPE
from math import log, ceil
from tempfile import TemporaryFile
from warnings import warn
from functools import wraps
try:
import audioop
except ImportError:
try:
import pyaudioop as audioop
except ImportError:
import sys
print("Warning: Neither audioop nor pyaudioop available. Audio processing may be limited.", file=sys.stderr)
# Create a minimal fallback for basic operations
class audioop:
@staticmethod
def add(data, val):
return data
@staticmethod
def mul(data, val):
return data
if sys.version_info >= (3, 0):
basestring = str
FRAME_WIDTHS = {
8: 1,
16: 2,
32: 4,
}
ARRAY_TYPES = {
8: "b",
16: "h",
32: "i",
}
ARRAY_RANGES = {
8: (-0x80, 0x7f),
16: (-0x8000, 0x7fff),
32: (-0x80000000, 0x7fffffff),
}
def get_frame_width(bit_depth):
return FRAME_WIDTHS[bit_depth]
def get_array_type(bit_depth, signed=True):
t = ARRAY_TYPES[bit_depth]
if not signed:
t = t.upper()
return t
def get_min_max_value(bit_depth):
return ARRAY_RANGES[bit_depth]
def _fd_or_path_or_tempfile(fd, mode='w+b', tempfile=True):
close_fd = False
if fd is None and tempfile:
fd = TemporaryFile(mode=mode)
close_fd = True
if isinstance(fd, basestring):
fd = open(fd, mode=mode)
close_fd = True
if isinstance(fd, BufferedReader):
close_fd = True
try:
if isinstance(fd, os.PathLike):
fd = open(fd, mode=mode)
close_fd = True
except AttributeError:
# module os has no attribute PathLike, so we're on python < 3.6.
# The protocol we're trying to support doesn't exist, so just pass.
pass
return fd, close_fd
def db_to_float(db, using_amplitude=True):
"""
Converts the input db to a float, which represents the equivalent
ratio in power.
"""
db = float(db)
if using_amplitude:
return 10 ** (db / 20)
else: # using power
return 10 ** (db / 10)
def ratio_to_db(ratio, val2=None, using_amplitude=True):
"""
Converts the input float to db, which represents the equivalent
to the ratio in power represented by the multiplier passed in.
"""
ratio = float(ratio)
# accept 2 values and use the ratio of val1 to val2
if val2 is not None:
ratio = ratio / val2
# special case for multiply-by-zero (convert to silence)
if ratio == 0:
return -float('inf')
if using_amplitude:
return 20 * log(ratio, 10)
else: # using power
return 10 * log(ratio, 10)
def register_pydub_effect(fn, name=None):
"""
decorator for adding pydub effects to the AudioSegment objects.
example use:
@register_pydub_effect
def normalize(audio_segment):
...
or you can specify a name:
@register_pydub_effect("normalize")
def normalize_audio_segment(audio_segment):
...
"""
if isinstance(fn, basestring):
name = fn
return lambda fn: register_pydub_effect(fn, name)
if name is None:
name = fn.__name__
from .audio_segment import AudioSegment
setattr(AudioSegment, name, fn)
return fn
def make_chunks(audio_segment, chunk_length):
"""
Breaks an AudioSegment into chunks that are <chunk_length> milliseconds
long.
if chunk_length is 50 then you'll get a list of 50 millisecond long audio
segments back (except the last one, which can be shorter)
"""
number_of_chunks = ceil(len(audio_segment) / float(chunk_length))
return [audio_segment[i * chunk_length:(i + 1) * chunk_length]
for i in range(int(number_of_chunks))]
def which(program):
"""
Mimics behavior of UNIX which command.
"""
# Add .exe program extension for windows support
if os.name == "nt" and not program.endswith(".exe"):
program += ".exe"
envdir_list = [os.curdir] + os.environ["PATH"].split(os.pathsep)
for envdir in envdir_list:
program_path = os.path.join(envdir, program)
if os.path.isfile(program_path) and os.access(program_path, os.X_OK):
return program_path
def get_encoder_name():
"""
Return enconder default application for system, either avconv or ffmpeg
"""
if which("avconv"):
return "avconv"
elif which("ffmpeg"):
return "ffmpeg"
else:
# should raise exception
warn("Couldn't find ffmpeg or avconv - defaulting to ffmpeg, but may not work", RuntimeWarning)
return "ffmpeg"
def get_player_name():
"""
Return enconder default application for system, either avconv or ffmpeg
"""
if which("avplay"):
return "avplay"
elif which("ffplay"):
return "ffplay"
else:
# should raise exception
warn("Couldn't find ffplay or avplay - defaulting to ffplay, but may not work", RuntimeWarning)
return "ffplay"
def get_prober_name():
"""
Return probe application, either avconv or ffmpeg
"""
if which("avprobe"):
return "avprobe"
elif which("ffprobe"):
return "ffprobe"
else:
# should raise exception
warn("Couldn't find ffprobe or avprobe - defaulting to ffprobe, but may not work", RuntimeWarning)
return "ffprobe"
def fsdecode(filename):
"""Wrapper for os.fsdecode which was introduced in python 3.2 ."""
if sys.version_info >= (3, 2):
PathLikeTypes = (basestring, bytes)
if sys.version_info >= (3, 6):
PathLikeTypes += (os.PathLike,)
if isinstance(filename, PathLikeTypes):
return os.fsdecode(filename)
else:
if isinstance(filename, bytes):
return filename.decode(sys.getfilesystemencoding())
if isinstance(filename, basestring):
return filename
raise TypeError("type {0} not accepted by fsdecode".format(type(filename)))
def get_extra_info(stderr):
"""
avprobe sometimes gives more information on stderr than
on the json output. The information has to be extracted
from stderr of the format of:
' Stream #0:0: Audio: flac, 88200 Hz, stereo, s32 (24 bit)'
or (macOS version):
' Stream #0:0: Audio: vorbis'
' 44100 Hz, stereo, fltp, 320 kb/s'
:type stderr: str
:rtype: list of dict
"""
extra_info = {}
re_stream = r'(?P<space_start> +)Stream #0[:\.](?P<stream_id>([0-9]+))(?P<content_0>.+)\n?(?! *Stream)((?P<space_end> +)(?P<content_1>.+))?'
for i in re.finditer(re_stream, stderr):
if i.group('space_end') is not None and len(i.group('space_start')) <= len(
i.group('space_end')):
content_line = ','.join([i.group('content_0'), i.group('content_1')])
else:
content_line = i.group('content_0')
tokens = [x.strip() for x in re.split('[:,]', content_line) if x]
extra_info[int(i.group('stream_id'))] = tokens
return extra_info
def mediainfo_json(filepath, read_ahead_limit=-1):
"""Return json dictionary with media info(codec, duration, size, bitrate...) from filepath
"""
prober = get_prober_name()
command_args = [
"-v", "info",
"-show_format",
"-show_streams",
]
try:
command_args += [fsdecode(filepath)]
stdin_parameter = None
stdin_data = None
except TypeError:
if prober == 'ffprobe':
command_args += ["-read_ahead_limit", str(read_ahead_limit),
"cache:pipe:0"]
else:
command_args += ["-"]
stdin_parameter = PIPE
file, close_file = _fd_or_path_or_tempfile(filepath, 'rb', tempfile=False)
file.seek(0)
stdin_data = file.read()
if close_file:
file.close()
command = [prober, '-of', 'json'] + command_args
res = Popen(command, stdin=stdin_parameter, stdout=PIPE, stderr=PIPE)
output, stderr = res.communicate(input=stdin_data)
output = output.decode("utf-8", 'ignore')
stderr = stderr.decode("utf-8", 'ignore')
try:
info = json.loads(output)
except json.decoder.JSONDecodeError:
# If ffprobe didn't give any information, just return it
# (for example, because the file doesn't exist)
return None
if not info:
return info
extra_info = get_extra_info(stderr)
audio_streams = [x for x in info['streams'] if x['codec_type'] == 'audio']
if len(audio_streams) == 0:
return info
# We just operate on the first audio stream in case there are more
stream = audio_streams[0]
def set_property(stream, prop, value):
if prop not in stream or stream[prop] == 0:
stream[prop] = value
for token in extra_info[stream['index']]:
m = re.match(r'([su]([0-9]{1,2})p?) \(([0-9]{1,2}) bit\)$', token)
m2 = re.match(r'([su]([0-9]{1,2})p?)( \(default\))?$', token)
if m:
set_property(stream, 'sample_fmt', m.group(1))
set_property(stream, 'bits_per_sample', int(m.group(2)))
set_property(stream, 'bits_per_raw_sample', int(m.group(3)))
elif m2:
set_property(stream, 'sample_fmt', m2.group(1))
set_property(stream, 'bits_per_sample', int(m2.group(2)))
set_property(stream, 'bits_per_raw_sample', int(m2.group(2)))
elif re.match(r'(flt)p?( \(default\))?$', token):
set_property(stream, 'sample_fmt', token)
set_property(stream, 'bits_per_sample', 32)
set_property(stream, 'bits_per_raw_sample', 32)
elif re.match(r'(dbl)p?( \(default\))?$', token):
set_property(stream, 'sample_fmt', token)
set_property(stream, 'bits_per_sample', 64)
set_property(stream, 'bits_per_raw_sample', 64)
return info
def mediainfo(filepath):
"""Return dictionary with media info(codec, duration, size, bitrate...) from filepath
"""
prober = get_prober_name()
command_args = [
"-v", "quiet",
"-show_format",
"-show_streams",
filepath
]
command = [prober, '-of', 'old'] + command_args
res = Popen(command, stdout=PIPE)
output = res.communicate()[0].decode("utf-8")
if res.returncode != 0:
command = [prober] + command_args
output = Popen(command, stdout=PIPE).communicate()[0].decode("utf-8")
rgx = re.compile(r"(?:(?P<inner_dict>.*?):)?(?P<key>.*?)\=(?P<value>.*?)$")
info = {}
if sys.platform == 'win32':
output = output.replace("\r", "")
for line in output.split("\n"):
# print(line)
mobj = rgx.match(line)
if mobj:
# print(mobj.groups())
inner_dict, key, value = mobj.groups()
if inner_dict:
try:
info[inner_dict]
except KeyError:
info[inner_dict] = {}
info[inner_dict][key] = value
else:
info[key] = value
return info
def cache_codecs(function):
cache = {}
@wraps(function)
def wrapper():
try:
return cache[0]
except:
cache[0] = function()
return cache[0]
return wrapper
@cache_codecs
def get_supported_codecs():
encoder = get_encoder_name()
command = [encoder, "-codecs"]
res = Popen(command, stdout=PIPE, stderr=PIPE)
output = res.communicate()[0].decode("utf-8")
if res.returncode != 0:
return []
if sys.platform == 'win32':
output = output.replace("\r", "")
rgx = re.compile(r"^([D.][E.][AVS.][I.][L.][S.]) (\w*) +(.*)")
decoders = set()
encoders = set()
for line in output.split('\n'):
match = rgx.match(line.strip())
if not match:
continue
flags, codec, name = match.groups()
if flags[0] == 'D':
decoders.add(codec)
if flags[1] == 'E':
encoders.add(codec)
return (decoders, encoders)
def get_supported_decoders():
return get_supported_codecs()[0]
def get_supported_encoders():
return get_supported_codecs()[1]
def stereo_to_ms(audio_segment):
'''
Left-Right -> Mid-Side
'''
channel = audio_segment.split_to_mono()
channel = [channel[0].overlay(channel[1]), channel[0].overlay(channel[1].invert_phase())]
return AudioSegment.from_mono_audiosegments(channel[0], channel[1])
def ms_to_stereo(audio_segment):
'''
Mid-Side -> Left-Right
'''
channel = audio_segment.split_to_mono()
channel = [channel[0].overlay(channel[1]) - 3, channel[0].overlay(channel[1].invert_phase()) - 3]
return AudioSegment.from_mono_audiosegments(channel[0], channel[1])
-5
View File
@@ -1,5 +0,0 @@
[wheel]
universal = 1
[pep8]
max-line-length = 100
-42
View File
@@ -1,42 +0,0 @@
__doc__ = """
Manipulate audio with an simple and easy high level interface.
See the README file for details, usage info, and a list of gotchas.
"""
from setuptools import setup
setup(
name='pydub',
version='0.25.1',
author='James Robert',
author_email='jiaaro@gmail.com',
description='Manipulate audio with an simple and easy high level interface',
license='MIT',
keywords='audio sound high-level',
url='http://pydub.com',
packages=['pydub'],
long_description=__doc__,
classifiers=[
'Development Status :: 5 - Production/Stable',
'License :: OSI Approved :: MIT License',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Intended Audience :: Developers',
'Operating System :: OS Independent',
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Sound/Audio :: Analysis",
"Topic :: Multimedia :: Sound/Audio :: Conversion",
"Topic :: Multimedia :: Sound/Audio :: Editors",
"Topic :: Multimedia :: Sound/Audio :: Mixers",
"Topic :: Software Development :: Libraries",
'Topic :: Utilities',
]
)
-29
View File
@@ -1,29 +0,0 @@
"""
Recipes are a way to create mixes.
"""
from abc import ABC, abstractmethod
from typing import Any, List
class HomepageRoutine(ABC):
"""
A routine creates a row of homepage items.
"""
@property
@abstractmethod
def is_valid(self) -> bool: ...
def __init__(self) -> None:
if not self.is_valid:
return
self.run()
@abstractmethod
def run(self) -> List[Any]:
"""
Creates the homepage items and saves them to the
homepage store if self.is_valid is true.
"""
...

Some files were not shown because too many files have changed in this diff Show More