mirror of
https://github.com/Dvorinka/swingmusic-extended.git
synced 2026-06-04 12:33:03 +00:00
merge refactors pr #364 from @michilyy
* Save to DB only unique trackhashes * Add check if track already exists in playlist * replace all paths with `pathlib.Path` * `architecture.md`: * add config folder layout `config.py`: * fix bug where `pathlib.Path` cannot be serialized `files.py`: * remove unused imports * update path concatenation to `pathlib.Path` * add config-folder creation `imgserver.py`: * fix serialisation bug `playlistlib.py`: * update path concatenation to `pathlib.Path` * update all `settings.Paths` usages to new singleton `Paths` class. * update all usages of `settings.Paths` * `files.py`: * rework assets copy function. * remove unused loop and unused `shutil.copy2` function `settings.py` * fix recursion exception in `Paths` * `settings.py`: * remove Singleton and `@property` todos from `Paths` * `__init__.py`: * remove now unused function `create_config_dir()` `setup.files`: * remove because merged into `settings.Paths()` for more central and clear flow how the base path gets decided `settings.py`: * add `copy_assets` function `start_swingmusic.py`: * add configurable settings.Paths class `__main__.py`: * update click to used correct default path * remove wrong commited egg files * remove change in the wrong branch * add forgotten `property` decorator update `get_files_and_dirs` to use pathlib where possible `config.py`: * update type annotation `folders.py`: * convert `pathlib` to posix path where needed for sub-functions `folderlib.py`: * rework `get_files_and_dirs` to use `pathlib` where possible `settings.py`: * add forgotten `@property` `start_swingmusic.py`: * remove second `log_startup_info()` * `artistlib.py`: * fix calling property `tagger.py`: * fix comparing elements in `pathlib.Path` * add support for repeating lyrics. * rework lyrics api and lib * update most path functions. add type-hint pathlib where needed * for serialization paths are converted to posix path * use `open` instead of `os.open` update `metaclass` with constant * fix initial config exception if empty file existed * update `userConfig` with `InitVar` to be excluded from `asdict` * remove `is_windows_slash()` rework path function to use pathlib * convert `pathlib.Path` to `str` for serialization * fixing bug with str + pathlib * `__main__.py`: * update click to use package version * remove now unused function `print_version` `filesystem.py`: * rework `CWD` to use importlib `pyproject.toml`: * disable namespace for `importlib.resources` to work correctly * update `lyrics.py`: * remove unused functions * simplify functions * fix bug where assets get created on root * remove unused code * update lyrics for clearer structure. * add support for unsynced lyrics * fix wrong return type in unsynced lyrics * update `/check` to use `send_lyrics` * prefer tags to duplicates * `lyrics.py`: * add docs to a function group * `logger.py`: * add logging config dict. * combine Logging into one file * add socket logger * add debug mode to logger * add JSONL formater * `logger.py`: * update config to directly use the formater. resolves circular import exception `__main__.py`: * add logger setup to main `start_swingmusic.py`: * add debug option to cli * `lyrics.py`: * add offset support * add `setuptools-scm` to get version from git * add support for docker build with scm * add support for docker build with scm need someone who can test the changes workflow * update all usage of `version.txt` to `metadata.version()` * 2x update all usage of `version.txt` to `metadata.version()` * update to no local_scheme version * provide fix for #331. convert `sql.Row` and `TrackTable` to dict before converting to dataclass. * fix `__main__.py`: * wrong import and uncommited changes * add debug and base_path parameter * fix logger pathlib * add client build workflow * set name * split client from build * try fixing builds * try another fix * try also another fix * try again something new * try again something new * change runner * fix failed run because of malformed runner * add wheel builds * remove systems from pure python build * add isolated pyinstaller build * artifacts with names * wrong wheel path * try fetch-depth for tag fetch * disable fail-fast. add wheel installation * add install system packages * add debug * fix wheel install fix pyinstaller spec file * try fix for pyinstaller * try another fix * build on release * add concrete release types * only run on released or pre-released * try release upload * reformat upload * fix needs tag * identifiable pyinstaller builds * compress client folder before uploading * update to src build * remove no more needed aarch64 build script rename pyinstaller assets to lowercase * remove unneeded code * fix: save to DB only unique track hashes * replace click with argparse * set concrete types in argparse * replace manuall path usages with pathlib * remove unused `configs.py` file * reformat `start_swingmusic.py` * fix empty set startup exception * optimizing static files serve function * fixing bug in optimisation of static files serve function * fix folder view bug * colorlib.py: * fix wrong type exception * remove singe use Index_everything class * update logging of populate.py * cleanup files * fix settings.py Paths copy function. Created folder on file. * add exist check to folder * remove unused `INFO` class * fix multiprocessing bug on windows * potential icon fix for pyinstaller fix multiple logging bug * fix argparse config path bug add jobs file * cleanup code fragments fix logging issue add notes to function * note that concurrent creates own sys.modules * refactor some lyrics plugin condition remove unused import from hashing * refactor taglib.py * update import statements to be static * playlistlib.py: * refactoring and more doc strings populate.py: * add poc bugfix settings.py: * add typehint * possible bugfix for multitreading globals * folder.py: * add check if provided path is absolute populate.py: * add bug note settings.py: * add possible error from Singleton implementation start_swingmusic.py: * correct spelling * pass resolved path to Paths tagger.py: * add logging * trying out fixes for multithreading * only upload results not metadata * fix build action again * folder.py: * strictly use pathlib where possible folderlib.py: * add missing docstring to function, who really need it. track.py: * refactor some code folder.py: * refactor some more code * Merge DBPath class and Paths class. Update all usages of DBPath folderslib.py: * fix bug with logging taglib.py: * add missing docstring settings.py: * merge classes * refactor * network.py: * add more docstring config.py: * update pathlib usage tools.py: * refactor * add docstrings * colorlib.py: * add docstring Refactor App builder into grouped config settings. * update assets access for migration * Update FUNDING.yml * Update FUNDING.yml * upgrade tinytag in requirements.txt * update readme * update license * update readme * Update README.md * Update README.md * cleanup requirements.txt remove unused import in audio_segment.py add entrypoint.sh for appimage support update pyproject.toml for optional dependencies add appimage to github workflow * fix invalid workflow file * AppImage build needs more research. Commenting for now * testing a new build workflow * add libev installation * update workflow to new optional dependencies * trying again another fix * finally fix all optional deps installation correctly * remove AppImage poc * albumslib.py: * add docstring folder.py: * add unix path fix update logger name to `__name__` * update build with docker update Dockerfile with git fix typo in lyrics.py add dynamic deps back * add log for static folder * add missing import * add some more todos * add support for AppImages even when it's not perfect. * quick bugfix for wrong appimage config path * fix uploading not finding AppImages builds aka wrong pattern * optimise docker build by using artifacts. Add client path option. change docstring to sphinx format * add todos * Now support AppImages for real: manually build AppImage as we are building a complex project. * fix missing dep in AppImage build * add full AppImage metadata * add missing image file. * only update swingmusic appimage not tool * add todo and fix AppImage build again. * Try fixing some path mixup in AppImage build * add debug tag to action * correct path to appimage folder * do not download tool before checkout * Another fix for path in appimage build * extend config files with more information * default client dir is now inside the config dir. TODOs updated. * default client dir is now inside the config dir. TODOs updated. Add priority todos. * Auto download client when client not found. Respects user provided dir. * rename `requests` submodule to `request` * poc for arm AppImage builds * try out another fix * fix typo in build.yml * add missing arch tag * fix uploading double names * unique naming * enable fallback version for project. * do not download client into readonly dir. * fix relative client download path. Client was resolved into parent of config. * remove client backup path as client is now downloadable * `Paths` checks if config folder exists and creates it if necessary. logger no more creates the config folder. `app_builder.py`: static route no more with '/client' * path are only created in MainProcess. fix gz file not found. * move assets into src and update usages accordingly * remove solved todos * Only upload artefacts if not draft/master aka only on tag * wrong type in assets copy * update log with correct priority * add debug statements and logging to Paths * remove debugging statement * remove double version tag from docker build * fork save release protection * fix typo * add fallback client dir for static builds. * update argparse to new param * add missing import pathlib * add sparse checkout as we do not need everything downloaded * add assets copy check * init logger bevor Paths * remove unused import * check if logdir exists and create if not * only add exec info to file * remove exception log from cli * move logging into main. Allows tools support again. * UserConfig now correctly uses _finished key. Bug where _finished was never written * double save serverId. update root_dir to trow no exception on init. remove debug param * clean up TODOs --------- Co-authored-by: skilletfun <skilletfun.laptew.sergey@yandex.ru> Co-authored-by: Mungai Njoroge <geoffreymungai45@gmail.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import argparse
|
||||
import pathlib
|
||||
from importlib.metadata import version
|
||||
|
||||
import multiprocessing
|
||||
|
||||
from swingmusic.logger import setup_logger
|
||||
from swingmusic.settings import default_base_path
|
||||
from swingmusic.start_swingmusic import start_swingmusic
|
||||
from swingmusic import tools as swing_tools
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog='swingmusic',
|
||||
description='Awesome Music',
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-v', '--version',
|
||||
action='version',
|
||||
version=f"swingmusic v{version('swingmusic')}")
|
||||
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=default_base_path(),
|
||||
help="Path to the config file.",
|
||||
type=pathlib.Path
|
||||
)
|
||||
parser.add_argument(
|
||||
"--client",
|
||||
help="Path to the Web UI folder.",
|
||||
type=pathlib.Path
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--fallback-client",
|
||||
help="Path to the Web UI folder if no valid client is found. Used in pyinstaller and appimage.",
|
||||
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)
|
||||
|
||||
path = {
|
||||
"config": args["config"],
|
||||
"client": args["client"],
|
||||
"fallback": args["fallback_client"]
|
||||
}
|
||||
|
||||
setup_logger(debug=args["debug"], app_dir=path["config"])
|
||||
|
||||
|
||||
# check tools
|
||||
if args["password_reset"]:
|
||||
swing_tools.handle_password_reset(path)
|
||||
|
||||
# else start swingmusic
|
||||
else:
|
||||
start_swingmusic(
|
||||
host=args["host"],
|
||||
port=args["port"],
|
||||
path=path
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
multiprocessing.set_start_method("spawn")
|
||||
run()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
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",
|
||||
|
||||
"lyrics_plugin",
|
||||
"mixes_plugin"
|
||||
]
|
||||
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Contains all the album routes.
|
||||
"""
|
||||
|
||||
from dataclasses import asdict
|
||||
from pprint import pprint
|
||||
import random
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from flask_openapi3 import Tag
|
||||
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.settings import Defaults
|
||||
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])
|
||||
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
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",
|
||||
)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
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])
|
||||
@@ -0,0 +1,379 @@
|
||||
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 = 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
|
||||
|
||||
# finally, convert roles to json string
|
||||
user["roles"] = body.roles
|
||||
|
||||
if user["password"]:
|
||||
user["password"] = hash_password(user["password"])
|
||||
|
||||
# remove empty values
|
||||
clean_user = {k: v for k, v in user.items() if v}
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,314 @@
|
||||
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
|
||||
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
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"}
|
||||
@@ -0,0 +1,22 @@
|
||||
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]}
|
||||
@@ -0,0 +1,297 @@
|
||||
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)}
|
||||
@@ -0,0 +1,303 @@
|
||||
"""
|
||||
Contains all the folder routes.
|
||||
"""
|
||||
import pathlib
|
||||
from datetime import datetime
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import psutil
|
||||
from pydantic import BaseModel, Field
|
||||
from flask_openapi3 import Tag
|
||||
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.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
|
||||
from swingmusic.store.tracks import TrackStore
|
||||
from swingmusic.utils.wintools import is_windows
|
||||
|
||||
tag = Tag(name="Folders", description="Get folders and tracks in a directory")
|
||||
api = APIBlueprint("folder", __name__, url_prefix="/folder", abp_tags=[tag])
|
||||
|
||||
|
||||
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": req_dir,
|
||||
"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,
|
||||
}
|
||||
|
||||
# TODO: currently only fixed on unix. Windows/Mac still pending.
|
||||
# note
|
||||
|
||||
if not pathlib.Path(req_dir).exists():
|
||||
req_dir = "/" + req_dir
|
||||
|
||||
results = get_files_and_dirs(
|
||||
pathlib.Path(req_dir),
|
||||
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")
|
||||
def list_folders(body: DirBrowserBody):
|
||||
"""
|
||||
List folders
|
||||
|
||||
Returns a list of all the folders in the given folder.
|
||||
Used when selecting root dirs.
|
||||
"""
|
||||
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)]
|
||||
}
|
||||
|
||||
|
||||
req_dir = pathlib.Path(req_dir)
|
||||
|
||||
if not req_dir.exists():
|
||||
req_dir = "/" / req_dir
|
||||
|
||||
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("$"): # ignore windows system folder
|
||||
continue
|
||||
|
||||
if name.startswith("."): # ignore unix hidden folder
|
||||
continue
|
||||
|
||||
if entry.is_dir(): # lastly, check if is dir
|
||||
dirs.append({
|
||||
"name": name,
|
||||
"path": entry.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.
|
||||
"""
|
||||
show_in_file_manager(query.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.
|
||||
"""
|
||||
tracks = TrackTable.get_tracks_in_path(query.path)
|
||||
tracks = (serialize_track(t) for t in tracks if Path(t.filepath).exists())
|
||||
|
||||
return {
|
||||
"tracks": list(tracks)[:300],
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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}
|
||||
@@ -0,0 +1,38 @@
|
||||
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)
|
||||
@@ -0,0 +1,259 @@
|
||||
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")
|
||||
@@ -0,0 +1,77 @@
|
||||
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,
|
||||
)
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,103 @@
|
||||
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()
|
||||
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"}
|
||||
@@ -0,0 +1,60 @@
|
||||
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)
|
||||
return {"trackhash": trackhash, "lyrics": lyrics.format_synced_lyrics()}, 200
|
||||
|
||||
return {"trackhash": trackhash, "lyrics": lrc}, 200
|
||||
@@ -0,0 +1,109 @@
|
||||
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
|
||||
@@ -0,0 +1,380 @@
|
||||
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,
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,174 @@
|
||||
from dataclasses import asdict
|
||||
from importlib import metadata
|
||||
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.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("swingmusic")
|
||||
|
||||
# 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!",
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
Contains all the track routes.
|
||||
"""
|
||||
|
||||
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.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
|
||||
|
||||
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
|
||||
|
||||
Returns a playable audio file that corresponds to the given filepath. Falls back to track hash if filepath is not found.
|
||||
|
||||
NOTE: Does not support range requests or transcoding.
|
||||
"""
|
||||
trackhash = path.trackhash
|
||||
filepath = query.filepath
|
||||
msg = {"msg": "File Not Found"}
|
||||
|
||||
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:
|
||||
audio_type = guess_mime_type(filepath)
|
||||
return send_from_directory(
|
||||
Path(filepath).parent,
|
||||
Path(filepath).name,
|
||||
mimetype=audio_type,
|
||||
conditional=True,
|
||||
as_attachment=True,
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,244 @@
|
||||
from importlib import metadata
|
||||
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 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):
|
||||
# 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)
|
||||
|
||||
|
||||
def load_plugins(web):
|
||||
# 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('swingmusic')}",
|
||||
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 not "Chrome" 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
|
||||
log.info(f"Serving client from '{app.static_folder}'")
|
||||
|
||||
@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
|
||||
@@ -0,0 +1,8 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 654 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 737 B |
@@ -0,0 +1,170 @@
|
||||
import importlib.resources
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from dataclasses import dataclass, asdict, field, InitVar
|
||||
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.
|
||||
"""
|
||||
text = importlib.resources.read_text("swingmusic.data","artist_split_ignore.txt")
|
||||
# only return unique and not empty lines
|
||||
lines = text.splitlines()
|
||||
return set([ line.strip() for line in lines if line.strip() ])
|
||||
|
||||
|
||||
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().app_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))
|
||||
@@ -0,0 +1,31 @@
|
||||
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)
|
||||
@@ -0,0 +1,23 @@
|
||||
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.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,25 @@
|
||||
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()
|
||||
@@ -0,0 +1,12 @@
|
||||
AC/DC
|
||||
Bob marley & the wailers
|
||||
Crosby, Stills, Nash & Young
|
||||
Smith & Thell
|
||||
Peter, Paul & Mary
|
||||
Simon & Garfunkel
|
||||
Judy & Mary
|
||||
Florence & The Machine
|
||||
Belle & Sebastian
|
||||
C&C Music Factory
|
||||
C & C Music Factory
|
||||
FO&O
|
||||
@@ -0,0 +1,66 @@
|
||||
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)
|
||||
@@ -0,0 +1,78 @@
|
||||
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()
|
||||
@@ -0,0 +1,81 @@
|
||||
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)))
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
This module contains the functions to interact with the SQLite database.
|
||||
"""
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
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()
|
||||
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
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()
|
||||
@@ -0,0 +1,748 @@
|
||||
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,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
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]
|
||||
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
@@ -0,0 +1,91 @@
|
||||
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"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
This module contains all the data processing and non-API libraries
|
||||
"""
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,187 @@
|
||||
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")
|
||||
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
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",
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
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
|
||||
@@ -0,0 +1,156 @@
|
||||
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),
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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()
|
||||
@@ -0,0 +1,170 @@
|
||||
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
|
||||
@@ -0,0 +1,42 @@
|
||||
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
|
||||
@@ -0,0 +1,217 @@
|
||||
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)
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
@@ -0,0 +1,161 @@
|
||||
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
|
||||
@@ -0,0 +1,43 @@
|
||||
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")
|
||||
@@ -0,0 +1,343 @@
|
||||
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
|
||||
@@ -0,0 +1,94 @@
|
||||
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"])
|
||||
@@ -0,0 +1,76 @@
|
||||
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
|
||||
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,176 @@
|
||||
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
|
||||
@@ -0,0 +1,17 @@
|
||||
### 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.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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
|
||||
@@ -0,0 +1,693 @@
|
||||
# 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 don’t 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. It’s 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 you’re implementing effects or other direct signal processing.
|
||||
|
||||
You probably don’t need this, but if you do… you’ll 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.
|
||||
@@ -0,0 +1,101 @@
|
||||
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
|
||||
@@ -0,0 +1,168 @@
|
||||
# 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
|
||||
@@ -0,0 +1,45 @@
|
||||
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
|
||||
@@ -0,0 +1,20 @@
|
||||
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.
|
||||
@@ -0,0 +1 @@
|
||||
include LICENSE
|
||||
@@ -0,0 +1,333 @@
|
||||
# Pydub [](https://travis-ci.org/jiaaro/pydub) [](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.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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"
|
||||
@@ -0,0 +1 @@
|
||||
from .audio_segment import AudioSegment
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,341 @@
|
||||
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})
|
||||
@@ -0,0 +1,32 @@
|
||||
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
|
||||
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
|
||||
"""
|
||||
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())
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,553 @@
|
||||
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()
|
||||
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
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))
|
||||
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
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:
|
||||
import pyaudioop as audioop
|
||||
|
||||
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])
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
[wheel]
|
||||
universal = 1
|
||||
|
||||
[pep8]
|
||||
max-line-length = 100
|
||||
@@ -0,0 +1,42 @@
|
||||
__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',
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,38 @@
|
||||
from swingmusic.db.userdata import UserTable
|
||||
from swingmusic.lib.recipes import HomepageRoutine
|
||||
from swingmusic.plugins.mixes import MixesPlugin
|
||||
from swingmusic.store.homepage import HomepageStore
|
||||
|
||||
|
||||
class ArtistMixes(HomepageRoutine):
|
||||
store_key = "artist_mixes"
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
return MixesPlugin().enabled
|
||||
|
||||
def run(self):
|
||||
users = UserTable.get_all()
|
||||
|
||||
for user in users:
|
||||
mix = MixesPlugin()
|
||||
mixes = mix.create_artist_mixes(user.id)
|
||||
|
||||
if not mixes:
|
||||
continue
|
||||
|
||||
HomepageStore.set_mixes(mixes, entrykey=self.store_key, userid=user.id)
|
||||
|
||||
custom_mixes = []
|
||||
for _mix in mixes:
|
||||
custom_mix = MixesPlugin.get_track_mix(_mix)
|
||||
|
||||
if custom_mix:
|
||||
custom_mixes.append(custom_mix)
|
||||
|
||||
HomepageStore.set_mixes(
|
||||
custom_mixes, entrykey="custom_mixes", userid=user.id
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
@@ -0,0 +1,40 @@
|
||||
from pprint import pprint
|
||||
from swingmusic.db.userdata import UserTable
|
||||
from swingmusic.lib.recipes import HomepageRoutine
|
||||
from swingmusic.lib.recipes.artistmixes import ArtistMixes
|
||||
from swingmusic.models.mix import Mix
|
||||
from swingmusic.plugins.mixes import MixesPlugin
|
||||
from swingmusic.store.homepage import HomepageStore
|
||||
|
||||
|
||||
class BecauseYouListened(HomepageRoutine):
|
||||
store_keys = ["because_you_listened_to_artist", "artists_you_might_like"]
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
return MixesPlugin().enabled
|
||||
|
||||
def run(self):
|
||||
users = UserTable.get_all()
|
||||
|
||||
for user in users:
|
||||
entry: dict[str, Mix] = HomepageStore.entries.get(
|
||||
ArtistMixes.store_key
|
||||
).items.get(user.id) # type: ignore
|
||||
|
||||
if not entry:
|
||||
continue
|
||||
|
||||
because_you_listened_to_artist, artists_you_might_like = (
|
||||
MixesPlugin().get_because_items(list(entry.values()))
|
||||
)
|
||||
|
||||
if not because_you_listened_to_artist or not artists_you_might_like:
|
||||
continue
|
||||
|
||||
HomepageStore.entries[self.store_keys[0]].items[
|
||||
user.id
|
||||
] = because_you_listened_to_artist
|
||||
HomepageStore.entries[self.store_keys[1]].items[
|
||||
user.id
|
||||
] = artists_you_might_like
|
||||
@@ -0,0 +1,96 @@
|
||||
from swingmusic.db.userdata import ScrobbleTable, UserTable
|
||||
from swingmusic.lib.home.recentlyadded import get_recently_added_items
|
||||
from swingmusic.lib.home.get_recently_played import get_recently_played
|
||||
from swingmusic.lib.recipes import HomepageRoutine
|
||||
from swingmusic.store.homepage import HomepageStore
|
||||
|
||||
|
||||
class RecentlyPlayed(HomepageRoutine):
|
||||
ITEM_LIMIT = 15
|
||||
store_key = "recently_played"
|
||||
|
||||
def __init__(self, userid: int | None = None) -> None:
|
||||
"""
|
||||
The userid is provided when we are running this routine
|
||||
outside a cron job. ie. when a user records a new scrobble.
|
||||
"""
|
||||
self.userids = [userid] if userid else [user.id for user in UserTable.get_all()]
|
||||
|
||||
# NOTE: When the userid is provided
|
||||
# we need to update the store for that userid only
|
||||
# using the last scrobble entry.
|
||||
self.update_only = userid is not None
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
return True
|
||||
|
||||
def run(self):
|
||||
if self.update_only:
|
||||
last_entry = ScrobbleTable.get_last_entry(self.userids[0])
|
||||
|
||||
if last_entry:
|
||||
items = get_recently_played(
|
||||
limit=self.ITEM_LIMIT, userid=self.userids[0], _entries=[last_entry]
|
||||
)
|
||||
|
||||
try:
|
||||
item = items[0]
|
||||
store_entry = HomepageStore.entries[self.store_key].items[
|
||||
self.userids[0]
|
||||
][0]
|
||||
except IndexError:
|
||||
store_entry = None
|
||||
item = None
|
||||
|
||||
if (
|
||||
store_entry
|
||||
and item
|
||||
and store_entry.get("type", "") + store_entry.get("hash", "")
|
||||
== item.get("type", "") + item.get("hash", "")
|
||||
):
|
||||
# If the item is the same as the one in the store
|
||||
# only update the timestamp
|
||||
HomepageStore.entries[self.store_key].items[self.userids[0]][0][
|
||||
"timestamp"
|
||||
] = item["timestamp"]
|
||||
else:
|
||||
# Otherwise, insert the new item
|
||||
# and remove the oldest item if there are more than 15 items
|
||||
HomepageStore.entries[self.store_key].items[self.userids[0]].insert(
|
||||
0, item
|
||||
)
|
||||
|
||||
if (
|
||||
len(
|
||||
HomepageStore.entries[self.store_key].items[self.userids[0]]
|
||||
)
|
||||
> self.ITEM_LIMIT
|
||||
):
|
||||
HomepageStore.entries[self.store_key].items[
|
||||
self.userids[0]
|
||||
].pop()
|
||||
|
||||
for userid in self.userids:
|
||||
items = get_recently_played(limit=self.ITEM_LIMIT, userid=userid)
|
||||
HomepageStore.entries[self.store_key].items[userid] = items
|
||||
|
||||
|
||||
class RecentlyAdded(HomepageRoutine):
|
||||
ITEM_LIMIT = 15
|
||||
store_key = "recently_added"
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
return True
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def run(self):
|
||||
items = get_recently_added_items(limit=self.ITEM_LIMIT)
|
||||
|
||||
# NOTE: Recently added is a global entry
|
||||
# So we don't need a userid
|
||||
HomepageStore.entries[self.store_key].items[0] = items
|
||||
@@ -0,0 +1,83 @@
|
||||
from gettext import ngettext
|
||||
import pendulum
|
||||
|
||||
from swingmusic.crons.cron import CronJob
|
||||
from swingmusic.db.userdata import UserTable
|
||||
from swingmusic.lib.recipes import HomepageRoutine
|
||||
from swingmusic.store.homepage import HomepageStore
|
||||
from swingmusic.utils.dates import get_date_range, seconds_to_time_string
|
||||
from swingmusic.utils.stats import get_artists_in_period
|
||||
|
||||
|
||||
class TopArtists(CronJob, HomepageRoutine):
|
||||
"""
|
||||
A routine to populate the top streamed artists/albums in the last week or month
|
||||
"""
|
||||
|
||||
hours = 1
|
||||
ITEM_LIMIT = 15
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
"""
|
||||
Only valid if it's the middle or last 2 days of this month.
|
||||
|
||||
When the duration is "week", it's valid on the weekend.
|
||||
"""
|
||||
if self.duration == "month":
|
||||
now = pendulum.now()
|
||||
middle_day = now.days_in_month // 2
|
||||
|
||||
return (
|
||||
now.day in range(middle_day, middle_day + 2)
|
||||
or now.day > now.days_in_month - 2
|
||||
)
|
||||
if self.duration == "week":
|
||||
return pendulum.now().isoweekday() in (5, 6, 7)
|
||||
|
||||
return False
|
||||
|
||||
def __init__(self, duration: str = "month") -> None:
|
||||
super().__init__()
|
||||
self.duration = duration
|
||||
|
||||
if not self.is_valid:
|
||||
return
|
||||
|
||||
def run(self):
|
||||
if not self.is_valid:
|
||||
self.destroy()
|
||||
return
|
||||
|
||||
self.userids = [user.id for user in UserTable.get_all()]
|
||||
|
||||
for userid in self.userids:
|
||||
date_range = get_date_range(self.duration)
|
||||
artists = get_artists_in_period(date_range[0], date_range[1], userid)[
|
||||
: self.ITEM_LIMIT
|
||||
]
|
||||
|
||||
artists = [
|
||||
{
|
||||
"type": "artist",
|
||||
"hash": artist["artisthash"],
|
||||
"help_text": seconds_to_time_string(artist["playduration"]),
|
||||
"secondary_text": str(artist["playcount"])
|
||||
+ " "
|
||||
+ ngettext("play", "plays", artist["playcount"]),
|
||||
}
|
||||
for artist in artists
|
||||
]
|
||||
|
||||
HomepageStore.entries[f"top_streamed_{self.duration}ly_artists"].items[
|
||||
userid
|
||||
] = artists
|
||||
|
||||
def destroy(self):
|
||||
"""
|
||||
Clear the top streamed entry from the homepage store.
|
||||
"""
|
||||
keys = [f"top_streamed_{self.duration}ly_artists"]
|
||||
|
||||
for key in keys:
|
||||
HomepageStore.entries[key].items = {}
|
||||
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
This library contains all the functions related to the search functionality.
|
||||
"""
|
||||
|
||||
from rapidfuzz import process, utils, fuzz
|
||||
from unidecode import unidecode
|
||||
|
||||
from swingmusic import models
|
||||
|
||||
from swingmusic.models.album import Album
|
||||
from swingmusic.models.artist import Artist
|
||||
from swingmusic.models.playlist import Playlist
|
||||
from swingmusic.models.track import Track
|
||||
from swingmusic.serializers.album import serialize_for_card as serialize_album
|
||||
from swingmusic.serializers.album import serialize_for_card_many as serialize_albums
|
||||
from swingmusic.serializers.artist import serialize_for_card, serialize_for_cards
|
||||
from swingmusic.serializers.track import serialize_track, serialize_tracks
|
||||
|
||||
from swingmusic.store.albums import AlbumStore
|
||||
from swingmusic.store.artists import ArtistStore
|
||||
from swingmusic.store.tracks import TrackStore
|
||||
|
||||
from swingmusic.utils.remove_duplicates import remove_duplicates
|
||||
|
||||
# ratio = fuzz.ratio
|
||||
# wratio = fuzz.WRatio
|
||||
|
||||
|
||||
class Cutoff:
|
||||
"""
|
||||
Holds all the default cutoff values.
|
||||
"""
|
||||
|
||||
tracks: int = 50
|
||||
albums: int = 50
|
||||
artists: int = 50
|
||||
playlists: int = 50
|
||||
|
||||
|
||||
class Limit:
|
||||
"""
|
||||
Holds all the default limit values.
|
||||
"""
|
||||
|
||||
tracks: int = 150
|
||||
albums: int = 150
|
||||
artists: int = 150
|
||||
playlists: int = 150
|
||||
|
||||
|
||||
class SearchTracks:
|
||||
def __init__(self, query: str) -> None:
|
||||
self.query = query
|
||||
self.tracks = TrackStore.get_flat_list()
|
||||
|
||||
def __call__(self, limit: int = Limit.tracks) -> list[models.Track]:
|
||||
"""
|
||||
Gets all songs with a given title.
|
||||
"""
|
||||
|
||||
track_titles = [unidecode(track.title).lower() for track in self.tracks]
|
||||
results = process.extract(
|
||||
self.query,
|
||||
track_titles,
|
||||
score_cutoff=Cutoff.tracks,
|
||||
limit=limit,
|
||||
processor=utils.default_process,
|
||||
scorer=fuzz.WRatio,
|
||||
)
|
||||
|
||||
tracks: list[Track] = []
|
||||
|
||||
for item in results:
|
||||
track = self.tracks[item[2]]
|
||||
track._score = item[1]
|
||||
tracks.append(track)
|
||||
|
||||
return remove_duplicates(tracks)
|
||||
|
||||
|
||||
class SearchArtists:
|
||||
def __init__(self, query: str) -> None:
|
||||
self.query = query
|
||||
self.artists = ArtistStore.get_flat_list()
|
||||
|
||||
def __call__(self, limit: int = Limit.artists):
|
||||
"""
|
||||
Gets all artists with a given name.
|
||||
"""
|
||||
choices = [unidecode(a.name).lower() for a in self.artists]
|
||||
|
||||
results = process.extract(
|
||||
self.query,
|
||||
choices,
|
||||
score_cutoff=Cutoff.artists,
|
||||
limit=limit,
|
||||
processor=utils.default_process,
|
||||
scorer=fuzz.WRatio,
|
||||
)
|
||||
|
||||
artists: list[Artist] = []
|
||||
|
||||
for item in results:
|
||||
artist = self.artists[item[2]]
|
||||
artist._score = item[1]
|
||||
artists.append(artist)
|
||||
|
||||
return artists
|
||||
|
||||
|
||||
class SearchAlbums:
|
||||
def __init__(self, query: str) -> None:
|
||||
self.query = query
|
||||
self.albums = AlbumStore.get_flat_list()
|
||||
|
||||
def __call__(self, limit: int = Limit.albums):
|
||||
"""
|
||||
Gets all albums with a given title.
|
||||
"""
|
||||
|
||||
choices = [unidecode(a.title).lower() for a in self.albums]
|
||||
|
||||
results = process.extract(
|
||||
self.query,
|
||||
choices,
|
||||
score_cutoff=Cutoff.albums,
|
||||
limit=limit,
|
||||
processor=utils.default_process,
|
||||
scorer=fuzz.token_sort_ratio,
|
||||
)
|
||||
|
||||
albums: list[Album] = []
|
||||
|
||||
for item in results:
|
||||
album = self.albums[item[2]]
|
||||
album._score = item[1]
|
||||
albums.append(album)
|
||||
|
||||
return albums
|
||||
|
||||
|
||||
class SearchPlaylists:
|
||||
def __init__(self, playlists: list[models.Playlist], query: str) -> None:
|
||||
self.playlists = playlists
|
||||
self.query = query
|
||||
|
||||
def __call__(self, limit: int = Limit.playlists):
|
||||
choices = [p.name for p in self.playlists]
|
||||
results = process.extract(
|
||||
self.query,
|
||||
choices,
|
||||
score_cutoff=Cutoff.playlists,
|
||||
limit=limit,
|
||||
processor=utils.default_process,
|
||||
scorer=fuzz.WRatio,
|
||||
)
|
||||
|
||||
playlists: list[Playlist] = []
|
||||
|
||||
for item in results:
|
||||
playlist = self.playlists[item[2]]
|
||||
playlist._score = item[1]
|
||||
playlists.append(playlist)
|
||||
|
||||
return playlists
|
||||
|
||||
|
||||
_type = models.Track | models.Album | models.Artist
|
||||
|
||||
|
||||
def get_titles(items: list[_type]):
|
||||
for item in items:
|
||||
if isinstance(item, models.Track):
|
||||
text = item.og_title
|
||||
elif isinstance(item, models.Album):
|
||||
text = item.title
|
||||
elif isinstance(item, models.Artist):
|
||||
text = item.name
|
||||
else:
|
||||
text = None
|
||||
|
||||
yield text
|
||||
|
||||
|
||||
class TopResults:
|
||||
"""
|
||||
Joins all tracks, albums and artists
|
||||
then fuzzy searches them as a single unit.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def collect_all():
|
||||
all_items: list[_type] = []
|
||||
|
||||
all_items.extend(ArtistStore.get_flat_list())
|
||||
all_items.extend(TrackStore.get_flat_list())
|
||||
all_items.extend(AlbumStore.get_flat_list())
|
||||
|
||||
return all_items, get_titles(all_items)
|
||||
|
||||
@staticmethod
|
||||
def get_track_items(item: Track | Album | Artist, limit=5):
|
||||
tracks: list[Track] = []
|
||||
|
||||
# INFO: If the item is a track, return empty list
|
||||
# to be filled by the results from the top search
|
||||
if isinstance(item, Track):
|
||||
return tracks
|
||||
|
||||
# INFO: If the item is an album, get the tracks from the album
|
||||
if isinstance(item, Album):
|
||||
tracks = TrackStore.get_tracks_by_albumhash(item.albumhash)[:limit]
|
||||
tracks.sort(key=lambda x: x.playduration, reverse=True)
|
||||
return tracks
|
||||
|
||||
# INFO: If the item is an artist, get the tracks from the artist
|
||||
if isinstance(item, Artist):
|
||||
tracks = TrackStore.get_tracks_by_artisthash(item.artisthash)[:limit]
|
||||
tracks.sort(key=lambda x: x.playduration, reverse=True)
|
||||
|
||||
return tracks
|
||||
|
||||
@staticmethod
|
||||
def get_album_items(item: Track | Album | Artist, limit=6):
|
||||
albums: list[Album] = []
|
||||
|
||||
# INFO: If the item is a track or album, search for albums
|
||||
if isinstance(item, Track) or isinstance(item, Album):
|
||||
return albums
|
||||
|
||||
# INFO: If the item is an artist, get the albums from the artist
|
||||
if isinstance(item, Artist):
|
||||
albums = AlbumStore.get_albums_by_artisthash(item.artisthash)[:limit]
|
||||
|
||||
return albums
|
||||
|
||||
@staticmethod
|
||||
def search(
|
||||
query: str,
|
||||
limit: int = None,
|
||||
albums_only=False,
|
||||
tracks_only=False,
|
||||
):
|
||||
tracks_limit = Limit.tracks if tracks_only else 4
|
||||
albums_limit = Limit.albums if albums_only else limit
|
||||
artists_limit = limit
|
||||
|
||||
# INFO: Individually search all stores as each type has a different scorer
|
||||
tracks = SearchTracks(query)(limit=tracks_limit) if not albums_only else []
|
||||
albums = SearchAlbums(query)(limit=albums_limit)
|
||||
artists = SearchArtists(query)(limit=artists_limit)
|
||||
|
||||
# INFO: Combine all results and sort them by score
|
||||
all_results = artists + tracks + albums
|
||||
all_results = sorted(all_results, key=lambda x: int(x._score), reverse=True)
|
||||
|
||||
# INFO: Get the top result
|
||||
top_result = all_results[0]
|
||||
top_tracks = []
|
||||
|
||||
if not albums_only:
|
||||
top_tracks = TopResults.get_track_items(top_result, limit=tracks_limit)
|
||||
|
||||
# INFO: If there are not enough tracks, fill with search results
|
||||
if len(top_tracks) < tracks_limit:
|
||||
found_tracks_set = {track.trackhash for track in top_tracks}
|
||||
|
||||
for track in tracks:
|
||||
if track.trackhash not in found_tracks_set:
|
||||
top_tracks.append(track)
|
||||
|
||||
if len(top_tracks) >= tracks_limit:
|
||||
break
|
||||
|
||||
top_tracks = serialize_tracks(top_tracks)
|
||||
|
||||
if tracks_only:
|
||||
return top_tracks
|
||||
|
||||
top_albums = TopResults.get_album_items(top_result, limit=albums_limit)
|
||||
|
||||
# INFO: If there are not enough albums, fill with search results
|
||||
if len(top_albums) < albums_limit:
|
||||
found_albums_set = {album.albumhash for album in top_albums}
|
||||
|
||||
for album in albums:
|
||||
if album.albumhash not in found_albums_set:
|
||||
top_albums.append(album)
|
||||
|
||||
if len(top_albums) >= albums_limit:
|
||||
break
|
||||
|
||||
top_albums = serialize_albums(top_albums)
|
||||
|
||||
if albums_only:
|
||||
return top_albums
|
||||
|
||||
artists = serialize_for_cards(artists)
|
||||
|
||||
if isinstance(top_result, Track):
|
||||
top_result = serialize_track(top_result)
|
||||
top_result["type"] = "track"
|
||||
|
||||
if isinstance(top_result, Album):
|
||||
top_result = serialize_album(top_result)
|
||||
top_result["type"] = "album"
|
||||
|
||||
if isinstance(top_result, Artist):
|
||||
top_result = serialize_for_card(
|
||||
top_result, include={"albumcount", "trackcount"}
|
||||
)
|
||||
top_result["type"] = "artist"
|
||||
|
||||
return {
|
||||
"top_result": top_result,
|
||||
"tracks": top_tracks,
|
||||
"artists": artists,
|
||||
"albums": top_albums,
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
from itertools import groupby
|
||||
import os
|
||||
from typing import Callable
|
||||
from swingmusic.lib.albumslib import sort_by_track_no
|
||||
from swingmusic.models.folder import Folder
|
||||
from swingmusic.models.track import Track
|
||||
from swingmusic.utils import flatten
|
||||
|
||||
|
||||
def sort_tracks(tracks: list[Track], key: str, reverse: bool = False):
|
||||
"""
|
||||
Sorts a list of tracks by a key.
|
||||
"""
|
||||
if key == "default":
|
||||
return tracks
|
||||
|
||||
sortfunc: Callable[[Track], str] = lambda track: getattr(track, key)
|
||||
if key == "artists" or key == "albumartists":
|
||||
sortfunc = lambda track: getattr(track, key)[0]["name"]
|
||||
|
||||
if key == "disc":
|
||||
# INFO: Group tracks into albums, then sort them by disc number.
|
||||
tracks = sorted(tracks, key=lambda x: x.album.casefold())
|
||||
groups = groupby(tracks, lambda x: x.albumhash)
|
||||
|
||||
return flatten([sort_by_track_no(list(g)) for k, g in groups])
|
||||
|
||||
# INFO: sort tracks by title for a fallback value
|
||||
tracks = sorted(tracks, key=lambda t: t.title.casefold())
|
||||
|
||||
if key == "title" and not reverse:
|
||||
return tracks
|
||||
|
||||
return sorted(
|
||||
tracks,
|
||||
key=lambda track: sortfunc(track).casefold()
|
||||
if isinstance(sortfunc(track), str)
|
||||
else sortfunc(track),
|
||||
reverse=reverse,
|
||||
)
|
||||
|
||||
|
||||
def sort_folders(folders: list[Folder], key: str, reverse: bool = False):
|
||||
"""
|
||||
Sorts a list of folders by a key.
|
||||
"""
|
||||
if key == "default":
|
||||
return folders
|
||||
|
||||
sortfunc: Callable[[Folder], str | float] = lambda folder: getattr(folder, key)
|
||||
|
||||
if key == "lastmod":
|
||||
sortfunc = lambda folder: os.path.getmtime(folder.path)
|
||||
|
||||
return sorted(folders, key=sortfunc, reverse=reverse)
|
||||
@@ -0,0 +1,332 @@
|
||||
import os
|
||||
from functools import partial
|
||||
from multiprocessing import Pool, cpu_count
|
||||
|
||||
from swingmusic import settings
|
||||
from swingmusic.config import UserConfig
|
||||
from swingmusic.db.libdata import TrackTable
|
||||
|
||||
from swingmusic.lib.taglib import extract_thumb, get_tags
|
||||
from swingmusic.models.album import Album
|
||||
from swingmusic.models.artist import Artist
|
||||
from swingmusic.models.track import Track
|
||||
from swingmusic.store.folder import FolderStore
|
||||
from swingmusic.store.tracks import TrackStore
|
||||
from swingmusic.utils import flatten
|
||||
from swingmusic.utils.filesystem import run_fast_scandir
|
||||
from swingmusic.utils.parsers import get_base_album_title
|
||||
from swingmusic.utils.progressbar import tqdm
|
||||
from swingmusic.utils.remove_duplicates import remove_duplicates
|
||||
|
||||
|
||||
from logging import getLogger
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
def parse_file_tags(file: str, config: UserConfig) -> dict | None:
|
||||
"""Worker function to process individual files"""
|
||||
try:
|
||||
return get_tags(file, config=config)
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to process file {file}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class IndexTracks:
|
||||
def __init__(self) -> None:
|
||||
"""
|
||||
Indexes all tracks in the database.
|
||||
|
||||
An instance key is used to prevent multiple instances of the
|
||||
same class from running at the same time.
|
||||
"""
|
||||
dirs_to_scan = UserConfig().rootDirs
|
||||
|
||||
if len(dirs_to_scan) == 0:
|
||||
log.warning(
|
||||
(
|
||||
"The root directory is not configured. "
|
||||
+ "Open the app in your webbrowser to configure."
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
if dirs_to_scan[0] == "$home":
|
||||
dirs_to_scan = [settings.Paths().USER_HOME_DIR.as_posix()]
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
files = set()
|
||||
|
||||
for _dir in dirs_to_scan:
|
||||
files = files.union(run_fast_scandir(_dir, full=True)[1])
|
||||
|
||||
unmodified, modified_tracks = self.filter_modded()
|
||||
untagged = files - unmodified
|
||||
|
||||
self.tag_untagged(untagged)
|
||||
self.extract_thumb_with_overwrite(modified_tracks)
|
||||
|
||||
@staticmethod
|
||||
def extract_thumb_with_overwrite(tracks: list[dict[str, str]]):
|
||||
"""
|
||||
Extracts the thumbnail from a list of filepaths,
|
||||
overwriting the existing thumbnail if it exists,
|
||||
for modified files.
|
||||
"""
|
||||
for track in tracks:
|
||||
try:
|
||||
extract_thumb(
|
||||
track["filepath"], track["albumhash"] + ".webp", overwrite=True, paths=settings.Paths()
|
||||
)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
@staticmethod
|
||||
def filter_modded():
|
||||
"""
|
||||
Removes tracks from the database that have been modified
|
||||
since they were indexed.
|
||||
|
||||
Returns a tuple of unmodified paths and modified tracks.
|
||||
Unmodified paths are indexed and the modified tracks are
|
||||
|
||||
"""
|
||||
|
||||
unmodified_paths = set()
|
||||
modified_tracks: list[dict[str, str]] = []
|
||||
|
||||
to_remove = set()
|
||||
|
||||
for track in TrackTable.get_all():
|
||||
try:
|
||||
if track.last_mod == round(os.path.getmtime(track.filepath)):
|
||||
unmodified_paths.add(track.filepath)
|
||||
continue
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
log.warning(e) # REVIEW More informations = good
|
||||
to_remove.add(track.filepath)
|
||||
|
||||
modified_tracks.append(
|
||||
{
|
||||
"filepath": track.filepath,
|
||||
"albumhash": track.albumhash,
|
||||
}
|
||||
)
|
||||
|
||||
to_remove = to_remove.union(set(t["filepath"] for t in modified_tracks))
|
||||
TrackTable.remove_tracks_by_filepaths(to_remove)
|
||||
|
||||
# REVIEW: Remove after testing!
|
||||
track = TrackTable.get_tracks_by_filepaths(list(to_remove)[:1])
|
||||
if track:
|
||||
raise Exception("Track not removed")
|
||||
# =============================================================
|
||||
|
||||
return unmodified_paths, modified_tracks
|
||||
|
||||
|
||||
def tag_untagged(self, files: set[str]):
|
||||
config = UserConfig()
|
||||
|
||||
# Create process pool with worker function
|
||||
with Pool(processes=max(1, cpu_count() // 2)) as pool:
|
||||
worker = partial(parse_file_tags, config=config)
|
||||
|
||||
# Process files and track progress
|
||||
results = []
|
||||
for result in tqdm(
|
||||
pool.imap_unordered(worker, files),
|
||||
total=len(files),
|
||||
desc="Reading files",
|
||||
):
|
||||
if result is not None:
|
||||
results.append(result)
|
||||
|
||||
# Bulk insert results
|
||||
for tags in results:
|
||||
TrackTable.insert_one(tags)
|
||||
FolderStore.filepaths.add(tags["filepath"])
|
||||
|
||||
print(f"{len(results)} new files indexed")
|
||||
print("Done")
|
||||
|
||||
|
||||
|
||||
#
|
||||
# Create functions
|
||||
#
|
||||
|
||||
def create_albums(_trackhashes: list[str] = []) -> list[tuple[Album, set[str]]]:
|
||||
"""
|
||||
Creates album objects using the indexed tracks. Takes in an optional
|
||||
list of trackhashes to create the albums from. If no list is provided,
|
||||
all tracks are used.
|
||||
|
||||
The trackhashes are passed when creating albums from the watchdogg module.
|
||||
|
||||
Returns a list of tuples containing the album and the trackhashes in the album.
|
||||
ie:
|
||||
|
||||
>>> list[tuple[Album, set[str]]]
|
||||
"""
|
||||
albums = dict()
|
||||
|
||||
if _trackhashes:
|
||||
all_tracks: list[Track] = TrackStore.get_tracks_by_trackhashes(_trackhashes)
|
||||
else:
|
||||
all_tracks: list[Track] = TrackStore.get_flat_list()
|
||||
|
||||
all_tracks = remove_duplicates(all_tracks)
|
||||
|
||||
for track in all_tracks:
|
||||
if track.albumhash not in albums:
|
||||
albums[track.albumhash] = {
|
||||
"albumartists": track.albumartists,
|
||||
"artisthashes": [a["artisthash"] for a in track.albumartists],
|
||||
"albumhash": track.albumhash,
|
||||
"base_title": None,
|
||||
"color": None,
|
||||
"created_date": track.last_mod,
|
||||
"date": track.date,
|
||||
"duration": track.duration,
|
||||
"genres": [*track.genres] if track.genres else [],
|
||||
"og_title": track.og_album,
|
||||
"lastplayed": track.lastplayed,
|
||||
"playcount": track.playcount,
|
||||
"playduration": track.playduration,
|
||||
"title": track.album,
|
||||
"tracks": {track.trackhash},
|
||||
"pathhash": track.pathhash,
|
||||
"extra": {},
|
||||
}
|
||||
else:
|
||||
album = albums[track.albumhash]
|
||||
album["tracks"].add(track.trackhash)
|
||||
album["playcount"] += track.playcount
|
||||
album["playduration"] += track.playduration
|
||||
album["lastplayed"] = max(album["lastplayed"], track.lastplayed)
|
||||
album["duration"] += track.duration
|
||||
album["date"] = min(album["date"], track.date)
|
||||
album["created_date"] = min(album["created_date"], track.last_mod)
|
||||
|
||||
if track.genres:
|
||||
album["genres"].extend(track.genres)
|
||||
|
||||
for album in albums.values():
|
||||
genres = []
|
||||
for genre in album["genres"]:
|
||||
if genre not in genres:
|
||||
genres.append(genre)
|
||||
|
||||
album["genres"] = genres
|
||||
album["genrehashes"] = " ".join([g["genrehash"] for g in genres])
|
||||
album["base_title"], _ = get_base_album_title(album["og_title"])
|
||||
|
||||
del genres
|
||||
trackhashes = album.pop("tracks")
|
||||
album["trackcount"] = len(trackhashes)
|
||||
|
||||
albums[album["albumhash"]] = (Album(**album), trackhashes)
|
||||
|
||||
return list(albums.values())
|
||||
|
||||
|
||||
def create_artists( artisthashes: list[str]) -> list[tuple[Artist, set[str], set[str]]]:
|
||||
"""
|
||||
Creates artist objects using the indexed tracks. Takes in an optional
|
||||
list of artisthashes to create the artists from. If no list is provided,
|
||||
all tracks are used.
|
||||
|
||||
Returns a list of tuples containing the artist, the trackhashes for the artist
|
||||
and the albumhashes for the artist.
|
||||
ie:
|
||||
|
||||
>>> list[tuple[Artist, set[str], set[str]]]
|
||||
"""
|
||||
|
||||
if artisthashes:
|
||||
all_tracks: list[Track] = flatten(
|
||||
[TrackStore.get_tracks_by_artisthash(hash) for hash in artisthashes]
|
||||
)
|
||||
else:
|
||||
all_tracks: list[Track] = TrackStore.get_flat_list()
|
||||
|
||||
all_tracks = remove_duplicates(all_tracks)
|
||||
artists = dict()
|
||||
|
||||
for track in all_tracks:
|
||||
this_artists = [*track.artists]
|
||||
|
||||
for a in track.albumartists:
|
||||
if a not in this_artists:
|
||||
a["in_track"] = False
|
||||
this_artists.append(a)
|
||||
|
||||
for thisartist in this_artists:
|
||||
if thisartist["artisthash"] not in artists:
|
||||
artists[thisartist["artisthash"]] = {
|
||||
"albumcount": None,
|
||||
"albums": {track.albumhash},
|
||||
"artisthash": thisartist["artisthash"],
|
||||
"created_date": track.last_mod,
|
||||
"date": track.date,
|
||||
"duration": track.duration,
|
||||
"genres": track.genres if track.genres else [],
|
||||
"name": None,
|
||||
"names": {thisartist["name"]},
|
||||
"lastplayed": track.lastplayed,
|
||||
"playcount": track.playcount,
|
||||
"playduration": track.playduration,
|
||||
"trackcount": None,
|
||||
"tracks": (
|
||||
{track.trackhash} if thisartist.get("in_track", True) else set()
|
||||
),
|
||||
"extra": {},
|
||||
}
|
||||
else:
|
||||
artist: dict = artists[thisartist["artisthash"]]
|
||||
artist["duration"] += track.duration
|
||||
artist["playcount"] += track.playcount
|
||||
artist["playduration"] += track.playduration
|
||||
artist["albums"].add(track.albumhash)
|
||||
artist["date"] = min(artist["date"], track.date)
|
||||
artist["lastplayed"] = max(artist["lastplayed"], track.lastplayed)
|
||||
artist["created_date"] = min(artist["created_date"], track.last_mod)
|
||||
artist["names"].add(thisartist["name"])
|
||||
|
||||
artist.setdefault("albums", set())
|
||||
|
||||
if thisartist.get("in_track", True):
|
||||
artist["tracks"].add(track.trackhash)
|
||||
|
||||
if track.genres:
|
||||
artist["genres"].extend(track.genres)
|
||||
|
||||
for artist in artists.values():
|
||||
artist["albumcount"] = len(artist["albums"])
|
||||
artist["trackcount"] = len(artist["tracks"])
|
||||
|
||||
genres = []
|
||||
|
||||
for genre in artist["genres"]:
|
||||
if genre not in genres:
|
||||
genres.append(genre)
|
||||
|
||||
artist["genres"] = genres
|
||||
artist["genrehashes"] = " ".join([g["genrehash"] for g in genres])
|
||||
artist["name"] = sorted(artist["names"])[0]
|
||||
|
||||
# INFO: Delete temporary keys
|
||||
del artist["names"]
|
||||
|
||||
tracks = artist.pop("tracks")
|
||||
albums = artist.pop("albums")
|
||||
|
||||
# INFO: Delete local variables
|
||||
del genres
|
||||
|
||||
artists[artist["artisthash"]] = (Artist(**artist), tracks, albums)
|
||||
|
||||
return list(artists.values())
|
||||
@@ -0,0 +1,314 @@
|
||||
import pathlib
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import pendulum
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from tinytag import TinyTag
|
||||
|
||||
from swingmusic.config import UserConfig
|
||||
from swingmusic.settings import Defaults, Paths
|
||||
from swingmusic.utils.hashing import create_hash
|
||||
from swingmusic.utils.parsers import split_artists
|
||||
|
||||
|
||||
def parse_album_art(filepath: str):
|
||||
"""
|
||||
Returns the album art for a given audio file.
|
||||
|
||||
:params filepath: Path to file
|
||||
:returns: `Pil.Image` if available else None
|
||||
"""
|
||||
tags = TinyTag.get(filepath, image=True)
|
||||
image = tags.images.any
|
||||
|
||||
if image:
|
||||
return image.data
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_thumb(filepath: str, webp_path: str, overwrite=False, paths:Paths=None) -> bool:
|
||||
"""
|
||||
Extracts the thumbnail from an audio file.
|
||||
Returns the path to the thumbnail.
|
||||
"""
|
||||
# this function will be run multithreaded.
|
||||
# Modules are not cached in concurrent runs.
|
||||
# If Paths is tried to be imported
|
||||
if paths is None:
|
||||
paths = Paths()
|
||||
|
||||
lg_img_path = paths.lg_thumb_path / webp_path
|
||||
sm_img_path = paths.sm_thumb_path / webp_path
|
||||
xms_img_path = paths.xsm_thumb_path / webp_path
|
||||
md_img_path = paths.md_thumb_path / webp_path
|
||||
|
||||
images = [
|
||||
(lg_img_path, Defaults.LG_THUMB_SIZE),
|
||||
(sm_img_path, Defaults.SM_THUMB_SIZE),
|
||||
(xms_img_path, Defaults.XSM_THUMB_SIZE),
|
||||
(md_img_path, Defaults.MD_THUMB_SIZE),
|
||||
]
|
||||
|
||||
def save_image(img: Image.Image):
|
||||
width, height = img.size
|
||||
ratio = width / height
|
||||
|
||||
for path, size in images:
|
||||
img.resize((size, int(size / ratio)), Image.LANCZOS).save(path, "webp")
|
||||
|
||||
del img
|
||||
|
||||
if not overwrite and sm_img_path.exists():
|
||||
img_size = os.path.getsize(sm_img_path)
|
||||
|
||||
if img_size > 0:
|
||||
return True
|
||||
|
||||
album_art = parse_album_art(filepath)
|
||||
|
||||
if album_art is not None:
|
||||
try:
|
||||
img = Image.open(BytesIO(album_art))
|
||||
except (UnidentifiedImageError, OSError):
|
||||
return False
|
||||
|
||||
try:
|
||||
save_image(img)
|
||||
except OSError:
|
||||
try:
|
||||
png = img.convert("RGB")
|
||||
save_image(png)
|
||||
except: # pylint: disable=bare-except
|
||||
return False
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def parse_date(date_str: str) -> int | None:
|
||||
"""
|
||||
Extracts the date from a string and returns a timestamp.
|
||||
"""
|
||||
try:
|
||||
date = pendulum.parse(date_str, strict=False)
|
||||
return int(date.timestamp())
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
|
||||
def clean_filename(filename: str):
|
||||
if "official" in filename.lower():
|
||||
return re.sub(r"\s*\([^)]*official[^)]*\)", "", filename, flags=re.IGNORECASE)
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParseData:
|
||||
artist: str
|
||||
title: str
|
||||
config: UserConfig
|
||||
|
||||
def __post_init__(self):
|
||||
self.artist = split_artists(self.artist, self.config)
|
||||
|
||||
|
||||
def extract_artist_title(filename: str, config: UserConfig):
|
||||
"""
|
||||
extract data from filename with specified separators
|
||||
|
||||
:params filename: filename
|
||||
:params config: UserConfig for user separators
|
||||
"""
|
||||
|
||||
path = Path(filename).with_suffix("")
|
||||
|
||||
path = clean_filename(str(path))
|
||||
split_result = path.split(" - ")
|
||||
split_result = [x.strip() for x in split_result]
|
||||
|
||||
if len(split_result) == 1:
|
||||
return ParseData(
|
||||
"",
|
||||
split_result[0],
|
||||
config,
|
||||
)
|
||||
|
||||
if len(split_result) > 2:
|
||||
try:
|
||||
int(split_result[0])
|
||||
|
||||
return ParseData(
|
||||
split_result[1],
|
||||
" - ".join(split_result[2:]),
|
||||
config,
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
artist = split_result[0]
|
||||
title = split_result[1]
|
||||
return ParseData(artist, title, config)
|
||||
|
||||
|
||||
def get_tags(filepath: str, config: UserConfig) -> dict:
|
||||
"""
|
||||
Parse tags from an audio file.
|
||||
If tag entries are missing, try getting them from the file name
|
||||
|
||||
:param filepath: Path to file.
|
||||
:param config: UserConfig for ``split`` and ``splitignore`` config
|
||||
:return: Metadata dict
|
||||
:raise FileNotFoundError: If filepath is invalid
|
||||
"""
|
||||
|
||||
filepath = pathlib.Path(filepath)
|
||||
filename = filepath.stem
|
||||
|
||||
if not filepath.exists():
|
||||
raise FileNotFoundError(filepath)
|
||||
|
||||
last_mod = round(filepath.stat().st_mtime)
|
||||
tags = TinyTag.get(filepath)
|
||||
|
||||
if hasattr(tags, "other"):
|
||||
other = tags.other
|
||||
else:
|
||||
other = {}
|
||||
|
||||
metadata: dict[str, Any] = {
|
||||
"album": tags.album,
|
||||
"albumartists": tags.albumartist,
|
||||
"artists": tags.artist,
|
||||
"title": tags.title,
|
||||
"last_mod": last_mod,
|
||||
"filepath": filepath.as_posix(),
|
||||
"folder": filepath.parent.as_posix(),
|
||||
"bitrate": tags.bitrate,
|
||||
"duration": tags.duration,
|
||||
"track": tags.track,
|
||||
"disc": tags.disc,
|
||||
"genres": tags.genre,
|
||||
"copyright": " ".join(other.get("copyright", [])), # INFO: Extract copyright from extra data
|
||||
"extra": {},
|
||||
"date": parse_date(tags.year or "") or int(last_mod)
|
||||
}
|
||||
|
||||
|
||||
# check the necessary tags and set them
|
||||
no_albumartist: bool = (tags.albumartist == "") or (tags.albumartist is None)
|
||||
no_artist: bool = (tags.artist == "") or (tags.artist is None)
|
||||
|
||||
if no_albumartist and not no_artist:
|
||||
# INFO: If no albumartist, use the artist
|
||||
metadata["albumartists"] = tags.artist
|
||||
|
||||
if no_artist and not no_albumartist:
|
||||
# INFO: If no artist, use the albumartist
|
||||
metadata["artists"] = tags.albumartist
|
||||
|
||||
parse_data = None
|
||||
|
||||
# INFO: If title or album is empty, extract the album and title from the filename
|
||||
to_filename = ["title", "album"]
|
||||
for tag in to_filename:
|
||||
p = metadata[tag]
|
||||
if p == "" or p is None:
|
||||
parse_data = extract_artist_title(filename, config)
|
||||
title = parse_data.title.replace("_", " ")
|
||||
metadata[tag] = title
|
||||
|
||||
# INFO: If artist or albumartist is empty
|
||||
# extract the artist and albumartist from the filename
|
||||
parse = ["artists", "albumartists"]
|
||||
for tag in parse:
|
||||
p = metadata[tag]
|
||||
|
||||
if p == "" or p is None:
|
||||
if not parse_data:
|
||||
parse_data = extract_artist_title(filename, config)
|
||||
|
||||
artist = parse_data.artist
|
||||
|
||||
if artist:
|
||||
metadata[tag] = ", ".join(artist)
|
||||
else:
|
||||
metadata[tag] = "Unknown"
|
||||
|
||||
|
||||
# make values beautiful
|
||||
# INFO: If these are empty, set to "Unknown"
|
||||
to_check = ["album", "albumartists"]
|
||||
for prop in to_check:
|
||||
if not metadata[prop]:
|
||||
metadata[prop] = "Unknown"
|
||||
|
||||
# INFO: Round the bitrate and duration
|
||||
to_round = ["bitrate", "duration"]
|
||||
for prop in to_round:
|
||||
try:
|
||||
metadata[prop] = int(getattr(tags, prop))
|
||||
except TypeError:
|
||||
metadata[prop] = 0
|
||||
|
||||
# INFO: Convert these to int
|
||||
to_int = ["track", "disc"]
|
||||
for prop in to_int:
|
||||
try:
|
||||
metadata[prop] = int(getattr(tags, prop))
|
||||
except (ValueError, TypeError):
|
||||
metadata[prop] = 1
|
||||
|
||||
|
||||
# generate hash
|
||||
# create albumhash using og_album
|
||||
metadata["albumhash"] = create_hash(
|
||||
tags.album or "", metadata.get("albumartists", "")
|
||||
)
|
||||
|
||||
metadata["trackhash"] = create_hash(
|
||||
metadata.get("artists", ""),
|
||||
metadata.get("album", ""),
|
||||
metadata.get("title", ""),
|
||||
)
|
||||
|
||||
|
||||
|
||||
# extract extra information not already in tags
|
||||
extra: dict[str, Any] = {
|
||||
k: v for k, v in tags.as_dict().items() if not k in metadata
|
||||
}
|
||||
|
||||
extra["hashinfo"] = {
|
||||
"algo": "sha1",
|
||||
"format": "[:5]+[-5:]", # first 5 + last 5 chars
|
||||
}
|
||||
|
||||
|
||||
# REMOVE EMPTY VALUES
|
||||
to_pop = ["filename", "artists", "albumartist", "year"]
|
||||
for key, value in extra.items():
|
||||
# None --bool--> False --not--> True
|
||||
# [] --bool--> False --not--> True
|
||||
# "" --bool--> False --not--> True
|
||||
# [""] --bool--> True --not--> False
|
||||
|
||||
if isinstance(value, list) and not "".join(value):
|
||||
to_pop.append(key)
|
||||
continue
|
||||
|
||||
if not value:
|
||||
to_pop.append(key)
|
||||
|
||||
for key in to_pop:
|
||||
extra.pop(key, None)
|
||||
|
||||
|
||||
metadata["extra"] = extra
|
||||
return metadata
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
This library contains all the functions related to tracks.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
from swingmusic.lib.pydub.pydub import AudioSegment
|
||||
from swingmusic.lib.pydub.pydub.silence import detect_leading_silence, detect_silence
|
||||
from swingmusic.utils.threading import ProcessWithReturnValue
|
||||
|
||||
|
||||
def get_leading_silence_end(filepath: pathlib.Path):
|
||||
"""
|
||||
Returns the leading silence of a track.
|
||||
"""
|
||||
format = filepath.suffix.replace(".", "")
|
||||
try:
|
||||
audio = AudioSegment.from_file(filepath, format=format)
|
||||
silence = detect_leading_silence(audio, silence_threshold=-40.0, chunk_size=10)
|
||||
except Exception as e:
|
||||
return 0
|
||||
|
||||
return silence if silence > 1000 else 0
|
||||
|
||||
|
||||
def get_trailing_silence_start(filepath: str):
|
||||
"""
|
||||
Returns the trailing silence of a track.
|
||||
"""
|
||||
format = filepath.suffix.replace(".", "")
|
||||
|
||||
try:
|
||||
audio = AudioSegment.from_file(filepath, format=format)
|
||||
duration = len(audio)
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
audio = audio[-30000:] if len(audio) > 30000 else audio
|
||||
silence_groups = detect_silence(audio, silence_thresh=-40.0, seek_step=10)
|
||||
|
||||
if len(silence_groups) == 0:
|
||||
return duration
|
||||
|
||||
silence_group = silence_groups[-1]
|
||||
is_ok = silence_group[1] == len(audio)
|
||||
|
||||
if is_ok:
|
||||
return duration - (silence_group[1] - silence_group[0])
|
||||
|
||||
return duration
|
||||
|
||||
|
||||
def get_silence_paddings(ending_file: str, starting_file: str):
|
||||
"""
|
||||
Returns the ending silence of a track and the starting silence of the next.
|
||||
"""
|
||||
starting_file = pathlib.Path(starting_file)
|
||||
ending_file = pathlib.Path(ending_file)
|
||||
|
||||
|
||||
silence = {"starting_file": 0, "ending_file": 0}
|
||||
ending_thread = None
|
||||
starting_thread = None
|
||||
|
||||
if ending_file.exists():
|
||||
ending_thread = ProcessWithReturnValue(
|
||||
target=get_trailing_silence_start, args=(ending_file,)
|
||||
)
|
||||
ending_thread.start()
|
||||
|
||||
if os.path.exists(starting_file):
|
||||
starting_thread = ProcessWithReturnValue(
|
||||
target=get_leading_silence_end, args=(starting_file,)
|
||||
)
|
||||
starting_thread.start()
|
||||
|
||||
if ending_thread:
|
||||
silence["ending_file"] = ending_thread.join()
|
||||
|
||||
if starting_thread:
|
||||
silence["starting_file"] = starting_thread.join()
|
||||
|
||||
return silence
|
||||
@@ -0,0 +1,75 @@
|
||||
from swingmusic.utils.threading import background
|
||||
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
@background
|
||||
def start_transcoding(
|
||||
input_path: str, output_path: str, bitrate: str, container_args: list[str], compression_level: int = 12
|
||||
):
|
||||
"""
|
||||
Starts a background transcoding process for an audio file.
|
||||
|
||||
This function uses FFmpeg to transcode an audio file from one format to another,
|
||||
with specified bitrate and container format. It runs as a background task.
|
||||
|
||||
Args:
|
||||
input_path (str): The path to the input audio file.
|
||||
output_path (str): The path where the transcoded file will be saved.
|
||||
bitrate (str): The desired bitrate for the output file (e.g., "128k").
|
||||
container_args (list[str]): FFmpeg arguments specific to the output container format.
|
||||
compression_level (int): Compression level (0-9, default: 6).
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Note:
|
||||
This function is decorated with @background, which means it runs asynchronously.
|
||||
The actual transcoding process is handled by FFmpeg in a subprocess.
|
||||
The function will print status messages about the transcoding process.
|
||||
"""
|
||||
# Base command
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-i",
|
||||
input_path,
|
||||
"-map_metadata", "0", # Add this line to copy metadata
|
||||
"-b:a",
|
||||
bitrate,
|
||||
"-vn",
|
||||
"-compression_level",
|
||||
str(compression_level),
|
||||
# REVIEW: Idk what any flag below this point does!
|
||||
"-movflags", "faststart+frag_keyframe+empty_moov", # TODO. specify fragment size
|
||||
"-write_xing", "0", # ffmpeg.org/ffmpeg-formats.html
|
||||
"-fflags", "+bitexact", #
|
||||
]
|
||||
|
||||
# Add format-specific parameters
|
||||
command.extend(container_args)
|
||||
|
||||
# Add output path and overwrite flag
|
||||
command.extend([output_path, "-y"])
|
||||
|
||||
process = subprocess.Popen(
|
||||
command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||
)
|
||||
print(f"Started transcoding process with PID: {process.pid}")
|
||||
|
||||
try:
|
||||
# Wait for the process to complete
|
||||
process.wait()
|
||||
print(f"Transcoding process (PID: {process.pid}) completed")
|
||||
except KeyboardInterrupt:
|
||||
print(f"Transcoding interrupted. Terminating process (PID: {process.pid})")
|
||||
finally:
|
||||
# Ensure the process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=5) # Wait up to 5 seconds for graceful termination
|
||||
except subprocess.TimeoutExpired:
|
||||
print(
|
||||
f"Process (PID: {process.pid}) did not terminate gracefully. Killing..."
|
||||
)
|
||||
process.kill()
|
||||
@@ -0,0 +1,372 @@
|
||||
"""
|
||||
This library contains the classes and functions related to the watchdog file watcher.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
from watchdog.events import PatternMatchingEventHandler
|
||||
from watchdog.observers.api import BaseObserverSubclassCallable
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from swingmusic import settings
|
||||
from swingmusic.config import UserConfig
|
||||
from swingmusic.db.libdata import TrackTable
|
||||
from swingmusic.db.userdata import LibDataTable
|
||||
from swingmusic.lib.colorlib import process_color
|
||||
from swingmusic.lib.tagger import create_albums, create_artists
|
||||
from swingmusic.lib.taglib import extract_thumb, get_tags
|
||||
from swingmusic.logger import log
|
||||
from swingmusic.models import Artist, Track
|
||||
from swingmusic.store.albums import AlbumStore
|
||||
from swingmusic.store.artists import ArtistMapEntry, ArtistStore
|
||||
from swingmusic.store.tracks import TrackStore
|
||||
|
||||
|
||||
class Watcher:
|
||||
"""
|
||||
Contains the methods for initializing and starting watchdog.
|
||||
"""
|
||||
|
||||
observers: list[BaseObserverSubclassCallable] = []
|
||||
|
||||
def __init__(self):
|
||||
self.observer = Observer()
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Starts watchers for each dir in root_dirs
|
||||
"""
|
||||
|
||||
trials = 0
|
||||
|
||||
while trials < 10:
|
||||
try:
|
||||
# dirs = sdb.get_root_dirs()
|
||||
dirs = UserConfig().rootDirs
|
||||
dirs = [rf"{d}" for d in dirs]
|
||||
|
||||
dir_map = [
|
||||
{"original": d, "realpath": os.path.realpath(d)} for d in dirs
|
||||
]
|
||||
break
|
||||
except sqlite3.OperationalError:
|
||||
trials += 1
|
||||
time.sleep(1)
|
||||
else:
|
||||
log.error(
|
||||
"WatchDogError: Failed to start Watchdog. Waiting for database timed out!"
|
||||
)
|
||||
return
|
||||
|
||||
if len(dirs) == 0:
|
||||
log.warning(
|
||||
"WatchDogInfo: No root directories configured. Watchdog not started."
|
||||
)
|
||||
return
|
||||
|
||||
dir_map = [d for d in dir_map if d["realpath"] != d["original"]]
|
||||
|
||||
# if len(dirs) > 0 and dirs[0] == "$home":
|
||||
# dirs = [settings.USER_HOME_DIR]
|
||||
|
||||
if any([d == "$home" for d in dirs]):
|
||||
dirs = [settings.Paths().USER_HOME_DIR]
|
||||
|
||||
event_handler = Handler(root_dirs=dirs, dir_map=dir_map)
|
||||
|
||||
for _dir in dirs:
|
||||
exists = os.path.exists(_dir)
|
||||
|
||||
if not exists:
|
||||
log.error("WatchdogError: Directory not found: %s", _dir)
|
||||
|
||||
for _dir in dirs:
|
||||
self.observer.schedule(
|
||||
event_handler, os.path.realpath(_dir), recursive=True
|
||||
)
|
||||
self.observers.append(self.observer)
|
||||
|
||||
try:
|
||||
self.observer.start()
|
||||
log.info("Started watchdog")
|
||||
except (FileNotFoundError, PermissionError):
|
||||
log.error(
|
||||
"WatchdogError: Failed to start watchdog, root directories could not be resolved."
|
||||
)
|
||||
return
|
||||
except OSError as e:
|
||||
log.error("Failed to start watchdog. %s", e)
|
||||
return
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
self.stop_all()
|
||||
|
||||
for obsv in self.observers:
|
||||
obsv.join()
|
||||
|
||||
def stop_all(self):
|
||||
"""
|
||||
Unschedules and stops all existing watchers.
|
||||
"""
|
||||
log.info("Stopping all watchdog observers")
|
||||
for obsv in self.observers:
|
||||
obsv.unschedule_all()
|
||||
obsv.stop()
|
||||
|
||||
def restart(self):
|
||||
"""
|
||||
Stops all existing watchers, refetches root_dirs from the db
|
||||
and restarts the watchers.
|
||||
"""
|
||||
log.info("🔃 Restarting watchdog")
|
||||
self.stop_all()
|
||||
self.run()
|
||||
|
||||
|
||||
def handle_color(albumhash: str):
|
||||
entry = LibDataTable.find_one(albumhash, "album")
|
||||
|
||||
if entry and entry.color:
|
||||
return
|
||||
|
||||
colors = process_color(albumhash, is_album=True)
|
||||
|
||||
if colors:
|
||||
return
|
||||
|
||||
if entry is None:
|
||||
LibDataTable.insert_one(
|
||||
{"itemhash": albumhash, "color": colors[0], "itemtype": "album"}
|
||||
)
|
||||
else:
|
||||
LibDataTable.update_one(albumhash, {"color": colors[0]})
|
||||
|
||||
return colors
|
||||
|
||||
|
||||
def add_track(filepath: str) -> None:
|
||||
"""
|
||||
Processes the audio tags for a given file ands add them to the database and store.
|
||||
|
||||
Then creates the folder, album and artist objects for the added track and adds them to the store.
|
||||
"""
|
||||
|
||||
TrackStore.remove_track_by_filepath(filepath)
|
||||
|
||||
config = UserConfig()
|
||||
tags = get_tags(filepath, config)
|
||||
|
||||
# if the track is somehow invalid, return
|
||||
if tags is None or tags["bitrate"] == 0 or tags["duration"] == 0:
|
||||
return
|
||||
|
||||
TrackTable.insert_one(tags)
|
||||
extract_thumb(filepath, tags["albumhash"] + ".webp", overwrite=True)
|
||||
|
||||
colors = handle_color(tags["albumhash"])
|
||||
track = Track(**tags)
|
||||
TrackStore.add_track(track)
|
||||
|
||||
# SECTION: Index album
|
||||
albumentry = AlbumStore.albummap.get(track.albumhash)
|
||||
|
||||
if albumentry is None:
|
||||
album, trackhashes = create_albums([track.trackhash])[0]
|
||||
AlbumStore.index_new_album(album, trackhashes)
|
||||
else:
|
||||
trackhash_exists = track.trackhash in albumentry.trackhashes
|
||||
|
||||
if not trackhash_exists:
|
||||
albumentry.trackhashes.add(track.trackhash)
|
||||
albumentry.album.trackcount += 1
|
||||
albumentry.set_color(colors[0]) if colors else None
|
||||
|
||||
# SECTION: Index artist
|
||||
artists = create_artists(track.artisthashes)
|
||||
|
||||
for artist in artists:
|
||||
ArtistStore.artistmap[artist[0].artisthash] = ArtistMapEntry(
|
||||
artist=artist[0],
|
||||
albumhashes=artist[1],
|
||||
trackhashes=artist[2],
|
||||
)
|
||||
|
||||
|
||||
def remove_track(filepath: str) -> None:
|
||||
"""
|
||||
Removes a track from the music dict.
|
||||
"""
|
||||
try:
|
||||
track = TrackStore.get_tracks_by_filepaths([filepath])[0]
|
||||
except IndexError:
|
||||
return
|
||||
|
||||
db.remove_tracks_by_filepaths(filepath)
|
||||
TrackStore.remove_track_by_filepath(filepath)
|
||||
|
||||
empty_album = TrackStore.count_tracks_by_trackhash(track.albumhash) > 0
|
||||
|
||||
if empty_album:
|
||||
AlbumStore.remove_album_by_hash(track.albumhash)
|
||||
|
||||
artists: list[Artist] = track.artists + track.albumartists # type: ignore
|
||||
|
||||
for artist in artists:
|
||||
empty_artist = not ArtistStore.artist_has_tracks(artist.artisthash)
|
||||
|
||||
if empty_artist:
|
||||
ArtistStore.remove_artist_by_hash(artist.artisthash)
|
||||
|
||||
|
||||
class Handler(PatternMatchingEventHandler):
|
||||
files_to_process = []
|
||||
files_to_process_windows = []
|
||||
file_sizes = {}
|
||||
|
||||
root_dirs = []
|
||||
dir_map = []
|
||||
|
||||
def __init__(self, root_dirs: list[str], dir_map: dict[str:str]):
|
||||
self.root_dirs = root_dirs
|
||||
self.dir_map = dir_map
|
||||
patterns = [f"*{f}" for f in settings.SUPPORTED_FILES]
|
||||
|
||||
PatternMatchingEventHandler.__init__(
|
||||
self,
|
||||
patterns=patterns,
|
||||
ignore_directories=True,
|
||||
)
|
||||
|
||||
def get_abs_path(self, path: str):
|
||||
"""
|
||||
Convert a realpath to a path relative to the matching root directory.
|
||||
"""
|
||||
for d in self.dir_map:
|
||||
if d["realpath"] in path:
|
||||
return path.replace(d["realpath"], d["original"])
|
||||
|
||||
return path
|
||||
|
||||
def on_created(self, event):
|
||||
"""
|
||||
Fired when a supported file is created.
|
||||
"""
|
||||
try:
|
||||
self.file_sizes[event.src_path] = os.path.getsize(event.src_path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
self.files_to_process.append(event.src_path)
|
||||
self.files_to_process_windows.append(event.src_path)
|
||||
|
||||
def on_deleted(self, event):
|
||||
"""
|
||||
Fired when a delete event occurs on a supported file.
|
||||
"""
|
||||
path = self.get_abs_path(event.src_path)
|
||||
remove_track(path)
|
||||
|
||||
def on_moved(self, event):
|
||||
"""
|
||||
Fired when a move event occurs on a supported file.
|
||||
"""
|
||||
trash = "share/Trash"
|
||||
|
||||
if trash in event.dest_path:
|
||||
path = self.get_abs_path(event.src_path)
|
||||
remove_track(path)
|
||||
|
||||
elif trash in event.src_path:
|
||||
path = self.get_abs_path(event.dest_path)
|
||||
add_track(path)
|
||||
|
||||
elif trash not in event.dest_path and trash not in event.src_path:
|
||||
dest_path = self.get_abs_path(event.dest_path)
|
||||
src_path = self.get_abs_path(event.src_path)
|
||||
|
||||
add_track(dest_path)
|
||||
remove_track(src_path)
|
||||
|
||||
def on_closed(self, event):
|
||||
"""
|
||||
Fired when a created file is closed.
|
||||
NOT FIRED IN WINDOWS
|
||||
"""
|
||||
try:
|
||||
# Get initial file size
|
||||
initial_size = os.path.getsize(event.src_path)
|
||||
|
||||
# Wait for 10 seconds
|
||||
time.sleep(10)
|
||||
|
||||
# Check if file size has changed
|
||||
current_size = os.path.getsize(event.src_path)
|
||||
|
||||
if current_size > 0 and current_size == initial_size:
|
||||
path = self.get_abs_path(event.src_path)
|
||||
add_track(path)
|
||||
# Remove from processing list only after successful processing
|
||||
self.files_to_process.remove(event.src_path)
|
||||
else:
|
||||
# File is still being modified or has been deleted
|
||||
log.info(
|
||||
f"File {event.src_path} is still being modified. Skipping processing for now."
|
||||
)
|
||||
except FileNotFoundError:
|
||||
# file was closed and deleted.
|
||||
log.info(f"File {event.src_path} was closed and deleted before processing.")
|
||||
except ValueError:
|
||||
# file was already removed from the list by another event handler.
|
||||
log.info(
|
||||
f"File {event.src_path} was already removed from the processing list."
|
||||
)
|
||||
|
||||
def on_modified(self, event):
|
||||
# this event handler is triggered twice on windows
|
||||
# for copy events. We need to test how this behaves in
|
||||
# Linux.
|
||||
|
||||
if event.src_path not in self.files_to_process_windows:
|
||||
return
|
||||
|
||||
# Check if file write operation is complete
|
||||
try:
|
||||
current_size = os.path.getsize(event.src_path)
|
||||
except FileNotFoundError:
|
||||
# File was deleted or moved
|
||||
return
|
||||
|
||||
previous_size = self.file_sizes.get(event.src_path, -1)
|
||||
|
||||
if current_size == previous_size:
|
||||
# Wait for a short duration to ensure the file write operation is complete
|
||||
time.sleep(10)
|
||||
|
||||
# Check the file size again
|
||||
try:
|
||||
current_size = os.path.getsize(event.src_path)
|
||||
except FileNotFoundError:
|
||||
# File was deleted or moved
|
||||
return
|
||||
|
||||
if current_size == previous_size:
|
||||
try:
|
||||
os.rename(event.src_path, event.src_path)
|
||||
path = self.get_abs_path(event.src_path)
|
||||
remove_track(path)
|
||||
add_track(path)
|
||||
self.files_to_process_windows.remove(event.src_path)
|
||||
del self.file_sizes[event.src_path]
|
||||
except OSError:
|
||||
# File is locked
|
||||
pass
|
||||
return
|
||||
|
||||
# Update the file size for the next iteration
|
||||
self.file_sizes[event.src_path] = current_size
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Logger module
|
||||
"""
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import datetime as dt
|
||||
import json
|
||||
import logging.config
|
||||
import logging.handlers
|
||||
|
||||
|
||||
LOG_RECORD_BUILTIN_ATTRS = {
|
||||
"args",
|
||||
"asctime",
|
||||
"created",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"filename",
|
||||
"funcName",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"lineno",
|
||||
"module",
|
||||
"msecs",
|
||||
"message",
|
||||
"msg",
|
||||
"name",
|
||||
"pathname",
|
||||
"process",
|
||||
"processName",
|
||||
"relativeCreated",
|
||||
"stack_info",
|
||||
"thread",
|
||||
"threadName",
|
||||
"taskName",
|
||||
}
|
||||
|
||||
|
||||
class JsonFormat(logging.Formatter):
|
||||
def __init__(self, *, fmt_keys: dict[str, str] | None = None,):
|
||||
|
||||
super().__init__()
|
||||
self.fmt_keys = fmt_keys or {}
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
message = self._prepare_log_dict(record)
|
||||
return json.dumps(message, default=str)
|
||||
|
||||
def _prepare_log_dict(self, record: logging.LogRecord):
|
||||
always_fields = {
|
||||
"args": record.args,
|
||||
"name": record.name,
|
||||
"line": record.lineno,
|
||||
"message": record.getMessage(),
|
||||
"timestamp": dt.datetime.fromtimestamp(record.created, tz=dt.timezone.utc).isoformat(),
|
||||
"who": record.name
|
||||
}
|
||||
|
||||
if record.exc_info is not None:
|
||||
always_fields["exc_info"] = self.formatException(record.exc_info)
|
||||
|
||||
if record.stack_info is not None:
|
||||
always_fields["stack_info"] = self.formatStack(record.stack_info)
|
||||
|
||||
message = {}
|
||||
|
||||
for key, val in self.fmt_keys.items():
|
||||
if (msg_val := always_fields.pop(val, None)) is not None:
|
||||
message[key] = msg_val
|
||||
else:
|
||||
message[key] = getattr(record, val)
|
||||
|
||||
message.update(always_fields)
|
||||
|
||||
for key, val in record.__dict__.items():
|
||||
if key not in LOG_RECORD_BUILTIN_ATTRS:
|
||||
message[key] = val
|
||||
|
||||
return message
|
||||
|
||||
|
||||
class CustomFormatter(logging.Formatter):
|
||||
"""
|
||||
Custom log formatter
|
||||
"""
|
||||
|
||||
grey = "\033[92m"
|
||||
yellow = "\x1b[33;20m"
|
||||
red = "\033[41m"
|
||||
bold_red = "\x1b[31;1m"
|
||||
reset = "\x1b[0m"
|
||||
# format_ = "[%(asctime)s] %(name)s %(levelname)s %(message)s (%(filename)s:%(lineno)d)"
|
||||
format_ = "[%(asctime)s] [%(levelname)s] %(message)s (%(filename)s:%(lineno)d)"
|
||||
# format_ = "%(message)s"
|
||||
|
||||
FORMATS = {
|
||||
logging.DEBUG: grey + format_ + reset,
|
||||
logging.INFO: grey + format_ + reset,
|
||||
logging.WARNING: yellow + format_ + reset,
|
||||
logging.ERROR: red + format_ + reset,
|
||||
logging.CRITICAL: bold_red + format_ + reset,
|
||||
}
|
||||
|
||||
def __init__(self, *, fmt_keys: dict[str, str] | None = None,):
|
||||
|
||||
super().__init__()
|
||||
self.fmt_keys = fmt_keys or {}
|
||||
|
||||
def format(self, record):
|
||||
log_fmt = self.FORMATS.get(record.levelno)
|
||||
#record.exc_info = None
|
||||
#record.exc_text = None
|
||||
self._style = logging.PercentStyle(log_fmt)
|
||||
self._fmt = self._style._fmt
|
||||
|
||||
self.datefmt = "%H:%M:%S"
|
||||
return super().format(record)
|
||||
|
||||
def formatException(self, e):
|
||||
# do not print on cli only in file.
|
||||
# TODO: inform user that non terminal exception happened?
|
||||
return ""
|
||||
|
||||
def formatStack(self, stack_info):
|
||||
return ""
|
||||
|
||||
CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
|
||||
"formatters": {
|
||||
"json": {
|
||||
"()": JsonFormat,
|
||||
"fmt_keys": {
|
||||
"level": "levelname",
|
||||
"message": "message",
|
||||
"timestamp": "timestamp",
|
||||
"logger": "name",
|
||||
"module": "module",
|
||||
"function": "funcName",
|
||||
"line": "lineno"
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"()": CustomFormatter,
|
||||
"fmt_keys": {
|
||||
"level": "levelname",
|
||||
"message": "message",
|
||||
"timestamp": "timestamp",
|
||||
"logger": "name",
|
||||
"module": "module",
|
||||
"function": "funcName",
|
||||
"line": "lineno"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers": {
|
||||
"stdout": {
|
||||
"class": "logging.StreamHandler",
|
||||
"level": "INFO",
|
||||
"formatter": "custom",
|
||||
"stream": "ext://sys.stderr"
|
||||
},
|
||||
"file": {
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"level": "DEBUG",
|
||||
"formatter": "json",
|
||||
"maxBytes": 5*1024*1024, # 5 MB
|
||||
"backupCount": 5
|
||||
},
|
||||
"remote": {
|
||||
"class": "logging.handlers.SocketHandler",
|
||||
"level": "DEBUG",
|
||||
"formatter": "json",
|
||||
"host": "127.0.0.2",
|
||||
"port": "19996"
|
||||
}
|
||||
},
|
||||
"loggers": {
|
||||
"swingmusic": {
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
"handlers": [
|
||||
"stdout",
|
||||
"file"
|
||||
]
|
||||
},
|
||||
"waitress": {
|
||||
"level": "ERROR",
|
||||
"propagate": False,
|
||||
"handlers": [
|
||||
"stdout",
|
||||
"file"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log = None
|
||||
|
||||
def setup_logger(app_dir:Path, debug=False):
|
||||
"""
|
||||
setup logger
|
||||
needs to be called at the beginning and at least once
|
||||
|
||||
:param app_dir: logging directory
|
||||
:param debug: When True Loglevel is set to DEBUG and enable Socket log
|
||||
"""
|
||||
|
||||
if Path.home().resolve().as_posix() == app_dir.resolve().as_posix():
|
||||
app_name = ".swingmusic"
|
||||
else:
|
||||
app_name = "swingmusic"
|
||||
|
||||
log_dir = Path(app_dir) / app_name / "logs"
|
||||
if not log_dir.exists():
|
||||
log_dir.mkdir(parents=True)
|
||||
|
||||
CONFIG["handlers"]["file"]["filename"] = log_dir / "log.jsonl"
|
||||
|
||||
# enable socket log
|
||||
if debug:
|
||||
logging.warning("YOU ARE IN DEBUG MODE.")
|
||||
for key in CONFIG["loggers"].keys():
|
||||
CONFIG["loggers"][key]["handlers"].append("remote")
|
||||
CONFIG["loggers"][key]["level"] = "DEBUG"
|
||||
|
||||
logging.config.dictConfig(CONFIG)
|
||||
|
||||
global log
|
||||
log = logging.getLogger(__name__)
|
||||
log.info("setup successfully")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user