diff --git a/sublime/adapters/__init__.py b/sublime/adapters/__init__.py
index f8ac0b1..e55b7ad 100644
--- a/sublime/adapters/__init__.py
+++ b/sublime/adapters/__init__.py
@@ -1,10 +1,10 @@
from .adapter_base import (
Adapter,
+ AlbumSearchQuery,
CacheMissError,
CachingAdapter,
ConfigParamDescriptor,
SongCacheStatus,
- AlbumSearchQuery,
)
from .manager import AdapterManager, Result, SearchResult
diff --git a/sublime/adapters/adapter_base.py b/sublime/adapters/adapter_base.py
index cf87f92..a8e6cee 100644
--- a/sublime/adapters/adapter_base.py
+++ b/sublime/adapters/adapter_base.py
@@ -18,6 +18,7 @@ from typing import (
from .api_objects import (
Album,
Artist,
+ Directory,
Genre,
Playlist,
PlaylistDetails,
@@ -398,7 +399,15 @@ class Adapter(abc.ABC):
"""
return False
- # Misc
+ # Browse directories
+ @property
+ def can_get_directory(self) -> bool:
+ """
+ Whether :class:`get_directory` can be called on the adapter right now.
+ """
+ return False
+
+ # Genres
@property
def can_get_genres(self) -> bool:
"""
@@ -594,6 +603,21 @@ class Adapter(abc.ABC):
"""
raise self._check_can_error("get_album")
+ def get_directory(self, directory_id: str) -> Directory:
+ """
+ Return a Directory object representing the song files and directories in the
+ given directory. This may not make sense for your adapter (for example, if
+ there's no actual underlying filesystem). In that case, make sure to set
+ :class:`can_get_directory` to ``False``.
+
+ :param directory_id: The directory to retrieve. If the special value ``"root"``
+ is given, the adapter should list all of the directories at the root of the
+ filesystem tree.
+ :returns: A list of the :class:`sublime.adapter.api_objects.Directory` and
+ :class:`sublime.adapter.api_objects.Song` objects in the given directory.
+ """
+ raise self._check_can_error("get_directory")
+
def get_genres(self) -> Sequence[Genre]:
"""
Get a list of the genres known to the adapter.
@@ -683,6 +707,7 @@ class CachingAdapter(Adapter):
ARTIST = "artist"
ARTISTS = "artists"
COVER_ART_FILE = "cover_art_file"
+ DIRECTORY = "directory"
GENRES = "genres"
IGNORED_ARTICLES = "ignored_articles"
PLAYLIST_DETAILS = "get_playlist_details"
diff --git a/sublime/adapters/api_objects.py b/sublime/adapters/api_objects.py
index f51d234..6233219 100644
--- a/sublime/adapters/api_objects.py
+++ b/sublime/adapters/api_objects.py
@@ -16,6 +16,7 @@ from typing import (
Optional,
Sequence,
TypeVar,
+ Union,
)
from fuzzywuzzy import fuzz
@@ -67,10 +68,10 @@ class Directory(abc.ABC):
id: str
title: Optional[str]
parent: Optional["Directory"]
+ children: Sequence[Union["Directory", "Song"]]
class Song(abc.ABC):
- # TODO make these cross-reference the corresponding Album / Artist / Directory
id: str
title: str
parent: Directory
@@ -97,7 +98,7 @@ class Song(abc.ABC):
created: Optional[datetime]
starred: Optional[datetime]
type: Optional[MediaType]
- # TODO trim down, make another data structure for directory?
+ # TODO trim down
# TODO remove distinction between Playlist and PlaylistDetails
diff --git a/sublime/adapters/filesystem/adapter.py b/sublime/adapters/filesystem/adapter.py
index 04b1d85..3bbf95e 100644
--- a/sublime/adapters/filesystem/adapter.py
+++ b/sublime/adapters/filesystem/adapter.py
@@ -1,3 +1,4 @@
+import hashlib
import logging
import shutil
import threading
@@ -84,6 +85,7 @@ class FilesystemAdapter(CachingAdapter):
can_get_albums = True
can_get_album = True
can_get_ignored_articles = True
+ can_get_directory = True
can_get_genres = True
can_search = True
@@ -113,7 +115,8 @@ class FilesystemAdapter(CachingAdapter):
# Determine if the adapter has ingested data for this key before, and if
# not, cache miss.
if not models.CacheInfo.get_or_none(
- models.CacheInfo.cache_key == cache_key
+ models.CacheInfo.valid == True, # noqa: 712
+ models.CacheInfo.cache_key == cache_key,
):
raise CacheMissError(partial_data=result)
return result
@@ -134,49 +137,27 @@ class FilesystemAdapter(CachingAdapter):
cache_info = models.CacheInfo.get_or_none(
models.CacheInfo.cache_key == cache_key,
models.CacheInfo.params_hash == util.params_hash(id),
+ models.CacheInfo.valid == True, # noqa: 712
)
if not cache_info:
raise CacheMissError(partial_data=obj)
return obj
- def _get_download_filename(
- self,
- filename: Path,
- params: Tuple[Any],
- cache_key: CachingAdapter.CachedDataKey,
- ) -> str:
- if not filename.exists():
- # Handle the case that this is the ground truth adapter.
- if self.is_cache:
- raise CacheMissError()
- else:
- raise Exception(f"File for {cache_key} {params} does not exist.")
-
- if not self.is_cache:
- return str(filename)
-
- # If we haven't ingested data for this file before, or it's been invalidated,
- # raise a CacheMissError with the filename.
- cache_info = models.CacheInfo.get_or_none(
- models.CacheInfo.cache_key == cache_key,
- models.CacheInfo.params_hash == util.params_hash(*params),
- )
- if not cache_info:
- raise CacheMissError(partial_data=str(filename))
-
- return str(filename)
-
# Data Retrieval Methods
# ==================================================================================
def get_cached_status(self, song: API.Song) -> SongCacheStatus:
- song = models.Song.get_or_none(models.Song.id == song.id)
- if not song:
+ song_model = models.Song.get_or_none(models.Song.id == song.id)
+ if not song_model:
return SongCacheStatus.NOT_CACHED
- cache_path = self.music_dir.joinpath(song.path)
- if cache_path.exists():
- # TODO check if path is permanently cached
- return SongCacheStatus.CACHED
+
+ try:
+ file = song_model.file
+ if file.valid and self.music_dir.joinpath(file.file_hash).exists():
+ # TODO check if path is permanently cached
+ return SongCacheStatus.CACHED
+ except Exception:
+ pass
return SongCacheStatus.NOT_CACHED
@@ -193,14 +174,17 @@ class FilesystemAdapter(CachingAdapter):
)
def get_cover_art_uri(self, cover_art_id: str, scheme: str) -> str:
- # TODO cache by the content of the file (need to see if cover art ID is
- # duplicated a lot)?
- params_hash = util.params_hash(cover_art_id)
- return self._get_download_filename(
- self.cover_art_dir.joinpath(params_hash),
- (cover_art_id,),
- CachingAdapter.CachedDataKey.COVER_ART_FILE,
+ cover_art = models.CacheInfo.get_or_none(
+ models.CacheInfo.cache_key == CachingAdapter.CachedDataKey.COVER_ART_FILE,
+ models.CacheInfo.params_hash == util.params_hash(cover_art_id),
)
+ if cover_art:
+ filename = self.cover_art_dir.joinpath(str(cover_art.file_hash))
+ if cover_art.valid and filename.exists():
+ return str(filename)
+ raise CacheMissError(partial_data=str(filename))
+
+ raise CacheMissError()
def get_song_uri(self, song_id: str, scheme: str, stream: bool = False) -> str:
song = models.Song.get_or_none(models.Song.id == song_id)
@@ -210,11 +194,17 @@ class FilesystemAdapter(CachingAdapter):
else:
raise Exception(f"Song {song_id} does not exist.")
- return self._get_download_filename(
- self.music_dir.joinpath(song.path),
- (song_id,),
- CachingAdapter.CachedDataKey.SONG_FILE,
- )
+ try:
+ if (song_file := song.file) and (
+ filename := self.music_dir.joinpath(str(song_file.file_hash))
+ ):
+ if song_file.valid and filename.exists():
+ return str(filename)
+ raise CacheMissError(partial_data=str(filename))
+ except models.CacheInfo.DoesNotExist:
+ pass
+
+ raise CacheMissError()
def get_song_details(self, song_id: str) -> API.Song:
return self._get_object_details(
@@ -239,7 +229,7 @@ class FilesystemAdapter(CachingAdapter):
# TODO: deal with cache invalidation
sql_query = models.Album.select()
- Type = AlbumSearchQuery.Type)
+ Type = AlbumSearchQuery.Type
if query.type == Type.GENRE:
assert query.genre
genre_name = genre.name if (genre := query.genre) else None
@@ -264,6 +254,7 @@ class FilesystemAdapter(CachingAdapter):
if not models.CacheInfo.get_or_none(
models.CacheInfo.cache_key == CachingAdapter.CachedDataKey.ALBUMS,
models.CacheInfo.params_hash == util.params_hash(query),
+ models.CacheInfo.valid == True, # noqa: 712
):
raise CacheMissError(partial_data=sql_query)
@@ -289,6 +280,20 @@ class FilesystemAdapter(CachingAdapter):
)
)
+ def get_directory(self, directory_id: str) -> API.Directory:
+ # ohea
+ result = list(model.select())
+ if self.is_cache and not ignore_cache_miss:
+ # Determine if the adapter has ingested data for this key before, and if
+ # not, cache miss.
+ if not models.CacheInfo.get_or_none(
+ models.CacheInfo.valid == True, # noqa: 712
+ models.CacheInfo.cache_key == cache_key,
+ ):
+ raise CacheMissError(partial_data=result)
+ return result
+ pass
+
def get_genres(self) -> Sequence[API.Genre]:
return self._get_list(models.Genre, CachingAdapter.CachedDataKey.GENRES)
@@ -349,7 +354,7 @@ class FilesystemAdapter(CachingAdapter):
data_key: CachingAdapter.CachedDataKey,
params: Tuple[Any, ...],
data: Any,
- ):
+ ) -> Any:
# TODO: this entire function is not exactly efficient due to the nested
# dependencies and everything. I'm not sure how to improve it, and I'm not sure
# if it needs improving at this point.
@@ -358,11 +363,25 @@ class FilesystemAdapter(CachingAdapter):
# TODO may need to remove reliance on asdict in order to support more backends.
params_hash = util.params_hash(*params)
- models.CacheInfo.insert(
+ logging.debug(
+ f"_do_ingest_new_data params={params} params_hash={params_hash} data_key={data_key} data={data}" # noqa: 502
+ )
+ now = datetime.now()
+ cache_info, cache_info_created = models.CacheInfo.get_or_create(
cache_key=data_key,
params_hash=params_hash,
- last_ingestion_time=datetime.now(),
- ).on_conflict_replace().execute()
+ defaults={
+ "cache_key": data_key,
+ "params_hash": params_hash,
+ "last_ingestion_time": now,
+ },
+ )
+ cache_info.last_ingestion_time = now
+ if not cache_info_created:
+ cache_info.valid = True
+ cache_info.save()
+
+ cover_art_key = CachingAdapter.CachedDataKey.COVER_ART_FILE
def setattrs(obj: Any, data: Dict[str, Any]):
for k, v in data.items():
@@ -403,7 +422,13 @@ class FilesystemAdapter(CachingAdapter):
"songs": [
ingest_song_data(s, fill_album=False) for s in api_album.songs or []
],
+ "_cover_art": self._do_ingest_new_data(
+ cover_art_key, params=(api_album.cover_art, "album"), data=None
+ )
+ if api_album.cover_art
+ else None,
}
+ del album_data["cover_art"]
if exclude_artist:
del album_data["artist"]
@@ -439,7 +464,15 @@ class FilesystemAdapter(CachingAdapter):
ingest_album_data(a, exclude_artist=True)
for a in api_artist.albums or []
],
+ "_artist_image_url": self._do_ingest_new_data(
+ cover_art_key,
+ params=(api_artist.artist_image_url, "artist"),
+ data=None,
+ )
+ if api_artist.artist_image_url
+ else None,
}
+ del artist_data["artist_image_url"]
del artist_data["similar_artists"]
artist, created = models.Artist.get_or_create(
@@ -460,7 +493,15 @@ class FilesystemAdapter(CachingAdapter):
"parent": ingest_directory_data(d) if (d := api_song.parent) else None,
"genre": ingest_genre_data(g) if (g := api_song.genre) else None,
"artist": ingest_artist_data(ar) if (ar := api_song.artist) else None,
+ "_cover_art": self._do_ingest_new_data(
+ CachingAdapter.CachedDataKey.COVER_ART_FILE,
+ params=(api_song.cover_art,),
+ data=None,
+ )
+ if api_song.cover_art
+ else None,
}
+ del song_data["cover_art"]
if fill_album:
# Don't incurr the overhead of creating an album if we are going to turn
@@ -492,7 +533,14 @@ class FilesystemAdapter(CachingAdapter):
else ()
)
],
+ "_cover_art": self._do_ingest_new_data(
+ cover_art_key, (api_playlist.cover_art,), None
+ )
+ if api_playlist.cover_art
+ else None,
}
+ del playlist_data["cover_art"]
+
playlist, playlist_created = models.Playlist.get_or_create(
id=playlist_data["id"], defaults=playlist_data
)
@@ -504,6 +552,14 @@ class FilesystemAdapter(CachingAdapter):
return playlist
+ def compute_file_hash(filename: str) -> str:
+ file_hash = hashlib.sha1()
+ with open(filename, "rb") as f:
+ while chunk := f.read(8192):
+ file_hash.update(chunk)
+
+ return file_hash.hexdigest()
+
if data_key == CachingAdapter.CachedDataKey.ALBUM:
ingest_album_data(data)
@@ -523,8 +579,19 @@ class FilesystemAdapter(CachingAdapter):
).execute()
elif data_key == CachingAdapter.CachedDataKey.COVER_ART_FILE:
- # ``data`` is the filename of the tempfile in this case
- shutil.copy(str(data), str(self.cover_art_dir.joinpath(params_hash)))
+ cache_info.file_id = params[0]
+
+ if data is None:
+ cache_info.save()
+ return cache_info
+
+ file_hash = compute_file_hash(data)
+ cache_info.file_hash = file_hash
+ cache_info.save()
+
+ # Copy the actual cover art file
+ shutil.copy(str(data), str(self.cover_art_dir.joinpath(file_hash)))
+ return cache_info
elif data_key == CachingAdapter.CachedDataKey.GENRES:
for g in data:
@@ -566,10 +633,23 @@ class FilesystemAdapter(CachingAdapter):
ingest_song_data(data)
elif data_key == CachingAdapter.CachedDataKey.SONG_FILE:
- relative_path = models.Song.get_by_id(params[0]).path
- absolute_path = self.music_dir.joinpath(relative_path)
- absolute_path.parent.mkdir(parents=True, exist_ok=True)
- shutil.copy(str(data), str(absolute_path))
+ cache_info.file_id = params[0]
+
+ if data is None:
+ cache_info.save()
+ return cache_info
+
+ file_hash = compute_file_hash(data)
+ cache_info.file_hash = file_hash
+ cache_info.save()
+
+ # Copy the actual cover art file
+ shutil.copy(str(data), str(self.music_dir.joinpath(file_hash)))
+
+ song = models.Song.get_by_id(params[0])
+ song.file = cache_info
+ song.save()
+ return cache_info
elif data_key == CachingAdapter.CachedDataKey.SONG_FILE_PERMANENT:
raise NotImplementedError()
@@ -577,9 +657,13 @@ class FilesystemAdapter(CachingAdapter):
def _do_invalidate_data(
self, data_key: CachingAdapter.CachedDataKey, params: Tuple[Any, ...],
):
- models.CacheInfo.delete().where(
+ params_hash = util.params_hash(*params)
+ logging.debug(
+ f"_do_invalidate_data params={params} params_hash={params_hash} data_key={data_key}" # noqa: 502
+ )
+ models.CacheInfo.update({"valid": False}).where(
models.CacheInfo.cache_key == data_key,
- models.CacheInfo.params_hash == util.params_hash(*params),
+ models.CacheInfo.params_hash == params_hash,
).execute()
cover_art_cache_key = CachingAdapter.CachedDataKey.COVER_ART_FILE
@@ -608,33 +692,49 @@ class FilesystemAdapter(CachingAdapter):
elif data_key == CachingAdapter.CachedDataKey.SONG_FILE:
# Invalidate the corresponding cover art.
if song := models.Song.get_or_none(models.Song.id == params[0]):
- self._do_invalidate_data(cover_art_cache_key, (song.cover_art,))
+ self._do_invalidate_data(
+ CachingAdapter.CachedDataKey.COVER_ART_FILE, (song.cover_art,)
+ )
def _do_delete_data(
self, data_key: CachingAdapter.CachedDataKey, params: Tuple[Any, ...],
):
- # Invalidate it.
- self._do_invalidate_data(data_key, params)
- cover_art_cache_key = CachingAdapter.CachedDataKey.COVER_ART_FILE
+ params_hash = util.params_hash(*params)
+ logging.debug(
+ f"_do_delete_data params={params} params_hash={params_hash} data_key={data_key}" # noqa: 502
+ )
if data_key == CachingAdapter.CachedDataKey.COVER_ART_FILE:
- cover_art_file = self.cover_art_dir.joinpath(util.params_hash(*params))
- cover_art_file.unlink(missing_ok=True)
+ cache_info = models.CacheInfo.get_or_none(
+ models.CacheInfo.cache_key == data_key,
+ models.CacheInfo.params_hash == params_hash,
+ )
+ if cache_info:
+ cover_art_file = self.cover_art_dir.joinpath(str(cache_info.file_hash))
+ cover_art_file.unlink(missing_ok=True)
+ cache_info.delete()
elif data_key == CachingAdapter.CachedDataKey.PLAYLIST_DETAILS:
# Delete the playlist and corresponding cover art.
if playlist := models.Playlist.get_or_none(models.Playlist.id == params[0]):
if cover_art := playlist.cover_art:
- self._do_delete_data(cover_art_cache_key, (cover_art,))
+ self._do_delete_data(
+ CachingAdapter.CachedDataKey.COVER_ART_FILE, (cover_art,),
+ )
playlist.delete_instance()
elif data_key == CachingAdapter.CachedDataKey.SONG_FILE:
- if song := models.Song.get_or_none(models.Song.id == params[0]):
- # Delete the song
- music_filename = self.music_dir.joinpath(song.path)
- music_filename.unlink(missing_ok=True)
+ cache_info = models.CacheInfo.get_or_none(
+ models.CacheInfo.cache_key == data_key,
+ models.CacheInfo.params_hash == params_hash,
+ )
+ if cache_info:
+ cover_art_file = self.music_dir.joinpath(str(cache_info.file_hash))
+ cover_art_file.unlink(missing_ok=True)
+ cache_info.delete()
- # Delete the corresponding cover art.
- if cover_art := song.cover_art:
- self._do_delete_data(cover_art_cache_key, (cover_art,))
+ models.CacheInfo.delete().where(
+ models.CacheInfo.cache_key == data_key,
+ models.CacheInfo.params_hash == params_hash,
+ ).execute()
diff --git a/sublime/adapters/filesystem/models.py b/sublime/adapters/filesystem/models.py
index 0a68b5e..9e6c429 100644
--- a/sublime/adapters/filesystem/models.py
+++ b/sublime/adapters/filesystem/models.py
@@ -1,6 +1,8 @@
+from typing import Optional
+
from peewee import (
+ AutoField,
BooleanField,
- CompositeKey,
ForeignKeyField,
IntegerField,
Model,
@@ -26,9 +28,19 @@ class BaseModel(Model):
database = database
-# class CachedFile(BaseModel):
-# id = TextField(unique=True, primary_key=True)
-# filename = TextField(null=True)
+class CacheInfo(BaseModel):
+ id = AutoField()
+ valid = BooleanField(default=True)
+ cache_key = CacheConstantsField()
+ params_hash = TextField()
+ last_ingestion_time = TzDateTimeField(null=False)
+ file_id = TextField(null=True)
+ file_hash = TextField(null=True)
+
+ # TODO some sort of expiry?
+
+ class Meta:
+ indexes = ((("cache_key", "params_hash"), True),)
class Genre(BaseModel):
@@ -41,12 +53,20 @@ class Artist(BaseModel):
id = TextField(unique=True, primary_key=True)
name = TextField(null=True)
album_count = IntegerField(null=True)
- artist_image_url = TextField(null=True)
starred = TzDateTimeField(null=True)
biography = TextField(null=True)
music_brainz_id = TextField(null=True)
last_fm_url = TextField(null=True)
+ _artist_image_url = ForeignKeyField(CacheInfo, null=True)
+
+ @property
+ def artist_image_url(self) -> Optional[str]:
+ try:
+ return self._artist_image_url.file_id
+ except Exception:
+ return None
+
@property
def similar_artists(self) -> Query:
return SimilarArtist.select().where(SimilarArtist.artist == self.id)
@@ -64,7 +84,6 @@ class SimilarArtist(BaseModel):
class Album(BaseModel):
id = TextField(unique=True, primary_key=True)
- cover_art = TextField(null=True)
created = TzDateTimeField(null=True)
duration = DurationField(null=True)
name = TextField(null=True)
@@ -76,6 +95,15 @@ class Album(BaseModel):
artist = ForeignKeyField(Artist, null=True, backref="albums")
genre = ForeignKeyField(Genre, null=True, backref="albums")
+ _cover_art = ForeignKeyField(CacheInfo, null=True)
+
+ @property
+ def cover_art(self) -> Optional[str]:
+ try:
+ return self._cover_art.file_id
+ except Exception:
+ return None
+
class IgnoredArticle(BaseModel):
name = TextField(unique=True, primary_key=True)
@@ -91,19 +119,32 @@ class Song(BaseModel):
id = TextField(unique=True, primary_key=True)
title = TextField()
duration = DurationField()
+ path = TextField()
+
album = ForeignKeyField(Album, null=True, backref="songs")
artist = ForeignKeyField(Artist, null=True, backref="songs")
parent = ForeignKeyField(Directory, null=True, backref="songs")
genre = ForeignKeyField(Genre, null=True, backref="songs")
+ # figure out how to deal with different transcodings, etc.
+ file = ForeignKeyField(CacheInfo, null=True)
+
+ _cover_art = ForeignKeyField(CacheInfo, null=True)
+
+ @property
+ def cover_art(self) -> Optional[str]:
+ try:
+ return self._cover_art.file_id
+ except Exception:
+ return None
+
track = IntegerField(null=True)
year = IntegerField(null=True)
- cover_art = TextField(null=True) # TODO: fk?
- path = TextField()
play_count = TextField(null=True)
created = TzDateTimeField(null=True)
starred = TzDateTimeField(null=True)
+ # TODO do I need any of these?
# size: Optional[int] = None
# content_type: Optional[str] = None
# suffix: Optional[str] = None
@@ -120,15 +161,9 @@ class Song(BaseModel):
# original_height: Optional[int] = None
-class CacheInfo(BaseModel):
- cache_key = CacheConstantsField()
- params_hash = TextField()
- last_ingestion_time = TzDateTimeField(null=False)
-
- # TODO some sort of expiry?
-
- class Meta:
- primary_key = CompositeKey("cache_key", "params_hash")
+class DirectoryXChildren(BaseModel):
+ directory_id = TextField()
+ order = IntegerField()
class Playlist(BaseModel):
@@ -141,12 +176,18 @@ class Playlist(BaseModel):
created = TzDateTimeField(null=True)
changed = TzDateTimeField(null=True)
public = BooleanField(null=True)
- cover_art = TextField(null=True) # TODO: fk
-
- # cover_art_file = ForeignKeyField(CachedFile, null=True)
songs = SortedManyToManyField(Song, backref="playlists")
+ _cover_art = ForeignKeyField(CacheInfo, null=True)
+
+ @property
+ def cover_art(self) -> Optional[str]:
+ try:
+ return self._cover_art.file_id
+ except Exception:
+ return None
+
class Version(BaseModel):
id = IntegerField(unique=True, primary_key=True)
@@ -177,6 +218,7 @@ ALL_TABLES = (
Artist,
CacheInfo,
Directory,
+ DirectoryXChildren,
Genre,
IgnoredArticle,
Playlist,
diff --git a/sublime/adapters/filesystem/sqlite_extensions.py b/sublime/adapters/filesystem/sqlite_extensions.py
index 850dd16..472fa4e 100644
--- a/sublime/adapters/filesystem/sqlite_extensions.py
+++ b/sublime/adapters/filesystem/sqlite_extensions.py
@@ -52,26 +52,28 @@ class SortedManyToManyQuery(ManyToManyQuery):
accessor = self._accessor
src_id = getattr(self._instance, self._src_attr)
- if isinstance(value, SelectQuery):
- raise NotImplementedError("Can't use a select query here")
- # query = value.columns(Value(src_id), accessor.dest_fk.rel_field)
- # accessor.through_model.insert_from(
- # fields=[accessor.src_fk, accessor.dest_fk],
- # query=query).execute()
- else:
- value = ensure_tuple(value)
- if not value:
- return
+ assert not isinstance(value, SelectQuery)
+ # TODO DEAD CODE
+ # if isinstance(value, SelectQuery):
+ # raise NotImplementedError("Can't use a select query here")
+ # # query = value.columns(Value(src_id), accessor.dest_fk.rel_field)
+ # # accessor.through_model.insert_from(
+ # # fields=[accessor.src_fk, accessor.dest_fk],
+ # # query=query).execute()
+ # else:
+ value = ensure_tuple(value)
+ if not value:
+ return
- inserts = [
- {
- accessor.src_fk.name: src_id,
- accessor.dest_fk.name: rel_id,
- "position": i,
- }
- for i, rel_id in enumerate(self._id_list(value))
- ]
- accessor.through_model.insert_many(inserts).execute()
+ inserts = [
+ {
+ accessor.src_fk.name: src_id,
+ accessor.dest_fk.name: rel_id,
+ "position": i,
+ }
+ for i, rel_id in enumerate(self._id_list(value))
+ ]
+ accessor.through_model.insert_many(inserts).execute()
# TODO probably don't need
# def remove(self, value: Any) -> Any:
diff --git a/sublime/adapters/manager.py b/sublime/adapters/manager.py
index bc328fe..e40a059 100644
--- a/sublime/adapters/manager.py
+++ b/sublime/adapters/manager.py
@@ -4,6 +4,7 @@ import threading
from concurrent.futures import Future, ThreadPoolExecutor
from dataclasses import dataclass
from datetime import timedelta
+from functools import partial
from pathlib import Path
from time import sleep
from typing import (
@@ -35,6 +36,7 @@ from .adapter_base import (
from .api_objects import (
Album,
Artist,
+ Directory,
Genre,
Playlist,
PlaylistDetails,
@@ -114,8 +116,9 @@ class Result(Generic[T]):
assert 0, "AdapterManager.Result had neither _data nor _future member!"
except Exception as e:
if self._default_value:
- self._data = self._default_value
- raise e
+ return self._default_value
+ else:
+ raise e
def add_done_callback(self, fn: Callable, *args):
"""Attaches the callable ``fn`` to the future."""
@@ -160,6 +163,7 @@ class AdapterManager:
def __post_init__(self):
self._download_dir = tempfile.TemporaryDirectory()
self.download_path = Path(self._download_dir.name)
+ # TODO can we use the threadpool executor max workersfor this
self.download_limiter_semaphore = threading.Semaphore(
self.concurrent_download_limit
)
@@ -522,6 +526,10 @@ class AdapterManager:
def can_get_artist() -> bool:
return AdapterManager._any_adapter_can_do("get_artist")
+ @staticmethod
+ def can_get_directory() -> bool:
+ return AdapterManager._any_adapter_can_do("get_directory")
+
@staticmethod
def can_get_play_queue() -> bool:
return AdapterManager._ground_truth_can_do("get_play_queue")
@@ -850,8 +858,9 @@ class AdapterManager:
return
for song_id in song_ids:
+ song = AdapterManager.get_song_details(song_id).result()
AdapterManager._instance.caching_adapter.delete_data(
- CachingAdapter.CachedDataKey.SONG_FILE, (song_id,)
+ CachingAdapter.CachedDataKey.SONG_FILE, (song.id,)
)
on_song_delete(song_id)
@@ -895,28 +904,33 @@ class AdapterManager:
before_download=before_download,
cache_key=CachingAdapter.CachedDataKey.ARTISTS,
).result()
-
- ignored_articles: Set[str] = set()
- if AdapterManager._any_adapter_can_do("get_ignored_articles"):
- try:
- ignored_articles = AdapterManager._get_from_cache_or_ground_truth(
- "get_ignored_articles",
- use_ground_truth_adapter=force,
- cache_key=CachingAdapter.CachedDataKey.IGNORED_ARTICLES,
- ).result()
- except Exception:
- logging.exception("Failed to retrieve ignored_articles")
-
- def strip_ignored_articles(artist: Artist) -> str:
- name_parts = artist.name.split()
- if name_parts[0] in ignored_articles:
- name_parts = name_parts[1:]
- return " ".join(name_parts)
-
- return sorted(artists, key=strip_ignored_articles)
+ return sorted(
+ artists, key=partial(AdapterManager._strip_ignored_articles, force)
+ )
return Result(do_get_artists)
+ @staticmethod
+ def _get_ignored_articles(force: bool) -> Set[str]:
+ if not AdapterManager._any_adapter_can_do("get_ignored_articles"):
+ return set()
+ try:
+ return AdapterManager._get_from_cache_or_ground_truth(
+ "get_ignored_articles",
+ use_ground_truth_adapter=force,
+ cache_key=CachingAdapter.CachedDataKey.IGNORED_ARTICLES,
+ ).result()
+ except Exception:
+ logging.exception("Failed to retrieve ignored_articles")
+ return set()
+
+ @staticmethod
+ def _strip_ignored_articles(force: bool, artist: Artist) -> str:
+ first_word, rest = (name := artist.name).split(maxsplit=1)
+ if first_word in AdapterManager._get_ignored_articles(force):
+ return rest
+ return name
+
@staticmethod
def get_artist(
artist_id: str,
@@ -973,6 +987,21 @@ class AdapterManager:
cache_key=CachingAdapter.CachedDataKey.ALBUM,
)
+ # Browse
+ @staticmethod
+ def get_directory(
+ directory_id: str,
+ before_download: Callable[[], None] = lambda: None,
+ force: bool = False,
+ ) -> Result[Directory]:
+ return AdapterManager._get_from_cache_or_ground_truth(
+ "get_directory",
+ directory_id,
+ before_download=before_download,
+ use_ground_truth_adapter=force,
+ cache_key=CachingAdapter.CachedDataKey.DIRECTORY,
+ )
+
@staticmethod
def get_play_queue() -> Result[Optional[PlayQueue]]:
assert AdapterManager._instance
diff --git a/sublime/adapters/subsonic/adapter.py b/sublime/adapters/subsonic/adapter.py
index 1e96204..4f89aa7 100644
--- a/sublime/adapters/subsonic/adapter.py
+++ b/sublime/adapters/subsonic/adapter.py
@@ -5,6 +5,7 @@ import multiprocessing
import os
import pickle
import random
+from dataclasses import asdict
from datetime import datetime, timedelta
from pathlib import Path
from time import sleep
@@ -24,7 +25,7 @@ from urllib.parse import urlencode, urlparse
import requests
-from .api_objects import Response
+from .api_objects import Directory, Response, Song
from .. import Adapter, AlbumSearchQuery, api_objects as API, ConfigParamDescriptor
@@ -61,6 +62,7 @@ class SubsonicAdapter(Adapter):
self.hostname = config["server_address"]
self.username = config["username"]
self.password = config["password"]
+ # TODO: SSID stuff
self.disable_cert_verify = config.get("disable_cert_verify")
self.is_shutting_down = False
@@ -121,6 +123,7 @@ class SubsonicAdapter(Adapter):
can_get_ignored_articles = True
can_get_albums = True
can_get_album = True
+ can_get_directory = True
can_get_genres = True
can_get_play_queue = True
can_save_play_queue = True
@@ -184,7 +187,7 @@ class SubsonicAdapter(Adapter):
)
logging.info(
- "SUBSONIC_ADAPTER_DEBUG_DELAY enabled. Pausing for {delay} seconds"
+ f"SUBSONIC_ADAPTER_DEBUG_DELAY enabled. Pausing for {delay} seconds"
)
sleep(delay)
@@ -235,7 +238,7 @@ class SubsonicAdapter(Adapter):
)
raise Exception(f"Subsonic API Error #{code}: {message}")
- logging.debug(f"Response from {url}", subsonic_response)
+ logging.debug(f"Response from {url}: {subsonic_response}")
return Response.from_dict(subsonic_response)
# Helper Methods for Testing
@@ -422,6 +425,28 @@ class SubsonicAdapter(Adapter):
assert album, f"Error getting album {album_id}"
return album
+ def _get_indexes(self) -> API.Directory:
+ indexes = self._get_json(self._make_url("getIndexes")).indexes
+ assert indexes, "Error getting indexes"
+ with open(self.ignored_articles_cache_file, "wb+") as f:
+ pickle.dump(indexes.ignored_articles, f)
+
+ root_dir_items: List[Union[Dict[str, Any], Directory, Song]] = []
+ for index in indexes.index:
+ # TODO figure out a more efficient way of doing this.
+ root_dir_items += index.artist
+ return Directory(id="root", _children=root_dir_items, _is_root=True)
+
+ def get_directory(self, directory_id: str) -> API.Directory:
+ if directory_id == "root":
+ return self._get_indexes()
+
+ directory = self._get_json(
+ self._make_url("getMusicDirectory"), id=directory_id
+ ).directory
+ assert directory, f"Error getting directory {directory_id}"
+ return directory
+
def get_genres(self) -> Sequence[API.Genre]:
if genres := self._get_json(self._make_url("getGenres")).genres:
return genres.genre
diff --git a/sublime/adapters/subsonic/api_objects.py b/sublime/adapters/subsonic/api_objects.py
index 6183566..aa13346 100644
--- a/sublime/adapters/subsonic/api_objects.py
+++ b/sublime/adapters/subsonic/api_objects.py
@@ -4,7 +4,7 @@ These are the API objects that are returned by Subsonic.
from dataclasses import asdict, dataclass, field
from datetime import datetime, timedelta
-from typing import List, Optional
+from typing import Any, Dict, List, Optional, Union
import dataclasses_json
from dataclasses_json import (
@@ -122,12 +122,33 @@ class ArtistInfo:
self.artist_image_url = ""
-@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
-class Directory(SublimeAPI.Directory):
+class Directory(DataClassJsonMixin, SublimeAPI.Directory):
id: str
- title: Optional[str] = None
- parent: Optional["Directory"] = None
+ title: Optional[str] = field(default=None, metadata=config(field_name="name"))
+ parent: Optional["Directory"] = field(init=False)
+ _parent: Optional[str] = field(default=None, metadata=config(field_name="parent"))
+ _is_root: bool = False
+
+ children: List[Union["Directory", "Song"]] = field(default_factory=list, init=False)
+ _children: List[Union[Dict[str, Any], "Directory", "Song"]] = field(
+ default_factory=list, metadata=config(field_name="child")
+ )
+
+ def __post_init__(self):
+ self.parent = (
+ Directory(self._parent or "root", _is_root=(self._parent is None))
+ if not self._is_root
+ else None
+ )
+ self.children = (
+ self._children
+ if self._is_root
+ else [
+ Directory.from_dict(c) if c.get("isDir") else Song.from_dict(c)
+ for c in self._children
+ ]
+ )
@dataclass_json(letter_case=LetterCase.CAMEL)
@@ -245,6 +266,13 @@ class PlayQueue(SublimeAPI.PlayQueue):
self.current_index = [int(s.id) for s in self.songs].index(cur)
+@dataclass_json(letter_case=LetterCase.CAMEL)
+@dataclass
+class Index:
+ name: str
+ artist: List[Directory] = field(default_factory=list)
+
+
@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
class IndexID3:
@@ -271,6 +299,13 @@ class Genres:
genre: List[Genre] = field(default_factory=list)
+@dataclass_json(letter_case=LetterCase.CAMEL)
+@dataclass
+class Indexes:
+ ignored_articles: str
+ index: List[Index] = field(default_factory=list)
+
+
@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
class Playlists:
@@ -300,12 +335,19 @@ class Response(DataClassJsonMixin):
)
album: Optional[Album] = None
+ directory: Optional[Directory] = None
+
genres: Optional[Genres] = None
+
+ indexes: Optional[Indexes] = None
+
playlist: Optional[PlaylistWithSongs] = None
playlists: Optional[Playlists] = None
+
play_queue: Optional[PlayQueue] = field(
default=None, metadata=config(field_name="playQueue")
)
+
song: Optional[Song] = None
search_result: Optional[SearchResult3] = field(
diff --git a/sublime/app.py b/sublime/app.py
index b4d2be0..ad53df1 100644
--- a/sublime/app.py
+++ b/sublime/app.py
@@ -1075,24 +1075,29 @@ class SublimeMusicApp(Gtk.Application):
# Download current song and prefetch songs. Only do this if
# download_on_stream is True and always_stream is off.
def on_song_download_complete(song_id: str):
- if (
- order_token != self.song_playing_order_token
- or not self.app_config.state.playing
- or not self.app_config.state.current_song
- or self.app_config.state.current_song.id != song_id
- ):
+ if order_token != self.song_playing_order_token:
return
- # Switch to the local media if the player can hotswap without lag.
- # For example, MPV can is barely noticable whereas there's quite a delay
- # with Chromecast.
- assert self.player
- if self.player.can_hotswap_source:
- self.player.play_media(
- AdapterManager.get_song_filename_or_stream(song)[0],
- self.app_config.state.song_progress,
- song,
- )
+ # Hotswap to the downloaded song.
+ if (
+ # TODO allow hotswap if not playing. This requires being able to
+ # replace the currently playing URI with something different.
+ self.app_config.state.playing
+ and self.app_config.state.current_song
+ and self.app_config.state.current_song.id == song_id
+ ):
+ # Switch to the local media if the player can hotswap without lag.
+ # For example, MPV can is barely noticable whereas there's quite a
+ # delay with Chromecast.
+ assert self.player
+ if self.player.can_hotswap_source:
+ self.player.play_media(
+ AdapterManager.get_song_filename_or_stream(song)[0],
+ self.app_config.state.song_progress,
+ song,
+ )
+
+ # Always update the window
self.update_window()
if (
diff --git a/sublime/cache_manager.py b/sublime/cache_manager.py
index 9dbdcb3..07fbbe9 100644
--- a/sublime/cache_manager.py
+++ b/sublime/cache_manager.py
@@ -1,6 +1,3 @@
-import glob
-import hashlib
-import itertools
import json
import logging
import os
@@ -10,17 +7,14 @@ import threading
from collections import defaultdict
from concurrent.futures import Future, ThreadPoolExecutor
from datetime import datetime
-from enum import Enum, EnumMeta
-from functools import lru_cache
+from enum import EnumMeta
from pathlib import Path
-from time import sleep
from typing import (
Any,
Callable,
DefaultDict,
Dict,
Generic,
- Iterable,
List,
Optional,
Set,
@@ -28,9 +22,6 @@ from typing import (
Union,
)
-import requests
-from fuzzywuzzy import fuzz
-
try:
import gi
@@ -46,12 +37,10 @@ except Exception:
)
networkmanager_imported = False
-from .adapters import AdapterManager, api_objects as API, Result as AdapterResult
from .config import AppConfiguration
from .server import Server
from .server.api_object import APIObject
from .server.api_objects import (
- AlbumID3,
AlbumWithSongsID3,
Artist,
ArtistID3,
@@ -82,96 +71,6 @@ class Singleton(type):
return None
-class SongCacheStatus(Enum):
- NOT_CACHED = 0
- CACHED = 1
- PERMANENTLY_CACHED = 2
- DOWNLOADING = 3
-
-
-@lru_cache(maxsize=8192)
-def similarity_ratio(query: str, string: str) -> int:
- """
- Return the :class:`fuzzywuzzy.fuzz.partial_ratio` between the ``query`` and
- the given ``string``.
-
- This ends up being called quite a lot, so the result is cached in an LRU
- cache using :class:`functools.lru_cache`.
-
- :param query: the query string
- :param string: the string to compare to the query string
- """
- return fuzz.partial_ratio(query.lower(), string.lower())
-
-
-class SearchResult:
- """
- An object representing the aggregate results of a search which can include
- both server and local results.
- """
-
- _artist: Set[API.Artist] = set()
- _album: Set[API.Album] = set()
- _song: Set[API.Song] = set()
- _playlist: Set[API.Playlist] = set()
-
- def __init__(self, query: str):
- self.query = query
-
- def add_results(self, result_type: str, results: Iterable):
- """Adds the ``results`` to the ``_result_type`` set."""
- if results is None:
- return
-
- member = f"_{result_type}"
- if getattr(self, member) is None:
- setattr(self, member, set())
-
- setattr(self, member, getattr(self, member, set()).union(set(results)))
-
- S = TypeVar("S")
-
- def _to_result(self, it: Iterable[S], transform: Callable[[S], str],) -> List[S]:
- all_results = sorted(
- ((similarity_ratio(self.query, transform(x)), x) for x in it),
- key=lambda rx: rx[0],
- reverse=True,
- )
- result: List[SearchResult.S] = []
- for ratio, x in all_results:
- if ratio > 60 and len(result) < 20:
- result.append(x)
- else:
- # No use going on, all the rest are less.
- break
- return result
-
- @property
- def artist(self) -> Optional[List[API.Artist]]:
- if self._artist is None:
- return None
- return self._to_result(self._artist, lambda a: a.name)
-
- @property
- def album(self) -> Optional[List[API.Album]]:
- if self._album is None:
- return None
-
- return self._to_result(self._album, lambda a: f"{a.name} - {a.artist}")
-
- @property
- def song(self) -> Optional[List[API.Song]]:
- if self._song is None:
- return None
- return self._to_result(self._song, lambda s: f"{s.title} - {s.artist}")
-
- @property
- def playlist(self) -> Optional[List[API.Playlist]]:
- if self._playlist is None:
- return None
- return self._to_result(self._playlist, lambda p: p.name)
-
-
T = TypeVar("T")
@@ -435,114 +334,11 @@ class CacheManager(metaclass=Singleton):
self.app_config.server.strhash(), *relative_paths
)
- def calculate_download_path(self, *relative_paths) -> Path:
- """
- Determine where to temporarily put the file as it is downloading.
- """
- assert self.app_config.server is not None
- xdg_cache_home = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser(
- "~/.cache"
- )
- return Path(xdg_cache_home).joinpath(
- "sublime-music", self.app_config.server.strhash(), *relative_paths,
- )
-
- def return_cached_or_download(
- self,
- relative_path: Union[Path, str],
- download_fn: Callable[[], bytes],
- before_download: Callable[[], None] = lambda: None,
- force: bool = False,
- allow_download: bool = True,
- ) -> "CacheManager.Result[str]":
- abs_path = self.calculate_abs_path(relative_path)
- abs_path_str = str(abs_path)
- download_path = self.calculate_download_path(relative_path)
-
- if abs_path.exists() and not force:
- return CacheManager.Result.from_data(abs_path_str)
-
- if not allow_download:
- return CacheManager.Result.from_data("")
-
- def do_download() -> str:
- resource_downloading = False
- with self.download_set_lock:
- if abs_path_str in self.current_downloads:
- resource_downloading = True
-
- self.current_downloads.add(abs_path_str)
-
- if resource_downloading:
- logging.info(f"{abs_path} already being downloaded.")
- # The resource is already being downloaded. Busy loop until
- # it has completed. Then, just return the path to the
- # resource.
- while abs_path_str in self.current_downloads:
- sleep(0.2)
- else:
- logging.info(f"{abs_path} not found. Downloading...")
-
- os.makedirs(download_path.parent, exist_ok=True)
- try:
- self.save_file(download_path, download_fn())
- except requests.exceptions.ConnectionError:
- with self.download_set_lock:
- self.current_downloads.discard(abs_path_str)
-
- # Move the file to its cache download location.
- os.makedirs(abs_path.parent, exist_ok=True)
- if download_path.exists():
- shutil.move(str(download_path), abs_path)
-
- logging.info(f"{abs_path} downloaded. Returning.")
- return abs_path_str
-
- def after_download(path: str):
- with self.download_set_lock:
- self.current_downloads.discard(path)
-
- return CacheManager.Result.from_server(
- do_download,
- before_download=before_download,
- after_download=after_download,
- )
-
@staticmethod
def create_future(fn: Callable, *args) -> Future:
"""Creates a future on the CacheManager's executor."""
return CacheManager.executor.submit(fn, *args)
- def delete_cached_cover_art(self, id: int):
- relative_path = f"cover_art/*{id}*"
-
- abs_path = self.calculate_abs_path(relative_path)
-
- for path in glob.glob(str(abs_path)):
- Path(path).unlink()
-
- def get_artist(
- self,
- artist_id: int,
- before_download: Callable[[], None] = lambda: None,
- force: bool = False,
- ) -> "CacheManager.Result[ArtistWithAlbumsID3]":
- cache_name = "artist_details"
-
- if artist_id in self.cache.get(cache_name, {}) and not force:
- return CacheManager.Result.from_data(self.cache[cache_name][artist_id])
-
- def after_download(artist: ArtistWithAlbumsID3):
- with self.cache_lock:
- self.cache[cache_name][artist_id] = artist
- self.save_cache_info()
-
- return CacheManager.Result.from_server(
- lambda: self.server.get_artist(artist_id),
- before_download=before_download,
- after_download=after_download,
- )
-
def get_indexes(
self,
before_download: Callable[[], None] = lambda: None,
@@ -592,239 +388,6 @@ class CacheManager(metaclass=Singleton):
after_download=after_download,
)
- def get_artist_info(
- self,
- artist_id: int,
- before_download: Callable[[], None] = lambda: None,
- force: bool = False,
- ) -> "CacheManager.Result[ArtistInfo2]":
- cache_name = "artist_infos"
-
- if artist_id in self.cache.get(cache_name, {}) and not force:
- return CacheManager.Result.from_data(self.cache[cache_name][artist_id])
-
- def after_download(artist_info: ArtistInfo2):
- if not artist_info:
- return
-
- with self.cache_lock:
- self.cache[cache_name][artist_id] = artist_info
- self.save_cache_info()
-
- return CacheManager.Result.from_server(
- lambda: (self.server.get_artist_info2(id=artist_id) or ArtistInfo2()),
- before_download=before_download,
- after_download=after_download,
- )
-
- def get_artist_artwork(
- self,
- artist: Union[Artist, ArtistID3],
- before_download: Callable[[], None] = lambda: None,
- force: bool = False,
- ) -> AdapterResult[str]:
- def do_get_artist_artwork(artist_info: ArtistInfo2) -> AdapterResult[str]:
- lastfm_url = "".join(artist_info.largeImageUrl or [])
-
- is_placeholder = lastfm_url == ""
- is_placeholder |= lastfm_url.endswith(
- "2a96cbd8b46e442fc41c2b86b821562f.png"
- )
- is_placeholder |= lastfm_url.endswith(
- "1024px-No_image_available.svg.png"
- )
-
- # If it is the placeholder LastFM image, try and use the cover
- # art filename given by the server.
- if is_placeholder:
- if isinstance(artist, (ArtistWithAlbumsID3, ArtistID3)):
- if artist.coverArt:
- return AdapterManager.get_cover_art_filename(
- artist.coverArt
- )
- elif (
- isinstance(artist, ArtistWithAlbumsID3)
- and artist.album
- and len(artist.album) > 0
- ):
- return AdapterManager.get_cover_art_filename(
- artist.album[0].coverArt
- )
-
- elif isinstance(artist, Directory) and len(artist.child) > 0:
- # Retrieve the first album's cover art
- return AdapterManager.get_cover_art_filename(
- artist.child[0].coverArt
- )
-
- if lastfm_url == "":
- return CacheManager.Result.from_data("")
-
- url_hash = hashlib.md5(lastfm_url.encode("utf-8")).hexdigest()
- return self.return_cached_or_download(
- f"cover_art/artist.{url_hash}",
- lambda: requests.get(lastfm_url).content,
- before_download=before_download,
- force=force,
- )
-
- def download_fn(artist_info: CacheManager.Result[ArtistInfo2]) -> str:
- # In this case, artist_info is a future, so we have to wait for
- # its result before calculating. Then, immediately unwrap the
- # result() because we are already within a future.
- return do_get_artist_artwork(artist_info.result()).result()
-
- artist_info = CacheManager.get_artist_info(artist.id)
- if artist_info.is_future:
- return CacheManager.Result.from_server(
- lambda: download_fn(artist_info), before_download=before_download,
- )
- else:
- return do_get_artist_artwork(artist_info.result())
-
- def get_album_list(
- self,
- type_: str,
- before_download: Callable[[], None] = lambda: None,
- force: bool = False,
- # Look at documentation for get_album_list in server.py:
- **params,
- ) -> "CacheManager.Result[List[AlbumID3]]":
- cache_name = "albums"
-
- if len(self.cache.get(cache_name, {}).get(type_, [])) > 0 and not force:
- return CacheManager.Result.from_data(self.cache[cache_name][type_])
-
- def do_get_album_list() -> List[AlbumID3]:
- def get_page(offset: int, page_size: int = 500,) -> List[AlbumID3]:
- return (
- self.server.get_album_list2(
- type_, size=page_size, offset=offset, **params,
- ).album
- or []
- )
-
- page_size = 40 if type_ == "random" else 500
- offset = 0
-
- next_page = get_page(offset, page_size=page_size)
- albums = next_page
-
- # If it returns 500 things, then there's more leftover.
- while len(next_page) == 500:
- next_page = get_page(offset)
- albums.extend(next_page)
- offset += 500
-
- return albums
-
- def after_download(albums: List[AlbumID3]):
- with self.cache_lock:
- if not self.cache[cache_name].get(type_):
- self.cache[cache_name][type_] = []
- self.cache[cache_name][type_] = albums
- self.save_cache_info()
-
- return CacheManager.Result.from_server(
- do_get_album_list,
- before_download=before_download,
- after_download=after_download,
- )
-
- def get_album(
- self,
- album_id: int,
- before_download: Callable[[], None] = lambda: None,
- force: bool = False,
- ) -> "CacheManager.Result[AlbumWithSongsID3]":
- cache_name = "album_details"
-
- if album_id in self.cache.get(cache_name, {}) and not force:
- return CacheManager.Result.from_data(self.cache[cache_name][album_id])
-
- def after_download(album: AlbumWithSongsID3):
- with self.cache_lock:
- self.cache[cache_name][album_id] = album
-
- # Albums have the song details as well, so save those too.
- for song in album.get("song", []):
- self.cache["song_details"][song.id] = song
- self.save_cache_info()
-
- return CacheManager.Result.from_server(
- lambda: self.server.get_album(album_id),
- before_download=before_download,
- after_download=after_download,
- )
-
- def search(
- self,
- query: str,
- search_callback: Callable[[SearchResult, bool], None],
- before_download: Callable[[], None] = lambda: None,
- ) -> "CacheManager.Result":
- if query == "":
- search_callback(SearchResult(""), True)
- return CacheManager.Result.from_data(None)
-
- before_download()
-
- # Keep track of if the result is cancelled and if it is, then don't
- # do anything with any results.
- cancelled = False
-
- # This future actually does the search and calls the
- # search_callback when each of the futures completes.
- def do_search():
- # Sleep for a little while before returning the local results.
- # They are less expensive to retrieve (but they still incur
- # some overhead due to the GTK UI main loop queue).
- sleep(0.2)
- if cancelled:
- return
-
- # Local Results
- search_result = SearchResult(query)
- search_result.add_results(
- "album", itertools.chain(*self.cache["albums"].values())
- )
- search_result.add_results("artist", self.cache["artists"])
- search_result.add_results("song", self.cache["song_details"].values())
- search_result.add_results("playlist", self.cache["playlists"])
- search_callback(search_result, False)
-
- # Wait longer to see if the user types anything else so we
- # don't peg the server with tons of requests.
- sleep(0.2)
- if cancelled:
- return
-
- # Server Results
- search_fn = self.server.search3
- try:
- # Attempt to add the server search results to the
- # SearchResult. If it fails, that's fine, we will use the
- # finally to always return a final SearchResult to the UI.
- server_result = search_fn(query)
- search_result.add_results("album", server_result.album)
- search_result.add_results("artist", server_result.artist)
- search_result.add_results("song", server_result.song)
- except Exception:
- # We really don't care about what the exception was (could
- # be connection error, could be invalid JSON, etc.) because
- # we will always have returned local results.
- return
- finally:
- search_callback(search_result, True)
-
- # When the future is cancelled (this will happen if a new search is
- # created).
- def on_cancel():
- nonlocal cancelled
- cancelled = True
-
- return CacheManager.Result.from_server(do_search, on_cancel=on_cancel)
-
_instance: Optional[__CacheManagerInternal] = None
def __init__(self):
@@ -833,4 +396,3 @@ class CacheManager(metaclass=Singleton):
@staticmethod
def reset(app_config: AppConfiguration):
CacheManager._instance = CacheManager.__CacheManagerInternal(app_config)
- similarity_ratio.cache_clear()
diff --git a/sublime/config.py b/sublime/config.py
index 500ec5d..30c7c2f 100644
--- a/sublime/config.py
+++ b/sublime/config.py
@@ -46,7 +46,8 @@ class ServerConfiguration:
self.version = 0
def strhash(self) -> str:
- # TODO: needs to change to something better
+ # TODO: make this configurable by the adapters the combination of the hashes
+ # will be the hash dir
"""
Returns the MD5 hash of the server's name, server address, and
username. This should be used whenever it's necessary to uniquely
diff --git a/sublime/ui/browse.py b/sublime/ui/browse.py
index 887bbc4..1b0e747 100644
--- a/sublime/ui/browse.py
+++ b/sublime/ui/browse.py
@@ -1,11 +1,10 @@
-from typing import Any, List, Optional, Tuple, Type, Union
+from functools import partial
+from typing import Any, List, Optional, Tuple, Union
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
-from sublime.adapters import AdapterManager
-from sublime.cache_manager import CacheManager
+from sublime.adapters import AdapterManager, api_objects as API, Result
from sublime.config import AppConfiguration
-from sublime.server.api_objects import Artist, Child, Directory
from sublime.ui import util
from sublime.ui.common import IconButton, SongListColumn
@@ -33,7 +32,7 @@ class BrowsePanel(Gtk.Overlay):
super().__init__()
scrolled_window = Gtk.ScrolledWindow()
- self.root_directory_listing = ListAndDrilldown(IndexList)
+ self.root_directory_listing = ListAndDrilldown()
self.root_directory_listing.connect(
"song-clicked", lambda _, *args: self.emit("song-clicked", *args),
)
@@ -53,38 +52,44 @@ class BrowsePanel(Gtk.Overlay):
self.add_overlay(self.spinner)
def update(self, app_config: AppConfiguration, force: bool = False):
- if not CacheManager.ready:
+ if not AdapterManager.can_get_directory():
return
self.update_order_token += 1
- def do_update(id_stack: List[int], update_order_token: int):
+ def do_update(update_order_token: int, id_stack: Result[List[int]]):
if self.update_order_token != update_order_token:
return
+ # TODO pass order token here?
self.root_directory_listing.update(
- id_stack, app_config=app_config, force=force,
+ id_stack.result(), app_config, force=force,
)
self.spinner.hide()
- def calculate_path(update_order_token: int) -> Tuple[List[str], int]:
+ def calculate_path() -> List[str]:
if app_config.state.selected_browse_element_id is None:
- return [], update_order_token
+ return []
id_stack = []
- directory = None
- current_dir_id = app_config.state.selected_browse_element_id
- while directory is None or directory.parent is not None:
- directory = CacheManager.get_music_directory(
+ current_dir_id: Optional[str] = app_config.state.selected_browse_element_id
+ while current_dir_id and (
+ directory := AdapterManager.get_directory(
current_dir_id, before_download=self.spinner.show,
).result()
+ ):
id_stack.append(directory.id)
- current_dir_id = directory.parent # Detect loops?
+ if directory.id == "root":
+ break
+ # Detect loops?
+ current_dir_id = directory.parent.id if directory.parent else None
- return id_stack, update_order_token
+ return id_stack
- path_fut = CacheManager.create_future(calculate_path, self.update_order_token)
- path_fut.add_done_callback(lambda f: GLib.idle_add(do_update, *f.result()))
+ path_result: Result[List[str]] = Result(calculate_path)
+ path_result.add_done_callback(
+ partial(GLib.idle_add, partial(do_update, self.update_order_token))
+ )
class ListAndDrilldown(Gtk.Paned):
@@ -103,10 +108,10 @@ class ListAndDrilldown(Gtk.Paned):
id_stack = None
- def __init__(self, list_type: Type):
+ def __init__(self):
Gtk.Paned.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
- self.list = list_type()
+ self.list = MusicDirectoryList()
self.list.connect(
"song-clicked", lambda _, *args: self.emit("song-clicked", *args),
)
@@ -119,46 +124,45 @@ class ListAndDrilldown(Gtk.Paned):
self.pack2(self.drilldown, True, False)
def update(
- self,
- id_stack: List[int],
- app_config: AppConfiguration,
- force: bool = False,
- directory_id: int = None,
+ self, id_stack: List[str], app_config: AppConfiguration, force: bool = False
):
+ dir_id = id_stack[-1]
+ selected_id = (
+ id_stack[-2]
+ if len(id_stack) > 2
+ else app_config.state.selected_browse_element_id
+ )
+
self.list.update(
- None if len(id_stack) == 0 else id_stack[-1],
- app_config,
+ directory_id=dir_id,
+ selected_id=selected_id,
+ app_config=app_config,
force=force,
- directory_id=directory_id,
)
if self.id_stack == id_stack:
# We always want to update, but in this case, we don't want to blow
# away the drilldown.
if isinstance(self.drilldown, ListAndDrilldown):
- self.drilldown.update(
- id_stack[:-1], app_config, force=force, directory_id=id_stack[-1],
- )
+ self.drilldown.update(id_stack[:-1], app_config, force=force)
return
self.id_stack = id_stack
- if len(id_stack) > 0:
+ if len(id_stack) > 1:
self.remove(self.drilldown)
- self.drilldown = ListAndDrilldown(MusicDirectoryList)
+ self.drilldown = ListAndDrilldown()
self.drilldown.connect(
"song-clicked", lambda _, *args: self.emit("song-clicked", *args),
)
self.drilldown.connect(
"refresh-window", lambda _, *args: self.emit("refresh-window", *args),
)
- self.drilldown.update(
- id_stack[:-1], app_config, force=force, directory_id=id_stack[-1],
- )
+ self.drilldown.update(id_stack[:-1], app_config, force=force)
self.drilldown.show_all()
self.pack2(self.drilldown, True, False)
-class DrilldownList(Gtk.Box):
+class MusicDirectoryList(Gtk.Box):
__gsignals__ = {
"song-clicked": (
GObject.SignalFlags.RUN_FIRST,
@@ -172,16 +176,20 @@ class DrilldownList(Gtk.Box):
),
}
+ update_order_token = 0
+ directory_id: Optional[str] = None
+ selected_id: Optional[str] = None
+
class DrilldownElement(GObject.GObject):
id = GObject.Property(type=str)
name = GObject.Property(type=str)
is_dir = GObject.Property(type=bool, default=True)
- def __init__(self, element: Union[Child, Artist]):
+ def __init__(self, element: Union[API.Directory, API.Song]):
GObject.GObject.__init__(self)
self.id = element.id
- self.name = element.name if isinstance(element, Artist) else element.title
- self.is_dir = element.get("isDir", True)
+ self.is_dir = isinstance(element, API.Directory)
+ self.name = element.title
def __init__(self):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
@@ -215,7 +223,7 @@ class DrilldownList(Gtk.Box):
self.directory_song_list = Gtk.TreeView(
model=self.directory_song_store,
- name="album-songs-list",
+ name="directory-songs-list",
headers_visible=False,
)
self.directory_song_list.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE)
@@ -241,6 +249,110 @@ class DrilldownList(Gtk.Box):
self.scroll_window.add(scrollbox)
self.pack_start(self.scroll_window, True, True, 0)
+ def update(
+ self,
+ app_config: AppConfiguration = None,
+ force: bool = False,
+ directory_id: str = None,
+ selected_id: str = None,
+ ):
+ self.directory_id = directory_id or self.directory_id
+ self.selected_id = selected_id or self.selected_id
+ self.update_store(
+ self.directory_id, force=force, order_token=self.update_order_token,
+ )
+
+ @util.async_callback(
+ AdapterManager.get_directory,
+ before_download=lambda self: self.loading_indicator.show(),
+ on_failure=lambda self, e: self.loading_indicator.hide(),
+ )
+ def update_store(
+ self,
+ directory: API.Directory,
+ app_config: AppConfiguration = None,
+ force: bool = False,
+ order_token: int = None,
+ ):
+ if order_token != self.update_order_token:
+ return
+
+ new_directories_store = []
+ new_songs_store = []
+ selected_dir_idx = None
+
+ for idx, el in enumerate(directory.children):
+ if isinstance(el, API.Directory):
+ new_directories_store.append(MusicDirectoryList.DrilldownElement(el))
+ if el.id == self.selected_id:
+ selected_dir_idx = idx
+ else:
+ new_songs_store.append(
+ [
+ util.get_cached_status_icon(
+ AdapterManager.get_cached_status(el)
+ ),
+ util.esc(el.title),
+ util.format_song_duration(el.duration),
+ el.id,
+ ]
+ )
+
+ util.diff_model_store(self.drilldown_directories_store, new_directories_store)
+
+ util.diff_song_store(self.directory_song_store, new_songs_store)
+
+ if len(new_directories_store) == 0:
+ self.list.hide()
+ else:
+ self.list.show()
+
+ if len(new_songs_store) == 0:
+ self.directory_song_list.hide()
+ self.scroll_window.set_min_content_width(275)
+ else:
+ self.directory_song_list.show()
+ self.scroll_window.set_min_content_width(350)
+
+ # Preserve selection
+ if selected_dir_idx is not None:
+ row = self.list.get_row_at_index(selected_dir_idx)
+ self.list.select_row(row)
+
+ self.loading_indicator.hide()
+
+ def on_download_state_change(self, _):
+ self.update()
+
+ # Create Element Helper Functions
+ # ==================================================================================
+ def create_row(self, model: DrilldownElement) -> Gtk.ListBoxRow:
+ row = Gtk.ListBoxRow(
+ action_name="app.browse-to", action_target=GLib.Variant("s", model.id),
+ )
+ rowbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
+ rowbox.add(
+ Gtk.Label(
+ label=f"{util.esc(model.name)}",
+ use_markup=True,
+ margin=8,
+ halign=Gtk.Align.START,
+ ellipsize=Pango.EllipsizeMode.END,
+ )
+ )
+
+ icon = Gio.ThemedIcon(name="go-next-symbolic")
+ image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON)
+ rowbox.pack_end(image, False, False, 5)
+ row.add(rowbox)
+ row.show_all()
+ return row
+
+ # Event Handlers
+ # ==================================================================================
+ def on_refresh_clicked(self, _: Any):
+ self.update(force=True)
+
def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any):
# The song ID is in the last column of the model.
self.emit(
@@ -284,153 +396,3 @@ class DrilldownList(Gtk.Box):
return True
return False
-
- def do_update_store(self, elements: Optional[List[Any]]):
- new_directories_store = []
- new_songs_store = []
- selected_dir_idx = None
-
- for idx, el in enumerate(elements or []):
- if el.get("isDir", True):
- new_directories_store.append(DrilldownList.DrilldownElement(el))
- if el.id == self.selected_id:
- selected_dir_idx = idx
- else:
- new_songs_store.append(
- [
- util.get_cached_status_icon(
- AdapterManager.get_cached_status(el)
- ),
- util.esc(el.title),
- util.format_song_duration(el.duration),
- el.id,
- ]
- )
-
- util.diff_model_store(self.drilldown_directories_store, new_directories_store)
-
- util.diff_song_store(self.directory_song_store, new_songs_store)
-
- if len(new_directories_store) == 0:
- self.list.hide()
- else:
- self.list.show()
-
- if len(new_songs_store) == 0:
- self.directory_song_list.hide()
- self.scroll_window.set_min_content_width(275)
- else:
- self.directory_song_list.show()
- self.scroll_window.set_min_content_width(350)
-
- # Preserve selection
- if selected_dir_idx is not None:
- row = self.list.get_row_at_index(selected_dir_idx)
- self.list.select_row(row)
-
- self.loading_indicator.hide()
-
- def create_row(self, model: "DrilldownList.DrilldownElement") -> Gtk.ListBoxRow:
- row = Gtk.ListBoxRow(
- action_name="app.browse-to", action_target=GLib.Variant("s", model.id),
- )
- rowbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- rowbox.add(
- Gtk.Label(
- label=f"{util.esc(model.name)}",
- use_markup=True,
- margin=8,
- halign=Gtk.Align.START,
- ellipsize=Pango.EllipsizeMode.END,
- )
- )
-
- icon = Gio.ThemedIcon(name="go-next-symbolic")
- image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON)
- rowbox.pack_end(image, False, False, 5)
- row.add(rowbox)
- row.show_all()
- return row
-
-
-class IndexList(DrilldownList):
- update_order_token = 0
-
- def update(
- self,
- selected_id: int,
- app_config: AppConfiguration = None,
- force: bool = False,
- **kwargs,
- ):
- self.update_order_token += 1
- self.selected_id = selected_id
- self.update_store(
- force=force, app_config=app_config, order_token=self.update_order_token,
- )
-
- def on_refresh_clicked(self, _: Any):
- self.update(self.selected_id, force=True)
-
- @util.async_callback(
- lambda *a, **k: CacheManager.get_indexes(*a, **k),
- before_download=lambda self: self.loading_indicator.show(),
- on_failure=lambda self, e: self.loading_indicator.hide(),
- )
- def update_store(
- self,
- artists: List[Artist],
- app_config: AppConfiguration = None,
- force: bool = False,
- order_token: int = None,
- ):
- if order_token != self.update_order_token:
- return
-
- self.do_update_store(artists)
-
- def on_download_state_change(self):
- self.update(self.selected_id)
-
-
-class MusicDirectoryList(DrilldownList):
- update_order_token = 0
-
- def update(
- self,
- selected_id: int,
- app_config: AppConfiguration = None,
- force: bool = False,
- directory_id: int = None,
- ):
- self.directory_id = directory_id
- self.selected_id = selected_id
- self.update_store(
- directory_id,
- force=force,
- app_config=app_config,
- order_token=self.update_order_token,
- )
-
- def on_refresh_clicked(self, _: Any):
- self.update(self.selected_id, force=True, directory_id=self.directory_id)
-
- @util.async_callback(
- lambda *a, **k: CacheManager.get_music_directory(*a, **k),
- before_download=lambda self: self.loading_indicator.show(),
- on_failure=lambda self, e: self.loading_indicator.hide(),
- )
- def update_store(
- self,
- directory: Directory,
- app_config: AppConfiguration = None,
- force: bool = False,
- order_token: int = None,
- ):
- if order_token != self.update_order_token:
- return
-
- self.do_update_store(directory.child)
-
- def on_download_state_change(self):
- self.update(self.selected_id, directory_id=self.directory_id)
diff --git a/sublime/ui/main.py b/sublime/ui/main.py
index 9616ab0..a0ee084 100644
--- a/sublime/ui/main.py
+++ b/sublime/ui/main.py
@@ -3,7 +3,7 @@ from typing import Any, Optional, Set
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
-from sublime.adapters import AdapterManager, Result, api_objects as API
+from sublime.adapters import AdapterManager, api_objects as API, Result
from sublime.config import AppConfiguration
from sublime.ui import albums, artists, browse, player_controls, playlists, util
from sublime.ui.common import SpinnerImage
diff --git a/sublime/ui/state.py b/sublime/ui/state.py
index a36f562..dfceb27 100644
--- a/sublime/ui/state.py
+++ b/sublime/ui/state.py
@@ -3,8 +3,8 @@ from datetime import timedelta
from enum import Enum
from typing import Dict, Optional, Tuple
-from sublime.adapters.api_objects import Song
from sublime.adapters import AlbumSearchQuery
+from sublime.adapters.api_objects import Genre, Song
class RepeatType(Enum):
@@ -52,9 +52,13 @@ class UIState:
selected_browse_element_id: Optional[str] = None
selected_playlist_id: Optional[str] = None
+ class _DefaultGenre(Genre):
+ def __init__(self):
+ self.name = "Rock"
+
# State for Album sort.
current_album_search_query: AlbumSearchQuery = AlbumSearchQuery(
- AlbumSearchQuery.Type.RANDOM, genre=None, year_range=(2010, 2020),
+ AlbumSearchQuery.Type.RANDOM, genre=_DefaultGenre(), year_range=(2010, 2020),
)
active_playlist_id: Optional[str] = None
diff --git a/tests/adapter_tests/adapter_manager_tests.py b/tests/adapter_tests/adapter_manager_tests.py
index 58d93f5..4c5da92 100644
--- a/tests/adapter_tests/adapter_manager_tests.py
+++ b/tests/adapter_tests/adapter_manager_tests.py
@@ -1,6 +1,26 @@
+from pathlib import Path
from time import sleep
-from sublime.adapters import Result
+import pytest
+
+from sublime.adapters import AdapterManager, Result
+from sublime.config import AppConfiguration, ServerConfiguration
+
+
+@pytest.fixture
+def adapter_manager(tmp_path: Path):
+ config = AppConfiguration(
+ servers=[
+ ServerConfiguration(
+ name="foo", server_address="bar", username="baz", password="ohea",
+ )
+ ],
+ current_server_index=0,
+ cache_location=tmp_path.as_posix(),
+ )
+ AdapterManager.reset(config)
+ yield
+ AdapterManager.shutdown()
def test_result_immediate():
@@ -24,7 +44,7 @@ def test_result_immediate_callback():
def test_result_future():
def resolve_later() -> int:
- sleep(1)
+ sleep(0.1)
return 42
result = Result(resolve_later)
@@ -35,7 +55,7 @@ def test_result_future():
def test_result_future_callback():
def resolve_later() -> int:
- sleep(1)
+ sleep(0.1)
return 42
check_done = False
@@ -49,21 +69,48 @@ def test_result_future_callback():
result = Result(resolve_later)
result.add_done_callback(check_done_callback)
- # Should take much less than 2 seconds to complete. If the assertion fails, then the
+ # Should take much less than 1 seconds to complete. If the assertion fails, then the
# check_done_callback failed.
t = 0
while not check_done:
- assert t < 2
+ assert t < 1
t += 0.1
sleep(0.1)
def test_default_value():
def resolve_fail() -> int:
- sleep(1)
+ sleep(0.1)
raise Exception()
result = Result(resolve_fail, default_value=42)
assert not result.data_is_available
assert result.result() == 42
assert result.data_is_available
+
+
+def test_cancel():
+ def resolve_later() -> int:
+ sleep(0.1)
+ return 42
+
+ cancel_called = False
+
+ def on_cancel():
+ nonlocal cancel_called
+ cancel_called = True
+
+ result = Result(resolve_later, on_cancel=on_cancel)
+ result.cancel()
+ assert cancel_called
+ assert not result.data_is_available
+ with pytest.raises(Exception):
+ result.result()
+
+
+def test_get_song_details(adapter_manager: AdapterManager):
+ # song = AdapterManager.get_song_details("1")
+ # print(song)
+ # assert 0
+ # TODO
+ pass
diff --git a/tests/adapter_tests/filesystem_adapter_tests.py b/tests/adapter_tests/filesystem_adapter_tests.py
index a22b410..8dd37b6 100644
--- a/tests/adapter_tests/filesystem_adapter_tests.py
+++ b/tests/adapter_tests/filesystem_adapter_tests.py
@@ -8,14 +8,21 @@ import pytest
from peewee import SelectQuery
-from sublime import util
from sublime.adapters import api_objects as SublimeAPI, CacheMissError
from sublime.adapters.filesystem import FilesystemAdapter
from sublime.adapters.subsonic import api_objects as SubsonicAPI
MOCK_DATA_FILES = Path(__file__).parent.joinpath("mock_data")
MOCK_ALBUM_ART = MOCK_DATA_FILES.joinpath("album-art.png")
+MOCK_ALBUM_ART2 = MOCK_DATA_FILES.joinpath("album-art2.png")
+MOCK_ALBUM_ART3 = MOCK_DATA_FILES.joinpath("album-art3.png")
MOCK_SONG_FILE = MOCK_DATA_FILES.joinpath("test-song.mp3")
+MOCK_SONG_FILE2 = MOCK_DATA_FILES.joinpath("test-song2.mp3")
+MOCK_ALBUM_ART_HASH = "5d7bee4f3fe25b18cd2a66f1c9767e381bc64328"
+MOCK_ALBUM_ART2_HASH = "031a8a1ca01f64f851a22d5478e693825a00fb23"
+MOCK_ALBUM_ART3_HASH = "46a8af0f8fe370e59202a545803e8bbb3a4a41ee"
+MOCK_SONG_FILE_HASH = "fe12d0712dbfd6ff7f75ef3783856a7122a78b0a"
+MOCK_SONG_FILE2_HASH = "c32597c724e2e484dbf5856930b2e5bb80de13b7"
MOCK_SUBSONIC_SONGS = [
SubsonicAPI.Song(
@@ -28,7 +35,7 @@ MOCK_SUBSONIC_SONGS = [
artist_id="art1",
duration=timedelta(seconds=20.8),
path="foo/song2.mp3",
- cover_art="2",
+ cover_art="s2",
_genre="Bar",
),
SubsonicAPI.Song(
@@ -41,7 +48,7 @@ MOCK_SUBSONIC_SONGS = [
artist_id="art2",
duration=timedelta(seconds=10.2),
path="foo/song1.mp3",
- cover_art="1",
+ cover_art="s1",
_genre="Foo",
),
SubsonicAPI.Song(
@@ -54,7 +61,7 @@ MOCK_SUBSONIC_SONGS = [
artist_id="art2",
duration=timedelta(seconds=10.2),
path="foo/song1.mp3",
- cover_art="1",
+ cover_art="s1",
_genre="Foo",
),
]
@@ -89,21 +96,16 @@ def mock_data_files(
def verify_songs(
actual_songs: Iterable[SublimeAPI.Song], expected_songs: Iterable[SubsonicAPI.Song]
):
+ actual_songs, expected_songs = (list(actual_songs), list(expected_songs))
+ assert len(actual_songs) == len(expected_songs)
for actual, song in zip(actual_songs, expected_songs):
for k, v in asdict(song).items():
- ignore = (
- "_genre",
- "_album",
- "_artist",
- "_parent",
- "album_id",
- "artist_id",
- )
- if k in ignore:
+ if k in ("_genre", "_album", "_artist", "_parent", "album_id", "artist_id"):
continue
- print(k) # noqa: T001
+ print(k, "->", v) # noqa: T001
actual_value = getattr(actual, k, None)
+
if k == "album":
assert ("a1", "foo") == (actual_value.id, actual_value.name)
elif k == "genre":
@@ -292,7 +294,7 @@ def test_invalidate_playlist(cache_adapter: FilesystemAdapter):
SubsonicAPI.PlaylistWithSongs("2", "test2", cover_art="pl_2", songs=[]),
)
cache_adapter.ingest_new_data(
- FilesystemAdapter.CachedDataKey.COVER_ART_FILE, ("pl_2",), MOCK_ALBUM_ART,
+ FilesystemAdapter.CachedDataKey.COVER_ART_FILE, ("pl_2",), MOCK_ALBUM_ART2,
)
stale_uri_1 = cache_adapter.get_cover_art_uri("pl_test1", "file")
@@ -338,43 +340,31 @@ def test_invalidate_playlist(cache_adapter: FilesystemAdapter):
assert e.partial_data == stale_uri_2
-def test_invalidate_song_data(cache_adapter: FilesystemAdapter):
+def test_invalidate_song_file(cache_adapter: FilesystemAdapter):
+ CACHE_KEYS = FilesystemAdapter.CachedDataKey
cache_adapter.ingest_new_data(
- FilesystemAdapter.CachedDataKey.SONG_DETAILS, ("2",), MOCK_SUBSONIC_SONGS[0]
+ CACHE_KEYS.SONG_DETAILS, ("2",), MOCK_SUBSONIC_SONGS[0]
)
cache_adapter.ingest_new_data(
- FilesystemAdapter.CachedDataKey.SONG_DETAILS, ("1",), MOCK_SUBSONIC_SONGS[1]
+ CACHE_KEYS.SONG_DETAILS, ("1",), MOCK_SUBSONIC_SONGS[1]
)
cache_adapter.ingest_new_data(
- FilesystemAdapter.CachedDataKey.COVER_ART_FILE, ("1",), MOCK_ALBUM_ART,
- )
- cache_adapter.ingest_new_data(
- FilesystemAdapter.CachedDataKey.SONG_FILE, ("1",), MOCK_SONG_FILE
- )
- cache_adapter.ingest_new_data(
- FilesystemAdapter.CachedDataKey.SONG_FILE, ("2",), MOCK_SONG_FILE
+ CACHE_KEYS.COVER_ART_FILE, ("s1", "song"), MOCK_ALBUM_ART,
)
+ cache_adapter.ingest_new_data(CACHE_KEYS.SONG_FILE, ("1",), MOCK_SONG_FILE)
+ cache_adapter.ingest_new_data(CACHE_KEYS.SONG_FILE, ("2",), MOCK_SONG_FILE2)
- stale_song_file = cache_adapter.get_song_uri("1", "file")
- stale_cover_art_file = cache_adapter.get_cover_art_uri("1", "file")
- cache_adapter.invalidate_data(FilesystemAdapter.CachedDataKey.SONG_FILE, ("1",))
+ cache_adapter.invalidate_data(CACHE_KEYS.SONG_FILE, ("1",))
+ cache_adapter.invalidate_data(CACHE_KEYS.COVER_ART_FILE, ("s1", "song"))
- try:
+ with pytest.raises(CacheMissError):
cache_adapter.get_song_uri("1", "file")
- assert 0, "DID NOT raise CacheMissError"
- except CacheMissError as e:
- assert e.partial_data
- assert e.partial_data == stale_song_file
- try:
- cache_adapter.get_cover_art_uri("1", "file")
- assert 0, "DID NOT raise CacheMissError"
- except CacheMissError as e:
- assert e.partial_data
- assert e.partial_data == stale_cover_art_file
+ with pytest.raises(CacheMissError):
+ cache_adapter.get_cover_art_uri("s1", "file")
- # Make sure it didn't delete the other ones.
- assert cache_adapter.get_song_uri("2", "file").endswith("song2.mp3")
+ # Make sure it didn't delete the other song.
+ assert cache_adapter.get_song_uri("2", "file").endswith(MOCK_SONG_FILE2_HASH)
def test_delete_playlists(cache_adapter: FilesystemAdapter):
@@ -410,11 +400,13 @@ def test_delete_playlists(cache_adapter: FilesystemAdapter):
# Even if the cover art failed to be deleted, it should cache miss.
shutil.copy(
- MOCK_ALBUM_ART,
- str(cache_adapter.cover_art_dir.joinpath(util.params_hash("pl_1"))),
+ MOCK_ALBUM_ART, str(cache_adapter.cover_art_dir.joinpath(MOCK_ALBUM_ART_HASH)),
)
- with pytest.raises(CacheMissError):
+ try:
cache_adapter.get_cover_art_uri("pl_1", "file")
+ assert 0, "DID NOT raise CacheMissError"
+ except CacheMissError as e:
+ assert e.partial_data is None
def test_delete_song_data(cache_adapter: FilesystemAdapter):
@@ -422,16 +414,17 @@ def test_delete_song_data(cache_adapter: FilesystemAdapter):
FilesystemAdapter.CachedDataKey.SONG_DETAILS, ("1",), MOCK_SUBSONIC_SONGS[1]
)
cache_adapter.ingest_new_data(
- FilesystemAdapter.CachedDataKey.COVER_ART_FILE, ("1",), MOCK_ALBUM_ART,
+ FilesystemAdapter.CachedDataKey.SONG_FILE, ("1",), MOCK_SONG_FILE
)
cache_adapter.ingest_new_data(
- FilesystemAdapter.CachedDataKey.SONG_FILE, ("1",), MOCK_SONG_FILE
+ FilesystemAdapter.CachedDataKey.COVER_ART_FILE, ("s1",), MOCK_ALBUM_ART,
)
music_file_path = cache_adapter.get_song_uri("1", "file")
- cover_art_path = cache_adapter.get_cover_art_uri("1", "file")
+ cover_art_path = cache_adapter.get_cover_art_uri("s1", "file")
cache_adapter.delete_data(FilesystemAdapter.CachedDataKey.SONG_FILE, ("1",))
+ cache_adapter.delete_data(FilesystemAdapter.CachedDataKey.COVER_ART_FILE, ("s1",))
assert not Path(music_file_path).exists()
assert not Path(cover_art_path).exists()
@@ -443,7 +436,7 @@ def test_delete_song_data(cache_adapter: FilesystemAdapter):
assert e.partial_data is None
try:
- cache_adapter.get_cover_art_uri("1", "file")
+ cache_adapter.get_cover_art_uri("s1", "file")
assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e:
assert e.partial_data is None
@@ -648,7 +641,7 @@ def test_caching_get_artist(cache_adapter: FilesystemAdapter):
)
artist = cache_adapter.get_artist("1")
- assert (
+ assert artist.artist_image_url and (
artist.id,
artist.name,
artist.album_count,
@@ -686,7 +679,7 @@ def test_caching_get_artist(cache_adapter: FilesystemAdapter):
)
artist = cache_adapter.get_artist("1")
- assert (
+ assert artist.artist_image_url and (
artist.id,
artist.name,
artist.album_count,
@@ -728,7 +721,7 @@ def test_caching_get_album(cache_adapter: FilesystemAdapter):
)
album = cache_adapter.get_album("a1")
- assert album
+ assert album and album.cover_art
assert (
album.id,
album.name,
@@ -747,9 +740,9 @@ def test_caching_invalidate_artist(cache_adapter: FilesystemAdapter):
# Simulate the artist details being retrieved from Subsonic.
cache_adapter.ingest_new_data(
FilesystemAdapter.CachedDataKey.ARTIST,
- ("1",),
+ ("artist1",),
SubsonicAPI.ArtistAndArtistInfo(
- "1",
+ "artist1",
"Bar",
album_count=1,
artist_image_url="image",
@@ -768,47 +761,40 @@ def test_caching_invalidate_artist(cache_adapter: FilesystemAdapter):
cache_adapter.ingest_new_data(
FilesystemAdapter.CachedDataKey.ALBUM,
("1",),
- SubsonicAPI.Album("1", "Foo", artist_id="1", cover_art="1"),
+ SubsonicAPI.Album("1", "Foo", artist_id="artist1", cover_art="1"),
)
cache_adapter.ingest_new_data(
FilesystemAdapter.CachedDataKey.ALBUM,
("2",),
- SubsonicAPI.Album("2", "Bar", artist_id="1", cover_art="2"),
+ SubsonicAPI.Album("2", "Bar", artist_id="artist1", cover_art="2"),
)
cache_adapter.ingest_new_data(
- FilesystemAdapter.CachedDataKey.COVER_ART_FILE, ("image",), MOCK_ALBUM_ART,
+ FilesystemAdapter.CachedDataKey.COVER_ART_FILE, ("image",), MOCK_ALBUM_ART3,
)
cache_adapter.ingest_new_data(
FilesystemAdapter.CachedDataKey.COVER_ART_FILE, ("1",), MOCK_ALBUM_ART,
)
cache_adapter.ingest_new_data(
- FilesystemAdapter.CachedDataKey.COVER_ART_FILE, ("2",), MOCK_ALBUM_ART,
+ FilesystemAdapter.CachedDataKey.COVER_ART_FILE, ("2",), MOCK_ALBUM_ART2,
)
- stale_artist = cache_adapter.get_artist("1")
+ stale_artist = cache_adapter.get_artist("artist1")
stale_album_1 = cache_adapter.get_album("1")
stale_album_2 = cache_adapter.get_album("2")
stale_artist_artwork = cache_adapter.get_cover_art_uri("image", "file")
stale_cover_art_1 = cache_adapter.get_cover_art_uri("1", "file")
stale_cover_art_2 = cache_adapter.get_cover_art_uri("2", "file")
- cache_adapter.invalidate_data(FilesystemAdapter.CachedDataKey.ARTIST, ("1",))
+ cache_adapter.invalidate_data(FilesystemAdapter.CachedDataKey.ARTIST, ("artist1",))
# Test the cascade of cache invalidations.
try:
- cache_adapter.get_artist("1")
+ cache_adapter.get_artist("artist1")
assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e:
assert e.partial_data
assert e.partial_data == stale_artist
- try:
- cache_adapter.get_cover_art_uri("image", "file")
- assert 0, "DID NOT raise CacheMissError"
- except CacheMissError as e:
- assert e.partial_data
- assert e.partial_data == stale_artist_artwork
-
try:
cache_adapter.get_album("1")
assert 0, "DID NOT raise CacheMissError"
@@ -823,6 +809,13 @@ def test_caching_invalidate_artist(cache_adapter: FilesystemAdapter):
assert e.partial_data
assert e.partial_data == stale_album_2
+ try:
+ cache_adapter.get_cover_art_uri("image", "file")
+ assert 0, "DID NOT raise CacheMissError"
+ except CacheMissError as e:
+ assert e.partial_data
+ assert e.partial_data == stale_artist_artwork
+
try:
cache_adapter.get_cover_art_uri("1", "file")
assert 0, "DID NOT raise CacheMissError"
diff --git a/tests/adapter_tests/mock_data/README b/tests/adapter_tests/mock_data/README
index 2596bdc..7f81a7d 100644
--- a/tests/adapter_tests/mock_data/README
+++ b/tests/adapter_tests/mock_data/README
@@ -1,2 +1,4 @@
-test-song.mp3 was originally named Happy_Music-2018-09-18_-_Beautiful_Memories_-_David_Fesliyan.mp3
-which is royalty free music from https://www.fesliyanstudios.com
+The test songs are royalty free music from https://www.fesliyanstudios.com
+
+* test-song.mp3 (originally named Happy_Music-2018-09-18_-_Beautiful_Memories_-_David_Fesliyan.mp3)
+* test-song2.mp3 (originally named 2017-03-24_-_Lone_Rider_-_David_Fesliyan.mp3)
diff --git a/tests/adapter_tests/mock_data/album-art2.png b/tests/adapter_tests/mock_data/album-art2.png
new file mode 100644
index 0000000..a70421e
--- /dev/null
+++ b/tests/adapter_tests/mock_data/album-art2.png
@@ -0,0 +1 @@
+really not a PNG
diff --git a/tests/adapter_tests/mock_data/album-art3.png b/tests/adapter_tests/mock_data/album-art3.png
new file mode 100644
index 0000000..dc8e5d6
--- /dev/null
+++ b/tests/adapter_tests/mock_data/album-art3.png
@@ -0,0 +1 @@
+definitely not a PNG. Stop looking lol
diff --git a/tests/adapter_tests/mock_data/test-song2.mp3 b/tests/adapter_tests/mock_data/test-song2.mp3
new file mode 100644
index 0000000..d8f7f6b
Binary files /dev/null and b/tests/adapter_tests/mock_data/test-song2.mp3 differ