diff --git a/src/server/config/cacheStrategy.py b/src/server/config/cacheStrategy.py index 5a12cd2d5..f0334e0bc 100644 --- a/src/server/config/cacheStrategy.py +++ b/src/server/config/cacheStrategy.py @@ -1,12 +1,13 @@ # -*- coding: utf-8 -*- """reAudioPlayer ONE""" from __future__ import annotations + __copyright__ = "Copyright (c) 2023 https://github.com/reAudioPlayer" from typing import Optional, TYPE_CHECKING import asyncio -from player.playerPlaylist import PlayerPlaylist +from player.iPlayerPlaylist import IPlayerPlaylist from dataModel.song import Song from helper.singleton import Singleton from helper.songCache import SongCache @@ -17,14 +18,15 @@ from player.player import Player -class ICacheStrategy(metaclass = Singleton): +class ICacheStrategy(metaclass=Singleton): """ICacheStrategy""" + __slots__ = ("_playlist", "_downloader", "_player") _STRATEGY: CacheStrategy = CacheStrategy.None_ _INSTANCE: Optional[ICacheStrategy] = None def __init__(self, player: Player) -> None: - self._playlist: Optional[PlayerPlaylist] = None + self._playlist: Optional[IPlayerPlaylist] = None self._downloader = Downloader() self._player = player @@ -53,11 +55,10 @@ def get(cls, strategy: CacheStrategy, player: Player) -> ICacheStrategy: asyncio.create_task(cls._INSTANCE.onStrategyLoad()) return cls._INSTANCE - async def onSongLoad(self, song: Song) -> None: """on song change""" - async def onPlaylistLoad(self, playlist: PlayerPlaylist) -> None: + async def onPlaylistLoad(self, playlist: IPlayerPlaylist) -> None: """on playlist change""" self._playlist = playlist @@ -66,19 +67,23 @@ async def onStrategyLoad(self) -> None: self._playlist = self._player.currentPlaylist -class CacheAllStrategy(ICacheStrategy, metaclass = Singleton): +class CacheAllStrategy(ICacheStrategy, metaclass=Singleton): """CacheAllStrategy""" + async def onStrategyLoad(self) -> None: await super().onStrategyLoad() + async def _task() -> None: for playlist in self._player.playlistManager.playlists: for song in playlist: await self._downloader.downloadSong(song.model) + asyncio.create_task(_task()) -class CachePlaylistStrategy(ICacheStrategy, metaclass = Singleton): +class CachePlaylistStrategy(ICacheStrategy, metaclass=Singleton): """CachePlaylistStrategy""" + async def _downloadTask(self) -> None: assert self._playlist is not None SongCache.prune(self._playlist) @@ -91,19 +96,21 @@ async def onStrategyLoad(self) -> None: return asyncio.create_task(self._downloadTask()) - async def onPlaylistLoad(self, playlist: PlayerPlaylist) -> None: + async def onPlaylistLoad(self, playlist: IPlayerPlaylist) -> None: await super().onPlaylistLoad(playlist) asyncio.create_task(self._downloadTask()) -class CacheCurrentStrategy(ICacheStrategy, metaclass = Singleton): +class CacheCurrentStrategy(ICacheStrategy, metaclass=Singleton): """CacheCurrentStrategy""" + async def onSongLoad(self, song: Song) -> None: SongCache.prune([song]) -class CacheCurrentNextStrategy(ICacheStrategy, metaclass = Singleton): +class CacheCurrentNextStrategy(ICacheStrategy, metaclass=Singleton): """CacheCurrentNextStrategy""" + async def onSongLoad(self, song: Song) -> None: assert self._playlist is not None currentSong, nextSong = song, self._playlist.next(True) diff --git a/src/server/db/database.py b/src/server/db/database.py index 59792918b..ba69eed03 100644 --- a/src/server/db/database.py +++ b/src/server/db/database.py @@ -12,6 +12,7 @@ from helper.singleton import Singleton from db.table.songs import SongsTable from db.table.playlists import PlaylistsTable +from db.table.smartPlaylists import SmartPlaylistTable from db.table.artists import ArtistsTable from config.runtime import Runtime @@ -22,6 +23,7 @@ class Database(metaclass=Singleton): "_songs", "_playlists", "_artists", + "_smartPlaylists", "_autoCommit", "_logger") @@ -29,6 +31,7 @@ def __init__(self) -> None: self._db: Optional[aiosqlite.Connection] = None self._songs: Optional[SongsTable] = None self._playlists: Optional[PlaylistsTable] = None + self._smartPlaylists: Optional[SmartPlaylistTable] = None self._artists: Optional[ArtistsTable] = None self._autoCommit: Optional[asyncio.Task[None]] = None self._logger = logging.getLogger("Database") @@ -48,11 +51,13 @@ async def init(self) -> None: self._songs = SongsTable(self._db) self._playlists = PlaylistsTable(self._db) self._artists = ArtistsTable(self._db) + self._smartPlaylists = SmartPlaylistTable(self._db) await asyncio.gather( self._songs.create(), self._playlists.create(), - self._artists.create() + self._artists.create(), + self._smartPlaylists.create() ) self._autoCommit = asyncio.create_task(self._autoCommitTask()) @@ -80,6 +85,12 @@ def artists(self) -> ArtistsTable: assert self._artists is not None return self._artists + @property + def smartPlaylists(self) -> SmartPlaylistTable: + """Return smartPlaylists table""" + assert self._smartPlaylists is not None + return self._smartPlaylists + @property def ready(self) -> bool: """Return if database is ready""" @@ -91,4 +102,6 @@ def ready(self) -> bool: return False if self._artists is None: return False + if self._smartPlaylists is None: + return False return True diff --git a/src/server/db/table/iPlaylistModel.py b/src/server/db/table/iPlaylistModel.py new file mode 100644 index 000000000..6b1d2792e --- /dev/null +++ b/src/server/db/table/iPlaylistModel.py @@ -0,0 +1,27 @@ +from abc import ABC, abstractmethod + +class IPlaylistModel(ABC): + @property + @abstractmethod + def name(self) -> str: + """playlist name""" + + @property + @abstractmethod + def description(self) -> str: + """playlist description""" + + @property + @abstractmethod + def cover(self) -> str: + """playlist cover""" + + @property + @abstractmethod + def plays(self) -> int: + """plays""" + + @property + @abstractmethod + def id(self) -> int: + """db id""" diff --git a/src/server/db/table/playlists.py b/src/server/db/table/playlists.py index 15a282a4a..d9b34728f 100644 --- a/src/server/db/table/playlists.py +++ b/src/server/db/table/playlists.py @@ -1,27 +1,33 @@ # -*- coding: utf-8 -*- """reAudioPlayer ONE""" from __future__ import annotations + __copyright__ = "Copyright (c) 2023 https://github.com/reAudioPlayer" -from typing import Type, Tuple, Optional, Dict, Any +from typing import Type, Tuple, Optional, Dict, Any, List +import json import aiosqlite from db.table.table import ITable, IModel +from db.table.iPlaylistModel import IPlaylistModel -class PlaylistModel(IModel): +class PlaylistModel(IModel, IPlaylistModel): """playlist model""" + __slots__ = ("_id", "_name", "_songs", "_description", "_cover", "_plays") _SQLType = Tuple[int, str, str, str, str, int] _SQLInsertType = Tuple[str, str, str, str, int] COLUMNS = ["id", "name", "description", "cover", "songs", "plays"] - def __init__(self, - name: str, - description: Optional[str] = None, - cover: Optional[str] = None, - songs: str = "[]", - plays: Optional[int] = None, - id_: Optional[int] = None) -> None: + def __init__( + self, + name: str, + description: Optional[str] = None, + cover: Optional[str] = None, + songs: str = "[]", + plays: Optional[int] = None, + id_: Optional[int] = None, + ) -> None: self._id = id_ self._name = name or "" self._songs = songs or "[]" @@ -33,7 +39,7 @@ def __init__(self, @classmethod def fromTuple(cls, row: aiosqlite.Row) -> PlaylistModel: id_, others = row[0], row[1:] - return cls(*others, id_) # type: ignore + return cls(*others, id_) # type: ignore @property def insertStatement(self) -> str: @@ -48,13 +54,7 @@ def updateStatement(self) -> str: return f"{', '.join([f'{item}=?' for item in items])}" def toTuple(self) -> _SQLInsertType: - return ( - self._name, - self._description, - self._cover, - self._songs, - self._plays - ) + return (self._name, self._description, self._cover, self._songs, self._plays) @property def eq(self) -> str: @@ -108,6 +108,15 @@ def songs(self, songs: str) -> None: self._songs = songs self._fireChanged() + @property + def songsList(self) -> List[int]: + """list of songs""" + return json.loads(self._songs) # type: ignore + + @songsList.setter + def songsList(self, songs: List[int]) -> None: + self._songs = json.dumps(songs) + @property def plays(self) -> int: """plays of the playlist""" @@ -134,12 +143,13 @@ def toDict(self) -> Dict[str, Any]: "description": self._description, "cover": self._cover, "songs": self._songs, - "plays": self._plays + "plays": self._plays, } class PlaylistsTable(ITable[PlaylistModel]): """playlist table""" + NAME = "Playlists" DESCRIPTION = """ id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, @@ -155,7 +165,7 @@ def _model(self) -> Type[PlaylistModel]: async def byId(self, id_: int) -> Optional[PlaylistModel]: """get playlist by id""" - return await self.selectOne(append = f"WHERE id = {id_}") + return await self.selectOne(append=f"WHERE id = {id_}") async def deleteById(self, id_: int) -> None: """delete playlist by id""" diff --git a/src/server/db/table/smartPlaylists.py b/src/server/db/table/smartPlaylists.py new file mode 100644 index 000000000..ece3067e2 --- /dev/null +++ b/src/server/db/table/smartPlaylists.py @@ -0,0 +1,224 @@ +# -*- coding: utf-8 -*- +"""reAudioPlayer ONE""" +from __future__ import annotations + +__copyright__ = "Copyright (c) 2023 https://github.com/reAudioPlayer" + +from typing import Type, Tuple, Optional, List, Any, Dict +import json +from hashids import Hashids # type: ignore +import aiosqlite +from db.table.table import ITable, IModel +from db.table.iPlaylistModel import IPlaylistModel + + +hashids = Hashids(salt="reapOne.smartPlaylist", min_length=22) + + +class SmartPlaylistModel(IModel, IPlaylistModel): + """smart playlist model""" + + __slots__ = ("_id", "_name", "_description", "_definition", "_cover", "_plays") + _SQLType = Tuple[int, str, str, str, str, int] + _SQLInsertType = Tuple[str, str, str, str, int] + COLUMNS = [ + "id", + "name", + "description", + "cover", + "definition", + "plays", + ] + + def __init__( + self, + name: str, + description: str, + cover: str, + definition: str, + plays: int, + id_: Optional[int] = None, + ) -> None: + self._id = id_ + self._name = name or "" + self._description = description or "" + self._cover = cover or "" + self._definition = definition or "{}" + self._plays = plays or 0 + super().__init__() + + @classmethod + def fromTuple(cls, row: aiosqlite.Row) -> SmartPlaylistModel: + id_, others = row[0], row[1:] + return cls(*others, id_) # type: ignore + + @classmethod + def empty(cls) -> SmartPlaylistModel: + return cls("", "", "", "", 0) + + @property + def insertStatement(self) -> str: + items = [*self.COLUMNS] + items.remove("id") + return f"({', '.join(items)}) VALUES ({', '.join(['?' for _ in items])})" + + @property + def updateStatement(self) -> str: + items = [*self.COLUMNS] + items.remove("id") + return f"{', '.join([f'{item}=?' for item in items])}" + + def toTuple(self) -> _SQLInsertType: + return ( + self._name, + self._description, + self._cover, + self._definition, + self._plays, + ) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, SmartPlaylistModel): + return False + if self._id != other._id: + return False + if self._name != other._name: + return False + if self._description != other._description: + return False + if self._cover != other._cover: + return False + if self._definition != other._definition: + return False + if self._plays != other._plays: + return False + return True + + @property + def eq(self) -> str: + return f"id = {self._id}" + + @property + def id(self) -> int: + """return id""" + assert self._id is not None + return self._id + + @property + def name(self) -> str: + """return name""" + return self._name + + @name.setter + def name(self, value: str) -> None: + if value == self._name: + return + self._name = value + self._fireChanged() + + @property + def definition(self) -> str: + """return definition""" + return self._definition + + @definition.setter + def definition(self, value: str) -> None: + if value == self._definition: + return + self._definition = value + self._fireChanged() + + @property + def definitionDict(self) -> Dict[str, Any]: + """return artists""" + try: + return json.loads(self.definition) # type: ignore + except: + return {} + + @definitionDict.setter + def definitionDict(self, value: Dict[str, Any]) -> None: + if value == self.definitionDict: + return + self._definition = json.dumps(value) + self._fireChanged() + + @property + def plays(self) -> int: + """return plays""" + return self._plays + + @plays.setter + def plays(self, value: int) -> None: + if value == self._plays: + return + self._plays = value + self._fireChanged() + + @property + def description(self) -> str: + """return description""" + return self._description + + @description.setter + def description(self, value: str) -> None: + if value == self._description: + return + self._description = value + self._fireChanged() + + @property + def cover(self) -> str: + """return cover""" + return self._cover + + @cover.setter + def cover(self, value: str) -> None: + if value == self._cover: + return + self._cover = value + self._fireChanged() + + @property + def url(self) -> str: + """return url""" + return f"/playlist/smart/{hashids.encode(self.id)}" + + def toDict(self) -> Dict[str, Any]: + """return dict""" + return { + "id": self.id, + "name": self.name, + "description": self.description, + "cover": self.cover, + "definition": self.definitionDict, + "plays": self.plays, + "href": self.url, + } + + +class SmartPlaylistTable(ITable[SmartPlaylistModel]): + """Songs table""" + + NAME = "SmartPlaylists" + DESCRIPTION = """ + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name TEXT, + description TEXT, + cover TEXT, + definition TEXT, + plays INTEGER + """ + + def _model(self) -> Type[SmartPlaylistModel]: + return SmartPlaylistModel + + async def byId(self, id_: int) -> Optional[SmartPlaylistModel]: + """get song by id""" + where = f"id = {id_}" + return await self.selectOne(append=f"WHERE {where}") + + async def allByIds(self, ids: List[int]) -> List[SmartPlaylistModel]: + """get songs by ids""" + where = f"id IN ({', '.join([ str(x) for x in ids ])})" + return await self.select(append=f"WHERE {where}") diff --git a/src/server/handler/player.py b/src/server/handler/player.py index b67697064..99bd0d9f9 100644 --- a/src/server/handler/player.py +++ b/src/server/handler/player.py @@ -19,17 +19,13 @@ MIN_PLAYLIST_ID = -2 -SPECIAL_PLAYLISTS = { - -1: "collection", - -2: "collection/breaking" -} +SPECIAL_PLAYLISTS = {-1: "collection", -2: "collection/breaking"} class PlayerHandler: """player handler""" - def __init__(self, - player: Player, - playlistManager: PlaylistManager) -> None: + + def __init__(self, player: Player, playlistManager: PlaylistManager) -> None: self._player = player self._playlistManager = playlistManager self._dbManager = Database() @@ -37,69 +33,70 @@ def __init__(self, async def getNext(self, _: web.Request) -> web.Response: """get(/api/player/next)""" asyncio.create_task(self._player.next()) - return web.Response(status = 200, text = "success!") + return web.Response(status=200, text="success!") async def getPrevious(self, _: web.Request) -> web.Response: """get(/api/player/previous)""" asyncio.create_task(self._player.last()) - return web.Response(status = 200, text = "success!") - - @withObjectPayload(Object({ - "type": String().enum("playlist", "collection", "collection/breaking", "track", "smart"), - "id": Integer().min(MIN_PLAYLIST_ID).optional(), - "smart": String().optional() - }), inBody = True) + return web.Response(status=200, text="success!") + + @withObjectPayload( + Object( + { + "type": String().enum( + "playlist", "collection", "collection/breaking", "track", "smart" + ), + "id": String().coerce().optional(), + "smart": String().optional(), + } + ), + inBody=True, + ) async def loadPlaylist(self, payload: Dict[str, Any]) -> web.Response: """post(/api/player/load)""" type_: str = payload["type"] - id_: Optional[int] = payload.get("id") - - if id_ is not None and id_ in SPECIAL_PLAYLISTS: - type_ = SPECIAL_PLAYLISTS.get(id_, type_) + id_: Optional[str] = payload.get("id") - if type_ in ("playlist", "track") and id_ == -1: - return web.HTTPBadRequest(text = "id is required for types playlist and track") + if type_ in ("playlist", "track") and not id_: + return web.HTTPBadRequest(text="id is required for types playlist and track") if type_ == "playlist": if id_ is None: - return web.HTTPBadRequest(text = "id is required for type playlist") + return web.HTTPBadRequest(text="id is required for type playlist") if playlist := self._playlistManager.get(id_): asyncio.create_task(self._player.loadPlaylist(playlist)) return web.Response() - return web.HTTPNotFound(text = "playlist not found") + return web.HTTPNotFound(text="playlist not found") - if type_ == "collection": - asyncio.create_task(self._player.loadPlaylist(await PlayerPlaylist.liked())) - return web.Response() + # if type_ == "collection": + # asyncio.create_task(self._player.loadPlaylist(await PlayerPlaylist.liked())) + # return web.Response() - if type_ == "collection/breaking": - asyncio.create_task(self._player.loadPlaylist(await PlayerPlaylist.breaking())) - return web.Response() + # if type_ == "collection/breaking": + # asyncio.create_task(self._player.loadPlaylist(await PlayerPlaylist.breaking())) + # return web.Response() if type_ == "track": assert id_ is not None - if not (song := await self._dbManager.songs.byId(id_)): - return web.HTTPNotFound(text = "song not found") - asyncio.create_task( - self._player.loadPlaylist( - PlayerPlaylist(songs = Song.list([song]), - name = str(id_)))) + if not (song := await self._dbManager.songs.byId(int(id_))): + return web.HTTPNotFound(text="song not found") + # asyncio.create_task( + # self._player.loadPlaylist(PlayerPlaylist(songs=Song.list([song]), name=str(id_))) + # ) return web.Response() - if type_ == "smart": - if "smart" not in payload: - return web.HTTPBadRequest(text = "smart definition is required for type smart") - definition: Dict[str, Any] = payload["smart"] - asyncio.create_task(self._player.loadPlaylist(await PlayerPlaylist.smart(definition))) - return web.Response() - - return web.HTTPBadRequest(text = "invalid type") - - @withObjectPayload(Object({ - "index": Integer().min(0), # index of song in playlist - "playlistIndex": Integer().min(MIN_PLAYLIST_ID).optional(), # playlist id - "type": String().enum("collection", "collection/breaking").optional() - }), inBody = True) + return web.HTTPBadRequest(text="invalid type") + + @withObjectPayload( + Object( + { + "index": Integer().min(0), # index of song in playlist + "playlistIndex": Integer().min(MIN_PLAYLIST_ID).optional(), # playlist id + "type": String().enum("collection", "collection/breaking").optional(), + } + ), + inBody=True, + ) async def loadSongAt(self, payload: web.Request) -> web.Response: """post(/api/player/at)""" songId: int = payload.get("index", -1) @@ -110,23 +107,10 @@ async def loadSongAt(self, payload: web.Request) -> web.Response: type_ = SPECIAL_PLAYLISTS[payload.get("playlistIndex", 0)] del payload["playlistIndex"] - if "playlistIndex" in payload: # other playlist + if "playlistIndex" in payload: # other playlist if await self._player.loadPlaylist( - self._playlistManager.get(payload["playlistIndex"]), songId): - found = True - else: - found = await self._player.at(songId) - - elif type_ == "collection/breaking": - if await self._player.loadPlaylist(await PlayerPlaylist.breaking(), - songId): - found = True - else: - found = await self._player.at(songId) - - elif type_ == "collection": - if await self._player.loadPlaylist(await PlayerPlaylist.liked(), - songId): + self._playlistManager.get(payload["playlistIndex"]), songId + ): found = True else: found = await self._player.at(songId) @@ -140,7 +124,7 @@ async def loadSongAt(self, payload: web.Request) -> web.Response: async def updateSong(self, request: web.Request) -> web.Response: """put(/api/tracks/{id})""" - id_ = int(request.match_info['id']) + id_ = int(request.match_info["id"]) jdata = JDict(await request.json()) song = await self._dbManager.songs.byId(id_) if song is None: @@ -154,7 +138,7 @@ async def updateSong(self, request: web.Request) -> web.Response: song.favourite = jdata.ensure("favourite", bool, song.favourite) song.source = jdata.ensure("source", str, song.source) song.spotify = jdata.ensure("spotify", str, song.spotify) - return web.Response(status = 200) + return web.Response(status=200) async def postShuffle(self, request: web.Request) -> web.Response: """post(/api/player/shuffle)""" @@ -167,7 +151,7 @@ async def postShuffle(self, request: web.Request) -> web.Response: async def getShuffle(self, _: web.Request) -> web.Response: """get(/api/player/shuffle)""" - return web.Response(status = 200, text = json.dumps(self._player.shuffle)) + return web.Response(status=200, text=json.dumps(self._player.shuffle)) async def getCurrentTrack(self, _: web.Request) -> web.Response: """get(/api/me/player/current-track)""" @@ -177,4 +161,6 @@ async def getCurrentTrack(self, _: web.Request) -> web.Response: async def getCurrentPlaylist(self, _: web.Request) -> web.Response: """get(/api/me/player/current-playlist)""" + if self._player.currentPlaylist is None: + return web.HTTPNotFound() return web.json_response(self._player.currentPlaylist.toDict()) diff --git a/src/server/handler/playlist.py b/src/server/handler/playlist.py index 6d4e457bf..8fd40d541 100644 --- a/src/server/handler/playlist.py +++ b/src/server/handler/playlist.py @@ -12,110 +12,118 @@ from player.playlistManager import PlaylistManager -SMART_DEFINITION = Object({ - "limit": Integer().min(1).optional(), - "direction": String().enum("asc", "desc").optional(), - "sort": String().enum("title", "artist", "album", "duration", "id").optional(), - "name": String().optional(), - "description": String().optional(), - "filter": Object({ - "title": Array(String()).optional(), - "artist": Array(String()).optional(), - "album": Array(String()).optional(), - "duration": Object({ - "from": Integer().min(0).optional(), - "to": Integer().min(0).optional() - }).optional() - }).optional() -}) +SMART_DEFINITION = Object( + { + "limit": Integer().min(1).optional(), + "direction": String().enum("asc", "desc").optional(), + "sort": String().enum("title", "artist", "album", "duration", "id").optional(), + "name": String().optional(), + "description": String().optional(), + "filter": Object( + { + "title": Array(String()).optional(), + "artist": Array(String()).optional(), + "album": Array(String()).optional(), + "duration": Object( + {"from": Integer().min(0).optional(), "to": Integer().min(0).optional()} + ).optional(), + } + ).optional(), + } +) class PlaylistHandler: """playlist handler""" + def __init__(self, player: Player, playlistManager: PlaylistManager) -> None: self._player = player self._playlistManager = playlistManager async def addSong(self, request: web.Request) -> web.Response: """post(/api/playlists/{id}/tracks)""" - id_ = int(request.match_info['id']) + id_ = int(request.match_info["id"]) jdata = await request.json() await self._playlistManager.addToPlaylist(id_, Song.fromDict(jdata)) return web.Response() async def moveSong(self, request: web.Request) -> web.Response: """put(/api/playlists/{id}/tracks)""" - id_ = int(request.match_info['id']) + id_ = int(request.match_info["id"]) jdata = await request.json() - self._playlistManager.moveInPlaylist(id_, - jdata["songOldIndex"], - jdata["songNewIndex"]) + self._playlistManager.moveInPlaylist(id_, jdata["songOldIndex"], jdata["songNewIndex"]) return web.Response() async def removeSong(self, request: web.Request) -> web.Response: """/api/playlists/{id}/tracks""" - id_ = int(request.match_info['id']) + id_ = int(request.match_info["id"]) jdata = await request.json() if not "songId" in jdata: - return web.Response(status = 400, text = "no songId") + return web.Response(status=400, text="no songId") await self._playlistManager.removefromPlaylist(id_, jdata["songId"]) - return web.Response(status = 200, text = "success!") + return web.Response(status=200, text="success!") - @withObjectPayload(Object({ - "id": Integer().min(0).coerce() - }), inPath = True) + @withObjectPayload(Object({"id": String().coerce()}), inPath=True) async def getPlaylist(self, payload: Dict[str, Any]) -> web.Response: """post(/api/playlists/{id})""" - id_: int = payload["id"] + id_: str = payload["id"] if playlist := self._playlistManager.get(id_): return web.json_response(playlist.toDict()) return web.HTTPNotFound() async def getPlaylists(self, _: web.Request) -> web.Response: """get(/api/playlists)""" - return web.json_response([ - playlist.toDict() - for playlist in self._playlistManager.playlists - ]) + return web.json_response( + [playlist.toDict() for playlist in self._playlistManager.playlists] + ) async def createPlaylist(self, _: web.Request) -> web.Response: """get(/api/playlists/new)""" - return web.Response(status = 200, text = str(await self._playlistManager.addPlaylist())) - - @withObjectPayload(Object({ - "id": Integer().min(0).coerce(), - }), inPath = True) + return web.Response(status=200, text=str(await self._playlistManager.addPlaylist())) + + @withObjectPayload( + Object( + { + "id": String().coerce(), + } + ), + inPath=True, + ) async def deletePlaylist(self, payload: Dict[str, Any]) -> web.Response: """delete(/api/playlists/id/{id})""" - id_: int = payload["id"] + id_: str = payload["id"] if await self._playlistManager.removePlaylist(id_): return web.Response() return web.HTTPNotFound() async def updatePlaylist(self, request: web.Request) -> web.Response: """post(/api/playlists/{id})""" - id_ = int(request.match_info['id']) + id_ = str(request.match_info["id"]) jdata: Dict[str, Any] = await request.json() - self._playlistManager.updatePlaylist(id_, - jdata.get("name"), - jdata.get("description"), - jdata.get("cover")) + self._playlistManager.updatePlaylist( + id_, jdata.get("name"), jdata.get("description"), jdata.get("cover") + ) return web.Response() - @withObjectPayload(SMART_DEFINITION, inBody = True) + @withObjectPayload(SMART_DEFINITION, inBody=True) async def updateSmartPlaylist(self, payload: Dict[str, Any]) -> web.Response: """post(/api/playlists/smart/{id})""" return web.Response() - @withObjectPayload(SMART_DEFINITION, inBody = True) + @withObjectPayload(SMART_DEFINITION, inBody=True) async def peekSmartPlaylist(self, payload: Dict[str, Any]) -> web.Response: """post(/api/playlists/smart/preview)""" playlist = await PlayerPlaylist.smart(payload) return web.json_response(playlist.toDict()) - @withObjectPayload(Object({ - "id": Integer().min(0).coerce(), - }), inPath = True) + @withObjectPayload( + Object( + { + "id": Integer().min(0).coerce(), + } + ), + inPath=True, + ) async def getSmartPlaylist(self, payload: Dict[str, int]) -> web.Response: """get(/api/playlists/smart/{id})""" id_: int = payload["id"] diff --git a/src/server/handler/websocket.py b/src/server/handler/websocket.py index 3a0cf485c..4c0fa16f8 100644 --- a/src/server/handler/websocket.py +++ b/src/server/handler/websocket.py @@ -12,12 +12,13 @@ from dataModel.song import Song from player.player import Player -from player.playerPlaylist import PlayerPlaylist +from player.iPlayerPlaylist import IPlayerPlaylist from helper.logged import Logged class Message(Dict[str, Any]): """websocket message""" + def __init__(self, data: Union[str, Dict[str, Any]]) -> None: if isinstance(data, str): super().__init__(json.loads(data)) @@ -42,40 +43,31 @@ def valid(self) -> bool: class Websocket(Logged): """websocket handler""" + def __init__(self, player: Player) -> None: super().__init__(self.__class__.__name__) - self._connections: List[WebSocketResponse] = [ ] + self._connections: List[WebSocketResponse] = [] self._player = player - self._player._playlistChangeCallback = self._onPlaylistChange # pylint: disable=protected-access - self._player._songChangeCallback = self._onSongChange # pylint: disable=protected-access + self._player._playlistChangeCallback = ( + self._onPlaylistChange + ) # pylint: disable=protected-access + self._player._songChangeCallback = self._onSongChange # pylint: disable=protected-access - async def _onPlaylistChange(self, playlist: PlayerPlaylist) -> None: - await self.publish(Message({ - "path": "player.playlist", - "data": playlist.toDict() - })) + async def _onPlaylistChange(self, playlist: IPlayerPlaylist) -> None: + await self.publish(Message({"path": "player.playlist", "data": playlist.toDict()})) async def _onSongChange(self, song: Song) -> None: - await self.publish(Message({ - "path": "player.song", - "data": song.toDict() - })) + await self.publish(Message({"path": "player.song", "data": song.toDict()})) async def _onPlayStateChange(self, playing: bool) -> None: - await self.publish(Message({ - "path": "player.playState", - "data": playing - })) + await self.publish(Message({"path": "player.playState", "data": playing})) async def _onPositionSync(self, pos: float) -> None: - await self.publish(Message({ - "path": "player.posSync", - "data": pos - })) + await self.publish(Message({"path": "player.posSync", "data": pos})) async def wsHandler(self, request: web.Request) -> WebSocketResponse: """get(/ws)""" - ws = WebSocketResponse(heartbeat = 10) + ws = WebSocketResponse(heartbeat=10) self._connections.append(ws) await ws.prepare(request) @@ -86,18 +78,18 @@ async def wsHandler(self, request: web.Request) -> WebSocketResponse: async for msg in ws: if msg.type == aiohttp.WSMsgType.TEXT: - if msg.data == 'close': + if msg.data == "close": await ws.close() else: data = Message(msg.data) if not data.valid: - await ws.send_str('invalid message') + await ws.send_str("invalid message") continue await self._handle(ws, data) elif msg.type == aiohttp.WSMsgType.ERROR: - self._logger.error('ws connection closed with exception %s', ws.exception()) + self._logger.error("ws connection closed with exception %s", ws.exception()) - self._logger.debug('websocket connection closed') + self._logger.debug("websocket connection closed") self._connections.remove(ws) return ws @@ -107,7 +99,7 @@ async def publish(self, msg: Message) -> None: for ws in self._connections: try: await ws.send_json(msg) - except: # pylint: disable=bare-except + except: # pylint: disable=bare-except pass async def _handle(self, _: WebSocketResponse, msg: Message) -> None: diff --git a/src/server/helper/songCache.py b/src/server/helper/songCache.py index c9f059c9c..9f9d92481 100644 --- a/src/server/helper/songCache.py +++ b/src/server/helper/songCache.py @@ -6,7 +6,7 @@ from typing import List, Optional, Union from config.runtime import Runtime from dataModel.song import Song -from player.playerPlaylist import PlayerPlaylist +from player.iPlayerPlaylist import IPlayerPlaylist CACHE_PATH = os.path.abspath("./_cache") @@ -14,6 +14,7 @@ class SongCache: """SongCache""" + @staticmethod def deleteFiles(files: List[str]) -> None: """pass w/o CACHE_PATH, relative file name only""" @@ -21,9 +22,8 @@ def deleteFiles(files: List[str]) -> None: os.remove(os.path.join(CACHE_PATH, file)) @staticmethod - def _songsToFiles(songs: Union[List[Song], PlayerPlaylist]) -> List[str]: - return [ song.downloadPath() + ".mp3" - for song in songs ] + def _songsToFiles(songs: Union[List[Song], IPlayerPlaylist]) -> List[str]: + return [song.downloadPath() + ".mp3" for song in songs] @classmethod def deleteSongs(cls, songs: List[Song]) -> None: @@ -36,16 +36,14 @@ def getAll() -> List[str]: return os.listdir(CACHE_PATH) @classmethod - def forcePrune(cls, allow: Optional[Union[List[Song], PlayerPlaylist]]) -> None: + def forcePrune(cls, allow: Optional[Union[List[Song], IPlayerPlaylist]]) -> None: """force prune, even if preserve is True""" allowFiles = cls._songsToFiles(allow or []) - toDelete = [ file - for file in cls.getAll() - if file not in allowFiles ] + toDelete = [file for file in cls.getAll() if file not in allowFiles] cls.deleteFiles(toDelete) @classmethod - def prune(cls, allow: Optional[Union[List[Song], PlayerPlaylist]]) -> None: + def prune(cls, allow: Optional[Union[List[Song], IPlayerPlaylist]]) -> None: """prune cache, if preserve is False""" if Runtime.cache.preserveInSession: return diff --git a/src/server/player/classicPlayerPlaylist.py b/src/server/player/classicPlayerPlaylist.py new file mode 100644 index 000000000..3bfd03ba8 --- /dev/null +++ b/src/server/player/classicPlayerPlaylist.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +"""reAudioPlayer ONE""" +__copyright__ = "Copyright (c) 2023 https://github.com/reAudioPlayer" + +from db.database import Database +from db.table.playlists import PlaylistModel +from player.iPlayerPlaylist import IPlayerPlaylist, PlaylistType +from dataModel.song import Song + + +class ClassicPlayerPlaylist(IPlayerPlaylist): + def __init__(self, model: PlaylistModel) -> None: + super().__init__(PlaylistType.Classic, model) + + @property + def _playlistModel(self) -> PlaylistModel: + assert isinstance(self._model, PlaylistModel) + return self._model + + async def _load(self) -> None: + self._songs = Song.list(await Database().songs.allByIds(self._playlistModel.songsList)) + self._resetQueue() diff --git a/src/server/player/iPlayerPlaylist.py b/src/server/player/iPlayerPlaylist.py new file mode 100644 index 000000000..38863291e --- /dev/null +++ b/src/server/player/iPlayerPlaylist.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +"""reAudioPlayer ONE""" +__copyright__ = "Copyright (c) 2023 https://github.com/reAudioPlayer" + +from abc import ABC, abstractmethod +from enum import Enum +from typing import List, Optional, Dict, Any, Generator +import random +import asyncio +from hashids import Hashids # type: ignore +from dataModel.song import Song +from dataModel.playlist import Playlist +from db.table.iPlaylistModel import IPlaylistModel + + +class PlaylistType(Enum): + """playlist type""" + + Classic = "classic" + Smart = "smart" + Special = "special" + Unknown = "unknown" + + _HASHIDS = { + Classic: Hashids(salt="reapOne.playlist", min_length=22), + Smart: Hashids(salt="reapOne.smartPlaylist", min_length=22), + } + + def generateId(self, index: int) -> str: + assert self != PlaylistType.Unknown + hashids = PlaylistType._HASHIDS.value[self.value] + return hashids.encode(index) # type: ignore + + +class IPlayerPlaylist(ABC): + __slots__ = ("_songs", "_queue", "_type", "_cursor", "_model") + + def __init__(self, type_: PlaylistType, model: Optional[IPlaylistModel]) -> None: + self._songs: List[Song] = [] + self._queue: List[int] = [] + self._cursor = -1 + self._type: PlaylistType = type_ + self._model: Optional[IPlaylistModel] = model + asyncio.create_task(self._load()) + + @abstractmethod + async def _load(self) -> None: + pass + + def _resetQueue(self) -> None: + self._queue = list(range(len(self._songs))) + + @property + def cursor(self) -> int: + """position in playlist""" + return self._cursor + + def _cursorPeek(self, increment: int = 1) -> int: + return (self.cursor + increment) % len(self._songs) + + def _queueAt(self, index: int) -> Optional[Song]: + assert len(self._songs) == len(self._queue) + if index > len(self._queue): + return None + songIndex = self._queue[index] + return self._songs[songIndex] + + @property + def current(self) -> Song: + assert len(self._songs) + song = self._queueAt(self._cursor) + assert song + return song + + def next(self, peek: bool = False) -> Song: + nextPosition = self._cursorPeek() + if not peek: + self._cursor = nextPosition + song = self._queueAt(nextPosition) + assert song + return song + + def last(self, peek: bool = False) -> Song: + lastPosition = self._cursorPeek(-1) + if not peek: + self._cursor = lastPosition + song = self._queueAt(lastPosition) + assert song + return song + + def at(self, index: int) -> Optional[Song]: + return self._queueAt(index) + + def shuffle(self) -> None: + prevIndex = self._queue[self._cursor] + random.shuffle(self._queue) + for index, songIndex in enumerate(self._queue): + if songIndex == prevIndex: + self._cursor = index + return + + @property + def id(self) -> str: + """return id""" + assert self._model is not None + return self._type.generateId(self._model.id) + + @property + def href(self) -> str: + """return href""" + return f"/playlist/{self.id}" + + @property + def queue(self) -> Generator[Song, None, None]: + for index in self._queue: + yield self._songs[index] + + def toDict(self) -> Dict[str, Any]: + """serialise""" + assert self._model is not None + return { + "name": self._model.name, + "description": self._model.description, + "cover": self._model.cover, + "type": self._type.value, + "cursor": self.cursor, + "songs": [song.toDict() for song in self._songs], + "queue": [song.toDict() for song in self.queue], + "plays": self._model.plays, + "id": self.id, + "href": self.href, + } + + def __eq__(self, other: object) -> bool: + if not isinstance(other, IPlayerPlaylist): + return False + return self.id == other.id + + def __hash__(self) -> int: + return hash(self.id) + + def __iter__(self) -> Generator[Song, None, None]: + for song in self._songs: + yield song + + def __len__(self) -> int: + return len(self._songs) diff --git a/src/server/player/player.py b/src/server/player/player.py index 83f520e6e..9259a8c2f 100644 --- a/src/server/player/player.py +++ b/src/server/player/player.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """reAudioPlayer ONE""" from __future__ import annotations + __copyright__ = "Copyright (c) 2022 https://github.com/reAudioPlayer" import logging @@ -17,7 +18,7 @@ from db.database import Database -from player.playerPlaylist import PlayerPlaylist +from player.iPlayerPlaylist import IPlayerPlaylist from player.playlistManager import PlaylistManager from downloader.downloader import Downloader @@ -26,9 +27,9 @@ from config.runtime import CacheStrategy - -class Player(metaclass = Singleton): # pylint: disable=too-many-instance-attributes +class Player(metaclass=Singleton): # pylint: disable=too-many-instance-attributes """Player""" + __slots__ = ( "_dbManager", "_config", @@ -42,25 +43,23 @@ class Player(metaclass = Singleton): # pylint: disable=too-many-instance-attribu "_playlistChangeCallback", "_songChangeCallback", "_strategy", - "_incrementPlayCountTask" + "_incrementPlayCountTask", ) _INSTANCE: Optional[Player] = None - def __init__(self, - downloader: Downloader, - playlistManager: PlaylistManager) -> None: + def __init__(self, downloader: Downloader, playlistManager: PlaylistManager) -> None: self._dbManager = Database() self._playlistManager = playlistManager self._downloader = downloader - self._playerPlaylist: Optional[PlayerPlaylist] = None + self._playerPlaylist: Optional[IPlayerPlaylist] = None self._song: Optional[Song] = None self._preloaded: Optional[str] = None self._logger = logging.getLogger("player") self._shuffle = False - self._playlistChangeCallback: Optional[Callable[[PlayerPlaylist], Awaitable[None]]] = None + self._playlistChangeCallback: Optional[Callable[[IPlayerPlaylist], Awaitable[None]]] = None self._songChangeCallback: Optional[Callable[[Song], Awaitable[None]]] = None - Player._INSTANCE = self # pylint: disable=protected-access + Player._INSTANCE = self # pylint: disable=protected-access self._strategy: Optional[ICacheStrategy] = None Runtime.cache.onStrategyChange.add(self._onStrategyChange) self._incrementPlayCountTask: Optional[asyncio.Task[None]] = None @@ -74,17 +73,17 @@ def getInstance(cls) -> Player: async def _onStrategyChange(self, newStrategy: CacheStrategy) -> None: self._strategy = ICacheStrategy.get(newStrategy, self) - async def _onPlaylistChange(self, playlist: PlayerPlaylist) -> None: + async def _onPlaylistChange(self, playlist: IPlayerPlaylist) -> None: if self._strategy: await self._strategy.onPlaylistLoad(playlist) if self._playlistChangeCallback: - await self._playlistChangeCallback(playlist) # pylint: disable=not-callable + await self._playlistChangeCallback(playlist) # pylint: disable=not-callable async def _onSongChange(self, newSong: Song) -> None: if self._strategy: await self._strategy.onSongLoad(newSong) if self._songChangeCallback: - await self._songChangeCallback(newSong) # pylint: disable=not-callable + await self._songChangeCallback(newSong) # pylint: disable=not-callable if self._incrementPlayCountTask: self._incrementPlayCountTask.cancel() @@ -92,11 +91,12 @@ async def _onSongChange(self, newSong: Song) -> None: async def _incrementPlayCount() -> None: await asyncio.sleep(30) newSong.model.plays += 1 + self._incrementPlayCountTask = asyncio.create_task(_incrementPlayCount()) - async def loadPlaylist(self, - playlist: Optional[PlayerPlaylist], - atIndex: Optional[int] = None) -> bool: + async def loadPlaylist( + self, playlist: Optional[IPlayerPlaylist], atIndex: Optional[int] = None + ) -> bool: """loads a playlist""" self._logger.debug("loadPlaylist [%s] (at %s)", playlist, atIndex) if not playlist: @@ -105,7 +105,6 @@ async def loadPlaylist(self, return False self._playerPlaylist = playlist - playlist.onLoad() asyncio.create_task(self._onPlaylistChange(self._playerPlaylist)) if atIndex is not None: @@ -118,7 +117,7 @@ async def unload(self) -> None: """unload and unbind song file""" if not self._playerPlaylist: return - current = self._playerPlaylist.current() + current = self._playerPlaylist.current cId = current.model.id if current else 0 self._logger.debug("unload %d", cId) @@ -136,12 +135,6 @@ async def next(self) -> None: return await self.unload() - if self._shuffle: - _, song = self._playerPlaylist.random() - await self._preloadSong(song) - await self._loadSong(song) - return - await self._preloadSong(self._playerPlaylist.next()) await self._loadSong() @@ -181,6 +174,8 @@ def shuffle(self) -> bool: @shuffle.setter def shuffle(self, value: bool) -> None: self._shuffle = value + if self._playerPlaylist: + self._playerPlaylist.shuffle() def updateSongMetadata(self, id_: int, song: Song) -> None: """updates the metadata""" @@ -189,7 +184,7 @@ def updateSongMetadata(self, id_: int, song: Song) -> None: async def _loadSong(self, song: Optional[Song] = None) -> None: if not self._playerPlaylist: return - song = song or self._playerPlaylist.current() + song = song or self._playerPlaylist.current if not song: return self._logger.debug("load src=%s, preloaded=%s", song.model.source, self._preloaded) @@ -203,6 +198,6 @@ def currentSong(self) -> Optional[Song]: return self._song @property - def currentPlaylist(self) -> PlayerPlaylist: + def currentPlaylist(self) -> Optional[IPlayerPlaylist]: """currently loaded playlist""" - return self._playerPlaylist or PlayerPlaylist() + return self._playerPlaylist diff --git a/src/server/player/playerPlaylist.py b/src/server/player/playerPlaylist.py index fadfcc409..6c223a3ec 100644 --- a/src/server/player/playerPlaylist.py +++ b/src/server/player/playerPlaylist.py @@ -106,7 +106,11 @@ async def breaking() -> PlayerPlaylist: # pylint: disable=too-many-locals @classmethod - async def smart(cls, data: Dict[str, Any]) -> PlayerPlaylist: + async def smart(cls, + definition: Dict[str, Any], + name: str = "Smart Playlist", + description: str = "your custom playlist, automatically updated" + ) -> PlayerPlaylist: """ smart playlist @@ -125,7 +129,7 @@ async def smart(cls, data: Dict[str, Any]) -> PlayerPlaylist: } } """ - dex = JDict(data) + dex = JDict(definition) limit = dex.optionalGet("limit", int) direction = dex.ensure("direction", str, "asc") sort = dex.ensure("sort", str, "id").replace("title", "name") @@ -166,9 +170,9 @@ def _addList(key: str) -> str: songs = await Database().songs.select("*", query) playlistIndex = -int(time.time()) - playlist = PlayerPlaylist(name="Smart Playlist", - description = "your custom playlist, automatically updated", # pylint: disable=line-too-long - playlistIndex = playlistIndex) + playlist = PlayerPlaylist(name=name, + description=description, # pylint: disable=line-too-long + playlistIndex=playlistIndex) await playlist.load(playlistIndex, Song.list(songs)) return playlist diff --git a/src/server/player/playlistManager.py b/src/server/player/playlistManager.py index 4bece0c87..4e1aac5bb 100644 --- a/src/server/player/playlistManager.py +++ b/src/server/player/playlistManager.py @@ -7,43 +7,50 @@ from dataModel.song import Song from db.database import Database from db.table.playlists import PlaylistModel -from player.playerPlaylist import PlayerPlaylist +from player.iPlayerPlaylist import IPlayerPlaylist +from player.smartPlayerPlaylist import SmartPlayerPlaylist +from player.classicPlayerPlaylist import ClassicPlayerPlaylist from player.playerPlaylist import OrderedUniqueList class PlaylistManager(Logged): """manages all playlists""" + __slots__ = ("_dbManager", "_playlists") def __init__(self) -> None: self._dbManager = Database() - self._playlists: OrderedUniqueList[PlayerPlaylist] = OrderedUniqueList() + self._playlists: OrderedUniqueList[IPlayerPlaylist] = OrderedUniqueList() super().__init__(self.__class__.__name__) async def loadPlaylists(self) -> None: """loads all playlists""" playlists = await self._dbManager.playlists.all() for playlist in playlists: - self._playlists.append(PlayerPlaylist(playlist.id)) + self._playlists.append(ClassicPlayerPlaylist(playlist)) + smartPlaylists = await self._dbManager.smartPlaylists.all() + for smartPlaylist in smartPlaylists: + self._playlists.append(SmartPlayerPlaylist(smartPlaylist)) async def addToPlaylist(self, playlistId: int, song: Song) -> None: """adds a song to a playlist""" songsInDb = await self._dbManager.songs.select("*", f"WHERE source='{song.model.source}'") - if playlist := self.get(playlistId): - await playlist.add(song, len(songsInDb) > 0) + # if playlist := self.get(playlistId): + # await playlist.add(song, len(songsInDb) > 0) def moveInPlaylist(self, playlistId: int, songIndex: int, newSongIndex: int) -> None: """moves a song in a playlist""" - if playlist := self.get(playlistId): - playlist.move(songIndex, newSongIndex) + # if playlist := self.get(playlistId): + # playlist.move(songIndex, newSongIndex) async def removefromPlaylist(self, playlistId: int, songId: int) -> None: """removes a song from a playlist""" - if playlist := self.get(playlistId): - await playlist.remove(songId) + pass + # if playlist := self.get(playlistId): + # await playlist.remove(songId) - def get(self, id_: int) -> Optional[PlayerPlaylist]: - """gets a playlist at this index""" + def get(self, id_: str) -> Optional[IPlayerPlaylist]: + """gets the playlist with this id""" for playlist in self._playlists: if playlist.id == id_: return playlist @@ -51,29 +58,31 @@ def get(self, id_: int) -> Optional[PlayerPlaylist]: def updateSong(self, id_: int, updateFunction: Callable[[Song], Song]) -> None: """updates all songs with this id""" - for playlist in self._playlists: - songs = playlist.byId(id_) - for song in songs: - song.update(updateFunction(song)) - - def updatePlaylist(self, - id_: int, - name: Optional[str], - description: Optional[str], - cover: Optional[str]) -> None: + # for playlist in self._playlists: + # songs = playlist.byId(id_) + # for song in songs: + # song.update(updateFunction(song)) + + def updatePlaylist( + self, + id_: str, + name: Optional[str], + description: Optional[str], + cover: Optional[str], + ) -> None: """updates a playlist""" playlist = self.get(id_) if not playlist: return - if name: - playlist.name = name - if description: - playlist.description = description - if cover: - playlist.cover = cover + # if name: + # playlist.name = name + # if description: + # playlist.description = description + # if cover: + # playlist.cover = cover @property - def playlists(self) -> OrderedUniqueList[PlayerPlaylist]: + def playlists(self) -> OrderedUniqueList[IPlayerPlaylist]: """return all playlists""" return self._playlists @@ -89,17 +98,21 @@ async def addPlaylist(self, name: Optional[str] = None) -> int: await self._dbManager.playlists.insert(PlaylistModel(name)) await self.loadPlaylists() playlist = self._playlists[-1] - assert playlist.playlistIndex is not None - return playlist.playlistIndex + # assert playlist.playlistIndex is not None + # return playlist.playlistIndex + return 0 - async def removePlaylist(self, playlistId: int) -> bool: + async def removePlaylist(self, playlistId: str) -> bool: """removes a playlist""" - self._logger.info("removing playlist %s, %s, %s", - playlistId, - self.get(playlistId), - bool(self.get(playlistId))) + self._logger.info( + "removing playlist %s, %s, %s", + playlistId, + self.get(playlistId), + bool(self.get(playlistId)), + ) if playlist := self.get(playlistId): self._playlists.remove(playlist) - await self._dbManager.playlists.deleteById(playlistId) + assert playlist._model + await self._dbManager.playlists.deleteById(playlist._model.id) return True return False diff --git a/src/server/player/smartPlayerPlaylist.py b/src/server/player/smartPlayerPlaylist.py new file mode 100644 index 000000000..81f2e4c0e --- /dev/null +++ b/src/server/player/smartPlayerPlaylist.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +"""reAudioPlayer ONE""" +from __future__ import annotations + +__copyright__ = "Copyright (c) 2023 https://github.com/reAudioPlayer" + +from typing import Any, Dict, Optional +from pyaddict import JDict, JList +from dataModel.song import Song +from db.table.smartPlaylists import SmartPlaylistModel +from db.database import Database +from player.iPlayerPlaylist import IPlayerPlaylist, PlaylistType + +""" +smart playlist DEFINITION + +{ + "limit": Integer().min(1).optional(), + "direction": String().enum("asc", "desc").optional(), + "sort": String().enum("title", "artist", "album", "duration", "id").optional(), + "filter": Object({ + "title": String().optional(), + "artist": String().optional(), + "album": String().optional(), + "duration": Object({ + "from": Integer().min(0).optional(), + "to": Integer().min(0).optional() + }).optional() + } +} +""" + + +BREAKING = { + "limit": 25, + "sort": "id", + "direction": "desc", +} + + +class SmartPlayerPlaylist(IPlayerPlaylist): + """smart playlist""" + + def __init__(self, model: Optional[SmartPlaylistModel]) -> None: + super().__init__(PlaylistType.Smart, model) + + @property + def _playlistModel(self) -> Optional[SmartPlaylistModel]: + assert isinstance(self._model, SmartPlaylistModel) + return self._model + + def _query(self, definition: Dict[str, Any]) -> str: + definition = JDict(definition) + limit = definition.optionalGet("limit", int) + direction = definition.ensure("direction", str, "asc") + sort = definition.ensure("sort", str, "id").replace("title", "name") + filter_ = definition.ensureCast("filter", JDict) + + query = "WHERE 1=1" + + def _addList(key: str) -> str: + value = filter_.optionalCast(key, JList) + if not value: + return "" + query = " AND " + items = value.iterator().optionalGet(str) + for i, item in enumerate(items): + if not item: + continue + if i > 0: + query += " OR " + encodedItem = item.replace("'", "''") + query += f"{key} LIKE '%{encodedItem}%'" + return query + + query += _addList("title").replace("title LIKE", "name LIKE") + query += _addList("artist") + query += _addList("album") + filterDuration = filter_.get("duration", {}) + filterDurationFrom = filterDuration.get("from", None) + filterDurationTo = filterDuration.get("to", None) + + if filterDurationFrom is not None: + query += f" AND duration >= {filterDurationFrom}" + if filterDurationTo is not None: + query += f" AND duration <= {filterDurationTo}" + + query += f" ORDER BY {sort} {direction}" + if limit is not None: + query += f" LIMIT {limit}" + return query + + async def _load(self) -> None: + assert self._playlistModel + query = self._query(self._playlistModel.definitionDict) + self._songs = Song.list(await Database().songs.select("*", query)) + self._resetQueue() + + +class BreakingPlaylist(SmartPlayerPlaylist): + """breaking special playlist""" + + def __init__(self) -> None: + super().__init__(None) + + async def _load(self) -> None: + query = self._query(BREAKING) + self._songs = Song.list(await Database().songs.select("*", query)) + self._resetQueue() + + def toDict(self) -> Dict[str, Any]: + return { + "name": "Breaking", + "description": "your {len(songs)} newest songs, automatically updated", + "type": self._type.value, + "cursor": self.cursor, + "queue": list(self.queue), + "id": "breaking", + "href": "/collection/breaking", + } diff --git a/src/ui/src/api/playlist.ts b/src/ui/src/api/playlist.ts index 807118084..8c8094d8e 100644 --- a/src/ui/src/api/playlist.ts +++ b/src/ui/src/api/playlist.ts @@ -9,12 +9,12 @@ import { useDataStore } from "../store/data"; const updateDataStore = async () => { const dataStore = useDataStore(); await dataStore.fetchPlaylists(); -} +}; const getPlaylistById = (id: number): IFullPlaylist => { const dataStore = useDataStore(); return dataStore.getPlaylistById(id); -} +}; /** * updates a playlist's metadata based on its id @@ -27,10 +27,10 @@ export const updatePlaylistMetadata = async (playlist: IPlaylistMeta) => { name: playlist.name, description: playlist.description, cover: playlist.cover, - }) - }) + }), + }); await updateDataStore(); -} +}; /** * fetches all playlists from the server @@ -38,7 +38,7 @@ export const updatePlaylistMetadata = async (playlist: IPlaylistMeta) => { export const getAllPlaylists = async (): Promise => { const res = await fetch("/api/playlists"); return await res.json(); -} +}; /** * fetches a playlist from the server @@ -46,15 +46,15 @@ export const getAllPlaylists = async (): Promise => { */ export const getPlaylist = (id: string | number): IFullPlaylist => { return getPlaylistById(id as number); -} +}; /** * fetches a playlist from the server based on its hash * @param hash the playlist's hash */ export const getPlaylistByHash = (hash: string): IFullPlaylist => { - return getPlaylist(unhashPlaylist(hash)); -} + return getPlaylist(hash); +}; /** * deletes a playlist based on its id @@ -62,14 +62,14 @@ export const getPlaylistByHash = (hash: string): IFullPlaylist => { */ export const deletePlaylist = async (id: number): Promise => { const res = await fetch(`/api/playlists/${id}`, { - method: "DELETE" + method: "DELETE", }); if (!res.ok) return false; await updateDataStore(); return true; -} +}; /** * creates a new playlist @@ -80,7 +80,7 @@ export const createPlaylist = async (): Promise => { const id = await res.json(); await updateDataStore(); return id; -} +}; /** * creates a new playlist with metadata @@ -89,38 +89,45 @@ export const createPlaylist = async (): Promise => { * @param cover the cover of the playlist * @returns the id of the new playlist */ -export const createPlaylistWithMetadata = async (name: string, - description: string = "", - cover: string = ""): Promise => { +export const createPlaylistWithMetadata = async ( + name: string, + description: string = "", + cover: string = "" +): Promise => { const id = await createPlaylist(); await updatePlaylistMetadata({ id, name, description, cover, - plays: 0 + plays: 0, }); return id; -} +}; -export const removeSongFromPlaylist = async (playlistId: number, songId: number) => { +export const removeSongFromPlaylist = async ( + playlistId: number, + songId: number +) => { await fetch(`/api/playlists/${playlistId}/tracks`, { method: "DELETE", body: JSON.stringify({ - songId: songId - }) + songId: songId, + }), }); await updateDataStore(); -} +}; /** * peeks into the songs in a smart playlist */ -export const peekSmartPlaylist = async (definition: any): Promise => { +export const peekSmartPlaylist = async ( + definition: any +): Promise => { const res = await fetch("/api/playlists/smart/peek", { method: "POST", - body: JSON.stringify(definition) + body: JSON.stringify(definition), }); const jdata = await res.json(); return jdata; -} +}; diff --git a/src/ui/src/store/player.ts b/src/ui/src/store/player.ts index 40911711b..cd81f1a6f 100644 --- a/src/ui/src/store/player.ts +++ b/src/ui/src/store/player.ts @@ -11,7 +11,6 @@ import { getShuffle, nextSong, prevSong, setShuffle } from "../api/player"; import { computed } from "vue"; import { type ILyrics, findLyrics } from "../views/SingAlong/lyrics"; - type PlaylistType = "playlist" | "collection" | "collection/breaking" | "track"; export type RepeatType = "repeat" | "repeat_one_on" | "repeat_on"; export interface Playable { @@ -23,9 +22,8 @@ export interface Playable { setMute: (mute: boolean) => void; } - export const usePlayerStore = defineStore({ - id: 'player', + id: "player", state: () => ({ playing: false, progress: 0, @@ -45,8 +43,8 @@ export const usePlayerStore = defineStore({ plays: 0, spotify: { id: null, - } - } + }, + }, }, playlist: { cover: null, @@ -105,7 +103,10 @@ export const usePlayerStore = defineStore({ if (this.repeat === "repeat_one_on") { this.play(); } else { - if (this.repeat === "repeat" && this.playlist.index.value === this.playlist.songs.length - 1) { + if ( + this.repeat === "repeat" && + this.playlist.index.value === this.playlist.songs.length - 1 + ) { return; } @@ -158,9 +159,9 @@ export const usePlayerStore = defineStore({ fetch(`/api/tracks/${this.song.id}`, { method: "PUT", body: JSON.stringify({ - duration - }) - }) + duration, + }), + }); //saveDuration(this.song.id, duration); }, @@ -171,7 +172,7 @@ export const usePlayerStore = defineStore({ this.player.seek(time); }, seekPercent(percent) { - this.seek(this.durationSeconds * percent / 100); + this.seek((this.durationSeconds * percent) / 100); }, setProgress(progress) { this.progress = Math.round(progress); @@ -181,9 +182,9 @@ export const usePlayerStore = defineStore({ fetch(`/api/tracks/${this.song.id}`, { method: "PUT", body: JSON.stringify({ - favourite - }) - }) + favourite, + }), + }); }, setPlaylist(playlist) { this.playlist.songs = playlist.songs; @@ -217,22 +218,22 @@ export const usePlayerStore = defineStore({ const body = { type: "playlist", id: playlistId, - } + }; - if (typeof playlistId === "string") { + if (playlistId === "track") { body.type = playlistId; body.id = id; } fetch("/api/player/load", { method: "POST", - body: JSON.stringify(body) - }) + body: JSON.stringify(body), + }); }, loadSong(playlist: number | PlaylistType, index: number) { const body = { - index - } + index, + }; if (typeof playlist === "number") { if (!isNaN(playlist)) { @@ -244,9 +245,9 @@ export const usePlayerStore = defineStore({ fetch("/api/player/at", { method: "POST", - body: JSON.stringify(body) + body: JSON.stringify(body), }); - } + }, }, getters: { hasLyrics(state) { @@ -258,7 +259,10 @@ export const usePlayerStore = defineStore({ displayDuration(state) { const duration = state.song.duration; if (isNaN(duration)) return "0:00"; - return `${Math.floor(duration / 60)}:${zeroPad(Math.floor(duration % 60), 2)}` + return `${Math.floor(duration / 60)}:${zeroPad( + Math.floor(duration % 60), + 2 + )}`; }, stream(state) { return `/api/player/stream/${state.song.id}`; @@ -267,12 +271,15 @@ export const usePlayerStore = defineStore({ return state.song.cover; }, progressPercent(state) { - return state.progress / this.durationSeconds * 1000; + return (state.progress / this.durationSeconds) * 1000; }, displayProgress(state) { const progress = state.progress; if (isNaN(progress)) return "0:00"; - return `${Math.floor(progress / 60)}:${zeroPad(Math.floor(progress % 60), 2)}` + return `${Math.floor(progress / 60)}:${zeroPad( + Math.floor(progress % 60), + 2 + )}`; }, loaded(state) { return state.song.id != -1; @@ -298,8 +305,13 @@ export const usePlayerStore = defineStore({ playlist(state) { return { ...state.playlist, - index: computed(() => state.playlist?.songs?.findIndex(song => song.id === state.song.id) ?? -1) + index: computed( + () => + state.playlist?.songs?.findIndex( + (song) => song.id === state.song.id + ) ?? -1 + ), }; - } - } + }, + }, }); diff --git a/src/ui/src/views/Playlist/index.vue b/src/ui/src/views/Playlist/index.vue index dd1709139..1a4c73e1d 100644 --- a/src/ui/src/views/Playlist/index.vue +++ b/src/ui/src/views/Playlist/index.vue @@ -14,11 +14,10 @@ import { useDataStore } from "../../store/data"; const route = useRoute(); const router = useRouter(); -const player = usePlayerStore(); const dataStore = useDataStore(); const hash = computed(() => route.params.hash as string); -const id = computed(() => unhashPlaylist(hash.value)); +const id = computed(() => hash.value); const playlist = ref(null as IFullPlaylist | null); const loading = ref(false); const error = ref(null as string | null); @@ -55,15 +54,19 @@ const onPlaylistRearrange = async (oldIndex, newIndex) => { method: "PUT", body: JSON.stringify({ songOldIndex: oldIndex, - songNewIndex: newIndex - }) - }) + songNewIndex: newIndex, + }), + }); await dataStore.fetchPlaylists(); -} +}; onMounted(load); watch(route, () => load(), { deep: true }); -watch(() => dataStore.playlists, () => load(), { deep: true }); +watch( + () => dataStore.playlists, + () => load(), + { deep: true } +);