Working on moving playlist details over to AdapterManager
This commit is contained in:
1
Pipfile
1
Pipfile
@@ -22,6 +22,7 @@ sphinx = "*"
|
|||||||
sphinx-rtd-theme = "*"
|
sphinx-rtd-theme = "*"
|
||||||
termcolor = "*"
|
termcolor = "*"
|
||||||
yapf = "*"
|
yapf = "*"
|
||||||
|
flake8-import-order = "*"
|
||||||
|
|
||||||
[packages]
|
[packages]
|
||||||
sublime-music = {editable = true,extras = ["keyring"],path = "."}
|
sublime-music = {editable = true,extras = ["keyring"],path = "."}
|
||||||
|
16
Pipfile.lock
generated
16
Pipfile.lock
generated
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"hash": {
|
"hash": {
|
||||||
"sha256": "ec62e729c70d9ce38576ae97cfdd88b0a006886cbca1fa4ff0160383e96453a6"
|
"sha256": "ada2f525e7d89500110d3063b4c271d60dd9e8a026ced396d5f3058b3e29e386"
|
||||||
},
|
},
|
||||||
"pipfile-spec": 6,
|
"pipfile-spec": 6,
|
||||||
"requires": {
|
"requires": {
|
||||||
@@ -156,6 +156,12 @@
|
|||||||
],
|
],
|
||||||
"version": "==4.0.1"
|
"version": "==4.0.1"
|
||||||
},
|
},
|
||||||
|
"peewee": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:1269a9736865512bd4056298003aab190957afe07d2616cf22eaf56cb6398369"
|
||||||
|
],
|
||||||
|
"version": "==3.13.3"
|
||||||
|
},
|
||||||
"protobuf": {
|
"protobuf": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:0bae429443cc4748be2aadfdaf9633297cfaeb24a9a02d0ab15849175ce90fab",
|
"sha256:0bae429443cc4748be2aadfdaf9633297cfaeb24a9a02d0ab15849175ce90fab",
|
||||||
@@ -401,6 +407,14 @@
|
|||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==3.2.2"
|
"version": "==3.2.2"
|
||||||
},
|
},
|
||||||
|
"flake8-import-order": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:90a80e46886259b9c396b578d75c749801a41ee969a235e163cfe1be7afd2543",
|
||||||
|
"sha256:a28dc39545ea4606c1ac3c24e9d05c849c6e5444a50fb7e9cdd430fc94de6e92"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==0.18.1"
|
||||||
|
},
|
||||||
"flake8-pep3101": {
|
"flake8-pep3101": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:86e3eb4e42de8326dcd98ebdeaf9a3c6854203a48f34aeb3e7e8ed948107f512",
|
"sha256:86e3eb4e42de8326dcd98ebdeaf9a3c6854203a48f34aeb3e7e8ed948107f512",
|
||||||
|
@@ -16,7 +16,7 @@ Linux Desktop.
|
|||||||
.. _Navidrome: https://www.navidrome.org/
|
.. _Navidrome: https://www.navidrome.org/
|
||||||
|
|
||||||
.. figure:: ./_static/screenshots/play-queue.png
|
.. figure:: ./_static/screenshots/play-queue.png
|
||||||
:width: 80 %
|
:width: 80%
|
||||||
:align: center
|
:align: center
|
||||||
:target: ./_static/screenshots/play-queue.png
|
:target: ./_static/screenshots/play-queue.png
|
||||||
|
|
||||||
|
1
setup.py
1
setup.py
@@ -60,6 +60,7 @@ setup(
|
|||||||
'Deprecated',
|
'Deprecated',
|
||||||
'fuzzywuzzy',
|
'fuzzywuzzy',
|
||||||
'osxmmkeys ; sys_platform=="darwin"',
|
'osxmmkeys ; sys_platform=="darwin"',
|
||||||
|
'peewee',
|
||||||
'pychromecast',
|
'pychromecast',
|
||||||
'PyGObject',
|
'PyGObject',
|
||||||
'python-dateutil',
|
'python-dateutil',
|
||||||
|
@@ -160,7 +160,8 @@ class Adapter(abc.ABC):
|
|||||||
def can_service_requests(self) -> bool:
|
def can_service_requests(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Specifies whether or not the adapter can currently service requests. If
|
Specifies whether or not the adapter can currently service requests. If
|
||||||
this is ``False``, none of the other functions are expected to work.
|
this is ``False``, none of the other data retrieval functions are
|
||||||
|
expected to work.
|
||||||
|
|
||||||
For example, if your adapter requires access to an external service,
|
For example, if your adapter requires access to an external service,
|
||||||
use this function to determine if it is currently possible to connect
|
use this function to determine if it is currently possible to connect
|
||||||
|
@@ -73,6 +73,7 @@ class Result(Generic[T]):
|
|||||||
class AdapterManager:
|
class AdapterManager:
|
||||||
available_adapters: Set[Any] = {FilesystemAdapter, SubsonicAdapter}
|
available_adapters: Set[Any] = {FilesystemAdapter, SubsonicAdapter}
|
||||||
executor: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=50)
|
executor: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=50)
|
||||||
|
is_shutting_down: bool = False
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _AdapterManagerInternal:
|
class _AdapterManagerInternal:
|
||||||
@@ -104,8 +105,13 @@ class AdapterManager:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def shutdown():
|
def shutdown():
|
||||||
assert AdapterManager._instance
|
logging.info('AdapterManager shutdown start')
|
||||||
AdapterManager._instance.shutdown()
|
AdapterManager.is_shutting_down = True
|
||||||
|
AdapterManager.executor.shutdown()
|
||||||
|
if AdapterManager._instance:
|
||||||
|
AdapterManager._instance.shutdown()
|
||||||
|
|
||||||
|
logging.info('CacheManager shutdown complete')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def reset(config: AppConfiguration):
|
def reset(config: AppConfiguration):
|
||||||
@@ -131,7 +137,7 @@ class AdapterManager:
|
|||||||
|
|
||||||
caching_adapter_type = FilesystemAdapter
|
caching_adapter_type = FilesystemAdapter
|
||||||
caching_adapter = None
|
caching_adapter = None
|
||||||
if caching_adapter_type:
|
if caching_adapter_type and ground_truth_adapter_type.can_be_cached:
|
||||||
caching_adapter = caching_adapter_type(
|
caching_adapter = caching_adapter_type(
|
||||||
{
|
{
|
||||||
key: getattr(config.server, key)
|
key: getattr(config.server, key)
|
||||||
@@ -146,13 +152,24 @@ class AdapterManager:
|
|||||||
caching_adapter=caching_adapter,
|
caching_adapter=caching_adapter,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def can_get_playlists() -> bool:
|
||||||
|
# It only matters that the ground truth one can service the request.
|
||||||
|
return (
|
||||||
|
AdapterManager._instance.ground_truth_adapter.can_service_requests
|
||||||
|
and
|
||||||
|
AdapterManager._instance.ground_truth_adapter.can_get_playlists)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_playlists(
|
def get_playlists(
|
||||||
before_download: Callable[[], None] = lambda: None,
|
before_download: Callable[[], None] = lambda: None,
|
||||||
force: bool = False, # TODO: rename to use_ground_truth_adapter?
|
force: bool = False, # TODO: rename to use_ground_truth_adapter?
|
||||||
) -> Result[List[Playlist]]:
|
) -> Result[List[Playlist]]:
|
||||||
assert AdapterManager._instance
|
assert AdapterManager._instance
|
||||||
if not force and AdapterManager._instance.caching_adapter:
|
if (not force and AdapterManager._instance.caching_adapter and
|
||||||
|
AdapterManager._instance.caching_adapter.can_service_requests
|
||||||
|
and
|
||||||
|
AdapterManager._instance.caching_adapter.can_get_playlists):
|
||||||
try:
|
try:
|
||||||
return Result(
|
return Result(
|
||||||
AdapterManager._instance.caching_adapter.get_playlists())
|
AdapterManager._instance.caching_adapter.get_playlists())
|
||||||
@@ -162,6 +179,13 @@ class AdapterManager:
|
|||||||
logging.exception(
|
logging.exception(
|
||||||
f'Error on {"get_playlists"} retrieving from cache.')
|
f'Error on {"get_playlists"} retrieving from cache.')
|
||||||
|
|
||||||
|
if (AdapterManager._instance.ground_truth_adapter
|
||||||
|
and not AdapterManager._instance.ground_truth_adapter
|
||||||
|
.can_service_requests and not AdapterManager._instance
|
||||||
|
.ground_truth_adapter.can_get_playlists):
|
||||||
|
raise Exception(
|
||||||
|
f'No adapters can service {"get_playlists"} at the moment.')
|
||||||
|
|
||||||
def future_fn() -> List[Playlist]:
|
def future_fn() -> List[Playlist]:
|
||||||
assert AdapterManager._instance
|
assert AdapterManager._instance
|
||||||
if before_download:
|
if before_download:
|
||||||
@@ -176,7 +200,6 @@ class AdapterManager:
|
|||||||
def future_finished(f: Future):
|
def future_finished(f: Future):
|
||||||
assert AdapterManager._instance
|
assert AdapterManager._instance
|
||||||
assert AdapterManager._instance.caching_adapter
|
assert AdapterManager._instance.caching_adapter
|
||||||
print(' ohea', f)
|
|
||||||
AdapterManager._instance.caching_adapter.ingest_new_data(
|
AdapterManager._instance.caching_adapter.ingest_new_data(
|
||||||
'get_playlists',
|
'get_playlists',
|
||||||
(),
|
(),
|
||||||
@@ -188,5 +211,64 @@ class AdapterManager:
|
|||||||
return future
|
return future
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_playlist_details(playlist_id: str) -> Result[PlaylistDetails]:
|
def can_get_playlist_details() -> bool:
|
||||||
raise NotImplementedError()
|
# It only matters that the ground truth one can service the request.
|
||||||
|
return (
|
||||||
|
AdapterManager._instance.ground_truth_adapter.can_service_requests
|
||||||
|
and AdapterManager._instance.ground_truth_adapter
|
||||||
|
.can_get_playlist_details)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_playlist_details(
|
||||||
|
playlist_id: str,
|
||||||
|
before_download: Callable[[], None] = lambda: None,
|
||||||
|
force: bool = False, # TODO: rename to use_ground_truth_adapter?
|
||||||
|
) -> Result[PlaylistDetails]:
|
||||||
|
assert AdapterManager._instance
|
||||||
|
if (not force and AdapterManager._instance.caching_adapter and
|
||||||
|
AdapterManager._instance.caching_adapter.can_service_requests
|
||||||
|
and AdapterManager._instance.caching_adapter
|
||||||
|
.can_get_playlist_details):
|
||||||
|
try:
|
||||||
|
return Result(
|
||||||
|
AdapterManager._instance.caching_adapter
|
||||||
|
.get_playlist_details(playlist_id))
|
||||||
|
except CacheMissError:
|
||||||
|
logging.debug(f'Cache Miss on {"get_playlist_details"}.')
|
||||||
|
except Exception:
|
||||||
|
logging.exception(
|
||||||
|
f'Error on {"get_playlist_details"} retrieving from cache.'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (AdapterManager._instance.ground_truth_adapter
|
||||||
|
and not AdapterManager._instance.ground_truth_adapter
|
||||||
|
.can_service_requests and not AdapterManager._instance
|
||||||
|
.ground_truth_adapter.can_get_playlist_details):
|
||||||
|
raise Exception(
|
||||||
|
f'No adapters can service {"get_playlist_details"} at the moment.'
|
||||||
|
)
|
||||||
|
|
||||||
|
def future_fn() -> PlaylistDetails:
|
||||||
|
assert AdapterManager._instance
|
||||||
|
if before_download:
|
||||||
|
before_download()
|
||||||
|
return (
|
||||||
|
AdapterManager._instance.ground_truth_adapter
|
||||||
|
.get_playlist_details(playlist_id))
|
||||||
|
|
||||||
|
future: Result[PlaylistDetails] = Result(future_fn)
|
||||||
|
|
||||||
|
if AdapterManager._instance.caching_adapter:
|
||||||
|
|
||||||
|
def future_finished(f: Future):
|
||||||
|
assert AdapterManager._instance
|
||||||
|
assert AdapterManager._instance.caching_adapter
|
||||||
|
AdapterManager._instance.caching_adapter.ingest_new_data(
|
||||||
|
'get_playlist_details',
|
||||||
|
(playlist_id, ),
|
||||||
|
f.result(),
|
||||||
|
)
|
||||||
|
|
||||||
|
future.add_done_callback(future_finished)
|
||||||
|
|
||||||
|
return future
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Defines the objects that are returned by adapter methods.
|
Defines the objects that are returned by adapter methods.
|
||||||
"""
|
"""
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
@@ -15,26 +15,26 @@ class Song:
|
|||||||
class Playlist:
|
class Playlist:
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
songCount: Optional[int] = None # TODO rename
|
song_count: Optional[int] = None
|
||||||
duration: Optional[timedelta] = None
|
duration: Optional[timedelta] = None
|
||||||
created: Optional[datetime] = None
|
created: Optional[datetime] = None
|
||||||
changed: Optional[datetime] = None
|
changed: Optional[datetime] = None
|
||||||
comment: Optional[str] = None
|
comment: Optional[str] = None
|
||||||
owner: Optional[str] = None
|
owner: Optional[str] = None
|
||||||
public: Optional[bool] = None
|
public: Optional[bool] = None
|
||||||
coverArt: Optional[str] = None # TODO rename
|
cover_art: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PlaylistDetails():
|
class PlaylistDetails:
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
songCount: int # TODO rename
|
song_count: int
|
||||||
duration: timedelta
|
duration: timedelta
|
||||||
|
songs: List[Song]
|
||||||
created: Optional[datetime] = None
|
created: Optional[datetime] = None
|
||||||
changed: Optional[datetime] = None
|
changed: Optional[datetime] = None
|
||||||
comment: Optional[str] = None
|
comment: Optional[str] = None
|
||||||
owner: Optional[str] = None
|
owner: Optional[str] = None
|
||||||
public: Optional[bool] = None
|
public: Optional[bool] = None
|
||||||
coverArt: Optional[str] = None # TODO rename
|
cover_art: Optional[str] = None
|
||||||
songs: List[Song] = field(default_factory=list)
|
|
||||||
|
@@ -1,10 +1,14 @@
|
|||||||
import logging
|
import logging
|
||||||
import sqlite3
|
from dataclasses import asdict
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from playhouse.sqliteq import SqliteQueueDatabase
|
||||||
|
|
||||||
from sublime.adapters.api_objects import (Playlist, PlaylistDetails)
|
from sublime.adapters.api_objects import (Playlist, PlaylistDetails)
|
||||||
from .. import CachingAdapter, ConfigParamDescriptor, CacheMissError
|
|
||||||
|
from . import database
|
||||||
|
from .. import CacheMissError, CachingAdapter, ConfigParamDescriptor
|
||||||
|
|
||||||
|
|
||||||
class FilesystemAdapter(CachingAdapter):
|
class FilesystemAdapter(CachingAdapter):
|
||||||
@@ -31,35 +35,14 @@ class FilesystemAdapter(CachingAdapter):
|
|||||||
):
|
):
|
||||||
self.data_directory = data_directory
|
self.data_directory = data_directory
|
||||||
logging.info('Opening connection to the database.')
|
logging.info('Opening connection to the database.')
|
||||||
self.database_filename = data_directory.joinpath('.cache_meta.db')
|
database_filename = data_directory.joinpath('cache.db')
|
||||||
database_connection = sqlite3.connect(
|
self.database = SqliteQueueDatabase(
|
||||||
self.database_filename,
|
database_filename,
|
||||||
detect_types=sqlite3.PARSE_DECLTYPES,
|
autorollback=True,
|
||||||
)
|
)
|
||||||
|
database.proxy.initialize(self.database)
|
||||||
# TODO extract this out eventually
|
self.database.connect()
|
||||||
c = database_connection.cursor()
|
self.database.create_tables(database.ALL_TABLES)
|
||||||
c.execute(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='playlists';"
|
|
||||||
)
|
|
||||||
if not c.fetchone():
|
|
||||||
c.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE playlists (
|
|
||||||
id TEXT NOT NULL UNIQUE PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
song_count INTEGER,
|
|
||||||
duration INTEGER,
|
|
||||||
created INTEGER,
|
|
||||||
changed INTEGER,
|
|
||||||
comment TEXT,
|
|
||||||
owner TEXT, -- TODO convert to a a FK
|
|
||||||
public INT,
|
|
||||||
cover_art TEXT -- TODO convert to a FK
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
c.close()
|
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
logging.info('Shutdown complete')
|
logging.info('Shutdown complete')
|
||||||
@@ -78,18 +61,10 @@ class FilesystemAdapter(CachingAdapter):
|
|||||||
can_get_playlists: bool = True
|
can_get_playlists: bool = True
|
||||||
|
|
||||||
def get_playlists(self) -> List[Playlist]:
|
def get_playlists(self) -> List[Playlist]:
|
||||||
database_connection = sqlite3.connect(
|
playlists = list(database.Playlist.select())
|
||||||
self.database_filename,
|
if len(playlists) == 0: # TODO not necessarily a cache miss
|
||||||
detect_types=sqlite3.PARSE_DECLTYPES,
|
raise CacheMissError()
|
||||||
)
|
return playlists
|
||||||
with database_connection:
|
|
||||||
playlists = database_connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT * from playlists
|
|
||||||
""").fetchall()
|
|
||||||
return [Playlist(*p) for p in playlists]
|
|
||||||
|
|
||||||
raise CacheMissError()
|
|
||||||
|
|
||||||
can_get_playlist_details: bool = True
|
can_get_playlist_details: bool = True
|
||||||
|
|
||||||
@@ -107,23 +82,8 @@ class FilesystemAdapter(CachingAdapter):
|
|||||||
params: Tuple[Any, ...],
|
params: Tuple[Any, ...],
|
||||||
data: Any,
|
data: Any,
|
||||||
):
|
):
|
||||||
database_connection = sqlite3.connect(
|
if function_name == 'get_playlists':
|
||||||
self.database_filename,
|
(
|
||||||
detect_types=sqlite3.PARSE_DECLTYPES,
|
database.Playlist.insert_many(
|
||||||
)
|
map(lambda p: database.Playlist(**asdict(p)),
|
||||||
with database_connection:
|
data)).on_conflict_replace())
|
||||||
if function_name == 'get_playlists':
|
|
||||||
database_connection.executemany(
|
|
||||||
"""
|
|
||||||
INSERT OR IGNORE INTO playlists (id, name, song_count, duration, created, comment, owner, public, cover_art)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT (id) DO
|
|
||||||
UPDATE SET id=?, name=?, song_count=?, duration=?, created=?, comment=?, owner=?, public=?, cover_art=?;
|
|
||||||
""", [
|
|
||||||
(
|
|
||||||
p.id, p.name, p.songCount, p.duration, p.created,
|
|
||||||
p.comment, p.owner, p.public, p.coverArt, p.id,
|
|
||||||
p.name, p.songCount, p.duration, p.created,
|
|
||||||
p.comment, p.owner, p.public, p.coverArt)
|
|
||||||
for p in data
|
|
||||||
])
|
|
||||||
|
51
sublime/adapters/filesystem/database.py
Normal file
51
sublime/adapters/filesystem/database.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from peewee import (
|
||||||
|
BooleanField,
|
||||||
|
DatabaseProxy,
|
||||||
|
DateTimeField,
|
||||||
|
Field,
|
||||||
|
ForeignKeyField,
|
||||||
|
IntegerField,
|
||||||
|
Model,
|
||||||
|
TextField,
|
||||||
|
)
|
||||||
|
|
||||||
|
proxy = DatabaseProxy()
|
||||||
|
|
||||||
|
|
||||||
|
class BaseModel(Model):
|
||||||
|
class Meta:
|
||||||
|
database = proxy
|
||||||
|
|
||||||
|
|
||||||
|
class DurationField(IntegerField):
|
||||||
|
def db_value(self, value: timedelta) -> Optional[int]:
|
||||||
|
return value.microseconds if value else None
|
||||||
|
|
||||||
|
def python_value(self, value: Optional[int]) -> Optional[timedelta]:
|
||||||
|
return timedelta(microseconds=value) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
class CoverArt(BaseModel):
|
||||||
|
id = TextField(unique=True, primary_key=True)
|
||||||
|
url = TextField()
|
||||||
|
filename = TextField(null=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Playlist(BaseModel):
|
||||||
|
id = TextField(unique=True, primary_key=True)
|
||||||
|
name = TextField()
|
||||||
|
song_count = IntegerField()
|
||||||
|
duration = DurationField()
|
||||||
|
created = DateTimeField()
|
||||||
|
changed = DateTimeField()
|
||||||
|
public = BooleanField()
|
||||||
|
cover_art = TextField()
|
||||||
|
|
||||||
|
|
||||||
|
ALL_TABLES = (
|
||||||
|
CoverArt,
|
||||||
|
Playlist,
|
||||||
|
)
|
@@ -1,12 +1,18 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import requests
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
from time import sleep
|
from time import sleep
|
||||||
from typing import Any, Dict, List, Optional, Union
|
from typing import Any, Dict, List, Optional, Union
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from sublime.adapters.api_objects import (Playlist, PlaylistDetails)
|
import requests
|
||||||
|
|
||||||
|
from sublime.adapters.api_objects import (
|
||||||
|
Playlist,
|
||||||
|
PlaylistDetails,
|
||||||
|
Song,
|
||||||
|
)
|
||||||
from .. import Adapter, ConfigParamDescriptor
|
from .. import Adapter, ConfigParamDescriptor
|
||||||
|
|
||||||
|
|
||||||
@@ -35,7 +41,6 @@ class SubsonicAdapter(Adapter):
|
|||||||
errors: Dict[str, Optional[str]] = {}
|
errors: Dict[str, Optional[str]] = {}
|
||||||
|
|
||||||
# TODO: verify the URL
|
# TODO: verify the URL
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
def __init__(self, config: dict, data_directory: Path):
|
def __init__(self, config: dict, data_directory: Path):
|
||||||
@@ -125,22 +130,30 @@ class SubsonicAdapter(Adapter):
|
|||||||
raise Exception(f'Subsonic API Error #{code}: {message}')
|
raise Exception(f'Subsonic API Error #{code}: {message}')
|
||||||
|
|
||||||
return subsonic_response
|
return subsonic_response
|
||||||
response = Response.from_json(subsonic_response)
|
|
||||||
|
|
||||||
# Check for an error and if it exists, raise it.
|
_snake_case_re = re.compile(r'(?<!^)(?=[A-Z])')
|
||||||
if response.error:
|
|
||||||
raise Server.SubsonicServerError(response.error)
|
|
||||||
|
|
||||||
return response
|
def _to_snake_case(self, obj: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
self._snake_case_re.sub('_', k).lower(): v
|
||||||
|
for k, v in obj.items()
|
||||||
|
}
|
||||||
|
|
||||||
# Data Retrieval Methods
|
# Data Retrieval Methods
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
can_get_playlists = True
|
can_get_playlists = True
|
||||||
|
|
||||||
def get_playlists(self) -> List[Playlist]:
|
def get_playlists(self) -> List[Playlist]:
|
||||||
result = self._get_json(self._make_url('getPlaylists')).get(
|
result = [
|
||||||
'playlists', {}).get('playlist')
|
{
|
||||||
return [Playlist(**p) for p in result]
|
**p,
|
||||||
|
'duration':
|
||||||
|
timedelta(
|
||||||
|
seconds=p['duration']) if p.get('duration') else None,
|
||||||
|
} for p in self._get_json(self._make_url('getPlaylists')).get(
|
||||||
|
'playlists', {}).get('playlist')
|
||||||
|
]
|
||||||
|
return [Playlist(**self._to_snake_case(p)) for p in result]
|
||||||
|
|
||||||
can_get_playlist_details = True
|
can_get_playlist_details = True
|
||||||
|
|
||||||
@@ -148,5 +161,15 @@ class SubsonicAdapter(Adapter):
|
|||||||
self,
|
self,
|
||||||
playlist_id: str,
|
playlist_id: str,
|
||||||
) -> PlaylistDetails:
|
) -> PlaylistDetails:
|
||||||
raise NotImplementedError()
|
result = self._get_json(
|
||||||
|
self._make_url('getPlaylist'),
|
||||||
|
id=playlist_id,
|
||||||
|
).get('playlist')
|
||||||
|
print(result)
|
||||||
|
assert result
|
||||||
|
result['duration'] = result.get('duration') or sum(
|
||||||
|
s.get('duration') or 0 for s in result['entry'])
|
||||||
|
result['songCount'] = result.get('songCount') or len(result['entry'])
|
||||||
|
songs = [Song(id=s['id']) for s in result['entry']]
|
||||||
|
del result['entry']
|
||||||
|
return PlaylistDetails(**self._to_snake_case(result), songs=songs)
|
||||||
|
@@ -268,8 +268,8 @@ class CacheManager(metaclass=Singleton):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def shutdown():
|
def shutdown():
|
||||||
CacheManager.should_exit = True
|
|
||||||
logging.info('CacheManager shutdown start')
|
logging.info('CacheManager shutdown start')
|
||||||
|
CacheManager.should_exit = True
|
||||||
CacheManager.executor.shutdown()
|
CacheManager.executor.shutdown()
|
||||||
CacheManager._instance.save_cache_info()
|
CacheManager._instance.save_cache_info()
|
||||||
logging.info('CacheManager shutdown complete')
|
logging.info('CacheManager shutdown complete')
|
||||||
|
@@ -1,127 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
import typing
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
song_table = '''
|
|
||||||
CREATE TABLE songs
|
|
||||||
(
|
|
||||||
id TEXT NOT NULL UNIQUE PRIMARY KEY,
|
|
||||||
parent TEXT,
|
|
||||||
title TEXT,
|
|
||||||
track INTEGER,
|
|
||||||
year INTEGER,
|
|
||||||
genre TEXT,
|
|
||||||
cover_art TEXT,
|
|
||||||
size INTEGER,
|
|
||||||
duration INTEGER,
|
|
||||||
path TEXT
|
|
||||||
)
|
|
||||||
'''
|
|
||||||
|
|
||||||
conn = sqlite3.connect(':memory:')
|
|
||||||
|
|
||||||
c = conn.cursor()
|
|
||||||
c.execute(song_table)
|
|
||||||
c.execute('''INSERT INTO songs (id, parent, title, year, genre)
|
|
||||||
VALUES ('1', 'ohea', 'Awake My Soul', 2019, 'Contemporary Christian')''')
|
|
||||||
c.execute('''INSERT INTO songs (id, parent, title, year, genre)
|
|
||||||
VALUES ('2', 'ohea', 'Way Maker', 2019, 'Contemporary Christian')''')
|
|
||||||
conn.commit()
|
|
||||||
c.execute('''SELECT * FROM songs''')
|
|
||||||
print(c.fetchall())
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
table_definitions = (
|
|
||||||
(
|
|
||||||
'playlist',
|
|
||||||
{
|
|
||||||
'id': int,
|
|
||||||
'name': str,
|
|
||||||
'comment': Optional[str],
|
|
||||||
'owner': Optional[str],
|
|
||||||
'public': Optional[bool],
|
|
||||||
'song_count': int,
|
|
||||||
'duration': int,
|
|
||||||
'created': datetime,
|
|
||||||
'changed': datetime,
|
|
||||||
'cover_art': Optional[str],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'song',
|
|
||||||
{
|
|
||||||
'id': int,
|
|
||||||
'parent': Optional[str],
|
|
||||||
'title': str,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'playlist_song_xref',
|
|
||||||
{
|
|
||||||
'playlist_id': int,
|
|
||||||
'song_id': int,
|
|
||||||
'position': int,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
python_to_sql_type = {
|
|
||||||
int: 'INTEGER',
|
|
||||||
float: 'REAL',
|
|
||||||
bool: 'INTEGER',
|
|
||||||
datetime: 'TIMESTAMP',
|
|
||||||
str: 'TEXT',
|
|
||||||
bytes: 'BLOB',
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, fields in table_definitions:
|
|
||||||
field_defs = []
|
|
||||||
for field, field_type in fields.items():
|
|
||||||
if type(field_type) == tuple:
|
|
||||||
print(field_type)
|
|
||||||
elif type(field_type) is typing._GenericAlias: # type: ignore
|
|
||||||
sql_type = python_to_sql_type.get(field_type.__args__[0])
|
|
||||||
constraints = ''
|
|
||||||
else:
|
|
||||||
sql_type = python_to_sql_type.get(field_type)
|
|
||||||
constraints = ' NOT NULL'
|
|
||||||
|
|
||||||
field_defs.append(f'{field} {sql_type}{constraints}')
|
|
||||||
|
|
||||||
print(f'''
|
|
||||||
CREATE TABLE {name}
|
|
||||||
({','.join(field_defs)})
|
|
||||||
''')
|
|
||||||
|
|
||||||
'''
|
|
||||||
|
|
||||||
id: str
|
|
||||||
value: str
|
|
||||||
parent: Optional[str]
|
|
||||||
isDir: bool
|
|
||||||
title: str
|
|
||||||
album: Optional[str]
|
|
||||||
artist: Optional[str]
|
|
||||||
track: Optional[int]
|
|
||||||
year: Optional[int]
|
|
||||||
genre: Optional[str]
|
|
||||||
coverArt: Optional[str]
|
|
||||||
size: Optional[int]
|
|
||||||
contentType: Optional[str]
|
|
||||||
suffix: Optional[str]
|
|
||||||
transcodedContentType: Optional[str]
|
|
||||||
transcodedSuffix: Optional[str]
|
|
||||||
duration: Optional[int]
|
|
||||||
bitRate: Optional[int]
|
|
||||||
path: Optional[str]
|
|
||||||
userRating: Optional[UserRating]
|
|
||||||
averageRating: Optional[AverageRating]
|
|
||||||
playCount: Optional[int]
|
|
||||||
discNumber: Optional[int]
|
|
||||||
created: Optional[datetime]
|
|
||||||
starred: Optional[datetime]
|
|
||||||
albumId: Optional[str]
|
|
||||||
artistId: Optional[str]
|
|
||||||
type: Optional[MediaType]
|
|
||||||
'''
|
|
@@ -462,7 +462,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@util.async_callback(
|
@util.async_callback(
|
||||||
lambda *a, **k: CacheManager.get_playlist(*a, **k),
|
lambda *a, **k: AdapterManager.get_playlist_details(*a, **k),
|
||||||
before_download=lambda self: self.show_loading_all(),
|
before_download=lambda self: self.show_loading_all(),
|
||||||
on_failure=lambda self, e: self.playlist_view_loading_box.hide(),
|
on_failure=lambda self, e: self.playlist_view_loading_box.hide(),
|
||||||
)
|
)
|
||||||
|
Reference in New Issue
Block a user