Fixed a bunch of perf issues; handled null album/artist IDs; sort directories & albums in artist view & genres

Closes #181
This commit is contained in:
Sumner Evans
2020-05-18 20:57:03 -06:00
parent 8344139c83
commit d312046307
21 changed files with 551 additions and 244 deletions

View File

@@ -48,6 +48,7 @@ v0.9.1
keyring notification popup. keyring notification popup.
* The ``NM`` library is used instead of the deprecated ``NetworkManager`` and * The ``NM`` library is used instead of the deprecated ``NetworkManager`` and
``NMClient``. (Contributed by @anarcat.) ``NMClient``. (Contributed by @anarcat.)
* Sublime Music will crash less often due to missing dependencies.
* Fixed some bugs where the state of the application wouldn't update when you * Fixed some bugs where the state of the application wouldn't update when you
deleted/downloaded songs from certain parts of the application. deleted/downloaded songs from certain parts of the application.

View File

@@ -815,11 +815,12 @@ class CachingAdapter(Adapter):
# Cache-Specific Methods # Cache-Specific Methods
# ================================================================================== # ==================================================================================
@abc.abstractmethod @abc.abstractmethod
def get_cached_status(self, song: Song) -> SongCacheStatus: def get_cached_statuses(self, songs: Sequence[Song]) -> Sequence[SongCacheStatus]:
""" """
Returns the cache status of a given song. See the :class:`SongCacheStatus` Returns the cache statuses for the given list of songs. See the
documentation for more details about what each status means. :class:`SongCacheStatus` documentation for more details about what each status
means.
:params song: The song to get the cache status for. :params songs: The songs to get the cache status for.
:returns: The :class:`SongCacheStatus` for the song. :returns: A list of :class:`SongCacheStatus` objects for each of the songs.
""" """

View File

@@ -29,8 +29,13 @@ class Genre(abc.ABC):
class Album(abc.ABC): class Album(abc.ABC):
id: str """
The ``id`` field is optional, because there are some situations where an adapter
(such as Subsonic) sends an album name, but not an album ID.
"""
name: str name: str
id: Optional[str]
artist: Optional["Artist"] artist: Optional["Artist"]
cover_art: Optional[str] cover_art: Optional[str]
created: Optional[datetime] created: Optional[datetime]
@@ -44,8 +49,14 @@ class Album(abc.ABC):
class Artist(abc.ABC): class Artist(abc.ABC):
id: str """
The ``id`` field is optional, because there are some situations where an adapter
(such as Subsonic) sends an artist name, but not an artist ID. This especially
happens when there are multiple artists.
"""
name: str name: str
id: Optional[str]
album_count: Optional[int] album_count: Optional[int]
artist_image_url: Optional[str] artist_image_url: Optional[str]
starred: Optional[datetime] starred: Optional[datetime]

View File

@@ -1,12 +1,13 @@
import hashlib import hashlib
import itertools
import logging import logging
import shutil import shutil
import threading import threading
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, cast, Dict, Optional, Sequence, Set, Union from typing import Any, cast, Dict, Optional, Sequence, Set, Tuple, Union
from peewee import fn from peewee import fn, prefetch
from sublime.adapters import api_objects as API from sublime.adapters import api_objects as API
@@ -137,8 +138,12 @@ class FilesystemAdapter(CachingAdapter):
model: Any, model: Any,
cache_key: CachingAdapter.CachedDataKey, cache_key: CachingAdapter.CachedDataKey,
ignore_cache_miss: bool = False, ignore_cache_miss: bool = False,
where_clauses: Tuple[Any, ...] = None,
) -> Sequence: ) -> Sequence:
result = list(model.select()) result = model.select()
if where_clauses is not None:
result = result.where(*where_clauses)
if self.is_cache and not ignore_cache_miss: if self.is_cache and not ignore_cache_miss:
# Determine if the adapter has ingested data for this key before, and if # Determine if the adapter has ingested data for this key before, and if
# not, cache miss. # not, cache miss.
@@ -192,10 +197,11 @@ class FilesystemAdapter(CachingAdapter):
# Data Retrieval Methods # Data Retrieval Methods
# ================================================================================== # ==================================================================================
def get_cached_status(self, song: API.Song) -> SongCacheStatus: def get_cached_statuses(
try: self, songs: Sequence[API.Song]
song_model = self.get_song_details(song.id) ) -> Sequence[SongCacheStatus]:
file = song_model.file def compute_song_cache_status(song: models.Song) -> SongCacheStatus:
file = song.file
if self._compute_song_filename(file).exists(): if self._compute_song_filename(file).exists():
if file.valid: if file.valid:
if file.cache_permanently: if file.cache_permanently:
@@ -204,10 +210,22 @@ class FilesystemAdapter(CachingAdapter):
# The file is on disk, but marked as stale. # The file is on disk, but marked as stale.
return SongCacheStatus.CACHED_STALE return SongCacheStatus.CACHED_STALE
return SongCacheStatus.NOT_CACHED
try:
# TODO batch getting song details for songs that aren't already a song
# model?
song_models = [
song
if isinstance(song, models.Song)
else self.get_song_details(song.id)
for song in songs
]
return [compute_song_cache_status(s) for s in song_models]
except Exception: except Exception:
pass pass
return SongCacheStatus.NOT_CACHED return list(itertools.repeat(SongCacheStatus.NOT_CACHED, len(songs)))
_playlists = None _playlists = None
@@ -270,6 +288,7 @@ class FilesystemAdapter(CachingAdapter):
models.Artist, models.Artist,
CachingAdapter.CachedDataKey.ARTISTS, CachingAdapter.CachedDataKey.ARTISTS,
ignore_cache_miss=ignore_cache_miss, ignore_cache_miss=ignore_cache_miss,
where_clauses=(~(models.Artist.id.startswith("invalid:")),),
) )
def get_artist(self, artist_id: str) -> API.Artist: def get_artist(self, artist_id: str) -> API.Artist:
@@ -300,7 +319,9 @@ class FilesystemAdapter(CachingAdapter):
# If we haven't ever cached the query result, try to construct one, and return # If we haven't ever cached the query result, try to construct one, and return
# it as a CacheMissError result. # it as a CacheMissError result.
sql_query = models.Album.select() sql_query = models.Album.select().where(
~(models.Album.id.startswith("invalid:"))
)
Type = AlbumSearchQuery.Type Type = AlbumSearchQuery.Type
if query.type == Type.GENRE: if query.type == Type.GENRE:
@@ -328,7 +349,10 @@ class FilesystemAdapter(CachingAdapter):
def get_all_albums(self) -> Sequence[API.Album]: def get_all_albums(self) -> Sequence[API.Album]:
return self._get_list( return self._get_list(
models.Album, CachingAdapter.CachedDataKey.ALBUMS, ignore_cache_miss=True models.Album,
CachingAdapter.CachedDataKey.ALBUMS,
ignore_cache_miss=True,
where_clauses=(~(models.Album.id.startswith("invalid:")),),
) )
def get_album(self, album_id: str) -> API.Album: def get_album(self, album_id: str) -> API.Album:
@@ -371,6 +395,9 @@ class FilesystemAdapter(CachingAdapter):
# Data Ingestion Methods # Data Ingestion Methods
# ================================================================================== # ==================================================================================
def _strhash(self, string: str) -> str:
return hashlib.sha1(bytes(string, "utf8")).hexdigest()
def ingest_new_data( def ingest_new_data(
self, data_key: CachingAdapter.CachedDataKey, param: Optional[str], data: Any, self, data_key: CachingAdapter.CachedDataKey, param: Optional[str], data: Any,
): ):
@@ -474,8 +501,9 @@ class FilesystemAdapter(CachingAdapter):
def ingest_album_data( def ingest_album_data(
api_album: API.Album, exclude_artist: bool = False api_album: API.Album, exclude_artist: bool = False
) -> models.Album: ) -> models.Album:
album_id = api_album.id or f"invalid:{self._strhash(api_album.name)}"
album_data = { album_data = {
"id": api_album.id, "id": album_id,
"name": api_album.name, "name": api_album.name,
"created": getattr(api_album, "created", None), "created": getattr(api_album, "created", None),
"duration": getattr(api_album, "duration", None), "duration": getattr(api_album, "duration", None),
@@ -522,8 +550,9 @@ class FilesystemAdapter(CachingAdapter):
] ]
).on_conflict_replace().execute() ).on_conflict_replace().execute()
artist_id = api_artist.id or f"invalid:{self._strhash(api_artist.name)}"
artist_data = { artist_data = {
"id": api_artist.id, "id": artist_id,
"name": api_artist.name, "name": api_artist.name,
"album_count": getattr(api_artist, "album_count", None), "album_count": getattr(api_artist, "album_count", None),
"starred": getattr(api_artist, "starred", None), "starred": getattr(api_artist, "starred", None),
@@ -542,7 +571,7 @@ class FilesystemAdapter(CachingAdapter):
} }
artist, created = models.Artist.get_or_create( artist, created = models.Artist.get_or_create(
id=api_artist.id, defaults=artist_data id=artist_id, defaults=artist_data
) )
if not created: if not created:
@@ -661,6 +690,7 @@ class FilesystemAdapter(CachingAdapter):
ingest_artist_data(a) ingest_artist_data(a)
models.Artist.delete().where( models.Artist.delete().where(
models.Artist.id.not_in([a.id for a in data]) models.Artist.id.not_in([a.id for a in data])
& ~models.Artist.id.startswith("invalid")
).execute() ).execute()
elif data_key == KEYS.COVER_ART_FILE: elif data_key == KEYS.COVER_ART_FILE:

View File

@@ -132,6 +132,10 @@ class Directory(BaseModel):
) + list(Song.select().where(Song.parent_id == self.id)) ) + list(Song.select().where(Song.parent_id == self.id))
return self._children return self._children
@children.setter
def children(self, value: List[Union["Directory", "Song"]]):
self._children = value
class Song(BaseModel): class Song(BaseModel):
id = TextField(unique=True, primary_key=True) id = TextField(unique=True, primary_key=True)

View File

@@ -1,4 +1,5 @@
import hashlib import hashlib
import itertools
import logging import logging
import os import os
import random import random
@@ -7,6 +8,7 @@ import threading
from concurrent.futures import Future, ThreadPoolExecutor from concurrent.futures import Future, ThreadPoolExecutor
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from functools import partial
from pathlib import Path from pathlib import Path
from time import sleep from time import sleep
from typing import ( from typing import (
@@ -26,6 +28,7 @@ from typing import (
) )
import requests import requests
from gi.repository import GLib
from sublime.config import AppConfiguration from sublime.config import AppConfiguration
@@ -318,7 +321,9 @@ class AdapterManager:
return Result(future_fn) return Result(future_fn)
@staticmethod @staticmethod
def _create_download_fn(uri: str, id: str) -> Callable[[], str]: def _create_download_fn(
uri: str, id: str, before_download: Callable[[], None] = None,
) -> Callable[[], str]:
""" """
Create a function to download the given URI to a temporary file, and return the Create a function to download the given URI to a temporary file, and return the
filename. The returned function will spin-loop if the resource is already being filename. The returned function will spin-loop if the resource is already being
@@ -337,6 +342,9 @@ class AdapterManager:
resource_downloading = True resource_downloading = True
AdapterManager.current_download_ids.add(id) AdapterManager.current_download_ids.add(id)
if before_download:
before_download()
# TODO (#122): figure out how to retry if the other request failed. # TODO (#122): figure out how to retry if the other request failed.
if resource_downloading: if resource_downloading:
logging.info(f"{uri} already being downloaded.") logging.info(f"{uri} already being downloaded.")
@@ -688,7 +696,7 @@ class AdapterManager:
@staticmethod @staticmethod
def get_cover_art_filename( def get_cover_art_filename(
cover_art_id: str = None, cover_art_id: str = None,
before_download: Callable[[], None] = lambda: None, before_download: Callable[[], None] = None,
force: bool = False, # TODO: rename to use_ground_truth_adapter? force: bool = False, # TODO: rename to use_ground_truth_adapter?
allow_download: bool = True, allow_download: bool = True,
) -> Result[str]: ) -> Result[str]:
@@ -731,15 +739,13 @@ class AdapterManager:
return Result(existing_cover_art_filename) return Result(existing_cover_art_filename)
# TODO: make _get_from_cache_or_ground_truth work with downloading # TODO: make _get_from_cache_or_ground_truth work with downloading
if before_download:
before_download()
future: Result[str] = Result( future: Result[str] = Result(
AdapterManager._create_download_fn( AdapterManager._create_download_fn(
AdapterManager._instance.ground_truth_adapter.get_cover_art_uri( AdapterManager._instance.ground_truth_adapter.get_cover_art_uri(
cover_art_id, AdapterManager._get_scheme(), size=300 cover_art_id, AdapterManager._get_scheme(), size=300
), ),
cover_art_id, cover_art_id,
before_download,
), ),
is_download=True, is_download=True,
default_value=existing_cover_art_filename, default_value=existing_cover_art_filename,
@@ -835,6 +841,7 @@ class AdapterManager:
song_id, AdapterManager._get_scheme() song_id, AdapterManager._get_scheme()
), ),
song_id, song_id,
lambda: before_download(song_id),
)() )()
AdapterManager._instance.caching_adapter.ingest_new_data( AdapterManager._instance.caching_adapter.ingest_new_data(
CachingAdapter.CachedDataKey.SONG_FILE, CachingAdapter.CachedDataKey.SONG_FILE,
@@ -963,20 +970,23 @@ class AdapterManager:
if not AdapterManager._any_adapter_can_do("get_ignored_articles"): if not AdapterManager._any_adapter_can_do("get_ignored_articles"):
return set() return set()
try: try:
return AdapterManager._get_from_cache_or_ground_truth( ignored_articles: Set[str] = AdapterManager._get_from_cache_or_ground_truth(
"get_ignored_articles", "get_ignored_articles",
None, None,
use_ground_truth_adapter=use_ground_truth_adapter, use_ground_truth_adapter=use_ground_truth_adapter,
cache_key=CachingAdapter.CachedDataKey.IGNORED_ARTICLES, cache_key=CachingAdapter.CachedDataKey.IGNORED_ARTICLES,
).result() ).result()
return set(map(str.lower, ignored_articles))
except Exception: except Exception:
logging.exception("Failed to retrieve ignored_articles") logging.exception("Failed to retrieve ignored_articles")
return set() return set()
@staticmethod @staticmethod
def _strip_ignored_articles(use_ground_truth_adapter: bool, string: str) -> str: def _strip_ignored_articles(
use_ground_truth_adapter: bool, ignored_articles: Set[str], string: str
) -> str:
first_word, *rest = string.split(maxsplit=1) first_word, *rest = string.split(maxsplit=1)
if first_word in AdapterManager._get_ignored_articles(use_ground_truth_adapter): if first_word in ignored_articles:
return rest[0] return rest[0]
return string return string
@@ -988,12 +998,15 @@ class AdapterManager:
key: Callable[[_S], str], key: Callable[[_S], str],
use_ground_truth_adapter: bool = False, use_ground_truth_adapter: bool = False,
) -> List[_S]: ) -> List[_S]:
return sorted( ignored_articles = AdapterManager._get_ignored_articles(
it, use_ground_truth_adapter
key=lambda x: AdapterManager._strip_ignored_articles(
use_ground_truth_adapter, key(x).lower()
),
) )
strip_fn = partial(
AdapterManager._strip_ignored_articles,
use_ground_truth_adapter,
ignored_articles,
)
return sorted(it, key=lambda x: strip_fn(key(x).lower()))
@staticmethod @staticmethod
def get_artist( def get_artist(
@@ -1060,36 +1073,30 @@ class AdapterManager:
before_download: Callable[[], None] = lambda: None, before_download: Callable[[], None] = lambda: None,
force: bool = False, force: bool = False,
) -> Result[Directory]: ) -> Result[Directory]:
return AdapterManager._get_from_cache_or_ground_truth( def do_get_directory() -> Directory:
directory: Directory = AdapterManager._get_from_cache_or_ground_truth(
"get_directory", "get_directory",
directory_id, directory_id,
before_download=before_download, before_download=before_download,
use_ground_truth_adapter=force, use_ground_truth_adapter=force,
cache_key=CachingAdapter.CachedDataKey.DIRECTORY, cache_key=CachingAdapter.CachedDataKey.DIRECTORY,
).result()
directory.children = AdapterManager.sort_by_ignored_articles(
directory.children,
key=lambda c: cast(Directory, c).name or ""
if hasattr(c, "name")
else cast(Song, c).title,
use_ground_truth_adapter=force,
) )
return directory
return Result(do_get_directory)
# Play Queue # Play Queue
@staticmethod @staticmethod
def get_play_queue() -> Result[Optional[PlayQueue]]: def get_play_queue() -> Result[Optional[PlayQueue]]:
assert AdapterManager._instance assert AdapterManager._instance
future: Result[ return AdapterManager._create_ground_truth_result("get_play_queue")
Optional[PlayQueue]
] = AdapterManager._create_ground_truth_result("get_play_queue")
if AdapterManager._instance.caching_adapter:
def future_finished(f: Result):
assert AdapterManager._instance
assert AdapterManager._instance.caching_adapter
if play_queue := f.result():
for song in play_queue.songs:
AdapterManager._instance.caching_adapter.ingest_new_data(
CachingAdapter.CachedDataKey.SONG, song.id, song
)
future.add_done_callback(future_finished)
return future
@staticmethod @staticmethod
def save_play_queue( def save_play_queue(
@@ -1192,12 +1199,18 @@ class AdapterManager:
# Cache Status Methods # Cache Status Methods
# ================================================================================== # ==================================================================================
@staticmethod @staticmethod
def get_cached_status(song: Song) -> SongCacheStatus: def get_cached_statuses(songs: Sequence[Song]) -> Sequence[SongCacheStatus]:
assert AdapterManager._instance assert AdapterManager._instance
if not AdapterManager._instance.caching_adapter: if not AdapterManager._instance.caching_adapter:
return SongCacheStatus.NOT_CACHED return list(itertools.repeat(SongCacheStatus.NOT_CACHED, len(songs)))
cache_statuses = []
for song, cache_status in zip(
songs, AdapterManager._instance.caching_adapter.get_cached_statuses(songs)
):
if song.id in AdapterManager.current_download_ids: if song.id in AdapterManager.current_download_ids:
return SongCacheStatus.DOWNLOADING cache_statuses.append(SongCacheStatus.DOWNLOADING)
else:
cache_statuses.append(cache_status)
return AdapterManager._instance.caching_adapter.get_cached_status(song) return cache_statuses

View File

@@ -47,8 +47,8 @@ class Genre(SublimeAPI.Genre):
@dataclass_json(letter_case=LetterCase.CAMEL) @dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass @dataclass
class Album(SublimeAPI.Album): class Album(SublimeAPI.Album):
id: str
name: str name: str
id: Optional[str]
cover_art: Optional[str] = None cover_art: Optional[str] = None
song_count: Optional[int] = None song_count: Optional[int] = None
year: Optional[int] = None year: Optional[int] = None
@@ -73,8 +73,8 @@ class Album(SublimeAPI.Album):
# Initialize the cross-references # Initialize the cross-references
self.artist = ( self.artist = (
None None
if not self.artist_id if not self.artist_id and not self._artist
else ArtistAndArtistInfo(self.artist_id, self._artist) else ArtistAndArtistInfo(id=self.artist_id, name=self._artist)
) )
self.genre = None if not self._genre else Genre(self._genre) self.genre = None if not self._genre else Genre(self._genre)
@@ -82,9 +82,8 @@ class Album(SublimeAPI.Album):
@dataclass_json(letter_case=LetterCase.CAMEL) @dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass @dataclass
class ArtistAndArtistInfo(SublimeAPI.Artist): class ArtistAndArtistInfo(SublimeAPI.Artist):
id: str = field(init=False)
_id: Optional[str] = field(metadata=config(field_name="id"))
name: str name: str
id: Optional[str]
albums: List[Album] = field( albums: List[Album] = field(
default_factory=list, metadata=config(field_name="album") default_factory=list, metadata=config(field_name="album")
) )
@@ -106,11 +105,6 @@ class ArtistAndArtistInfo(SublimeAPI.Artist):
return hashlib.sha1(bytes(string, "utf8")).hexdigest() return hashlib.sha1(bytes(string, "utf8")).hexdigest()
def __post_init__(self): def __post_init__(self):
self.id = (
self._id
if self._id is not None
else ArtistAndArtistInfo._strhash(self.name)
)
self.album_count = self.album_count or len(self.albums) self.album_count = self.album_count or len(self.albums)
if not self.artist_image_url: if not self.artist_image_url:
self.artist_image_url = self.cover_art self.artist_image_url = self.cover_art
@@ -198,10 +192,12 @@ class Song(SublimeAPI.Song, DataClassJsonMixin):
self.parent_id = (self.parent_id or "root") if self.id != "root" else None self.parent_id = (self.parent_id or "root") if self.id != "root" else None
self.artist = ( self.artist = (
None None
if not self.artist_id if not self._artist
else ArtistAndArtistInfo(self.artist_id, self._artist) else ArtistAndArtistInfo(id=self.artist_id, name=self._artist)
)
self.album = (
None if not self._album else Album(id=self.album_id, name=self._album)
) )
self.album = None if not self.album_id else Album(self.album_id, self._album)
self.genre = None if not self._genre else Genre(self._genre) self.genre = None if not self._genre else Genre(self._genre)

View File

@@ -158,6 +158,7 @@ class AppConfiguration:
# Do the import in the function to avoid circular imports. # Do the import in the function to avoid circular imports.
from sublime.adapters import AdapterManager from sublime.adapters import AdapterManager
AdapterManager.reset(self) AdapterManager.reset(self)
@property @property

View File

@@ -95,7 +95,6 @@ class AlbumsPanel(Gtk.Box):
) )
actionbar.pack_start(self.alphabetical_type_combo) actionbar.pack_start(self.alphabetical_type_combo)
# TODO: Sort genre combo box alphabetically?
self.genre_combo, self.genre_combo_store = self.make_combobox( self.genre_combo, self.genre_combo_store = self.make_combobox(
(), self.on_genre_change (), self.on_genre_change
) )
@@ -203,9 +202,8 @@ class AlbumsPanel(Gtk.Box):
def get_genres_done(f: Result): def get_genres_done(f: Result):
try: try:
new_store = [ genre_names = map(lambda g: g.name, f.result() or [])
(genre.name, genre.name, True) for genre in (f.result() or []) new_store = [(name, name, True) for name in sorted(genre_names)]
]
util.diff_song_store(self.genre_combo_store, new_store) util.diff_song_store(self.genre_combo_store, new_store)
@@ -497,10 +495,11 @@ class AlbumsGrid(Gtk.Overlay):
@property @property
def id(self) -> str: def id(self) -> str:
assert self.album.id
return self.album.id return self.album.id
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<AlbumsGrid.AlbumModel {self.album}>" return f"<AlbumsGrid._AlbumModel {self.album}>"
current_query: AlbumSearchQuery = AlbumSearchQuery(AlbumSearchQuery.Type.RANDOM) current_query: AlbumSearchQuery = AlbumSearchQuery(AlbumSearchQuery.Type.RANDOM)
current_models: List[_AlbumModel] = [] current_models: List[_AlbumModel] = []
@@ -538,7 +537,6 @@ class AlbumsGrid(Gtk.Overlay):
row_spacing=5, row_spacing=5,
column_spacing=5, column_spacing=5,
margin_top=5, margin_top=5,
# margin_bottom=5,
homogeneous=True, homogeneous=True,
valign=Gtk.Align.START, valign=Gtk.Align.START,
halign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER,
@@ -560,7 +558,6 @@ class AlbumsGrid(Gtk.Overlay):
self.detail_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) self.detail_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.detail_box.pack_start(Gtk.Box(), True, True, 0) self.detail_box.pack_start(Gtk.Box(), True, True, 0)
# TODO wrap in revealer?
self.detail_box_inner = Gtk.Box() self.detail_box_inner = Gtk.Box()
self.detail_box.pack_start(self.detail_box_inner, False, False, 0) self.detail_box.pack_start(self.detail_box_inner, False, False, 0)
@@ -745,7 +742,8 @@ class AlbumsGrid(Gtk.Overlay):
self.emit("cover-clicked", self.list_store_bottom[selected_index].id) self.emit("cover-clicked", self.list_store_bottom[selected_index].id)
def on_grid_resize(self, flowbox: Gtk.FlowBox, rect: Gdk.Rectangle): def on_grid_resize(self, flowbox: Gtk.FlowBox, rect: Gdk.Rectangle):
# TODO (#124): this doesn't work with themes that add extra padding. # TODO (#124): this doesn't work at all consistency, especially with themes that
# add extra padding.
# 200 + (10 * 2) + (5 * 2) = 230 # 200 + (10 * 2) + (5 * 2) = 230
# picture + (padding * 2) + (margin * 2) # picture + (padding * 2) + (margin * 2)
new_items_per_row = min((rect.width // 230), 10) new_items_per_row = min((rect.width // 230), 10)
@@ -755,7 +753,7 @@ class AlbumsGrid(Gtk.Overlay):
self.items_per_row * 230 - 10, -1, self.items_per_row * 230 - 10, -1,
) )
self.reflow_grids() self.reflow_grids(force_reload_from_master=True)
# Helper Methods # Helper Methods
# ========================================================================= # =========================================================================
@@ -829,7 +827,11 @@ class AlbumsGrid(Gtk.Overlay):
page_offset = self.page_size * self.page page_offset = self.page_size * self.page
# Calculate the look-at window. # Calculate the look-at window.
models = models if models is not None else self.list_store_top models = (
models
if models is not None
else list(self.list_store_top) + list(self.list_store_bottom)
)
if self.sort_dir == "ascending": if self.sort_dir == "ascending":
window = models[page_offset : (page_offset + self.page_size)] window = models[page_offset : (page_offset + self.page_size)]
else: else:
@@ -852,10 +854,8 @@ class AlbumsGrid(Gtk.Overlay):
self.detail_box_revealer.set_reveal_child(False) self.detail_box_revealer.set_reveal_child(False)
if force_reload_from_master: if force_reload_from_master:
# Just remove everything and re-add all of the items. # Just remove everything and re-add all of the items. It's not worth trying
# TODO (#114): make this smarter somehow to avoid flicker. Maybe # to diff in this case.
# change this so that it removes one by one and adds back one by
# one.
self.list_store_top.splice( self.list_store_top.splice(
0, len(self.list_store_top), window[:entries_before_fold], 0, len(self.list_store_top), window[:entries_before_fold],
) )
@@ -863,6 +863,8 @@ class AlbumsGrid(Gtk.Overlay):
0, len(self.list_store_bottom), window[entries_before_fold:], 0, len(self.list_store_bottom), window[entries_before_fold:],
) )
elif self.currently_selected_index or entries_before_fold != self.page_size: elif self.currently_selected_index or entries_before_fold != self.page_size:
# This case handles when the selection changes and the entries need to be
# re-allocated to the top and bottom grids
# Move entries between the two stores. # Move entries between the two stores.
top_store_len = len(self.list_store_top) top_store_len = len(self.list_store_top)
bottom_store_len = len(self.list_store_bottom) bottom_store_len = len(self.list_store_bottom)
@@ -909,6 +911,7 @@ class AlbumsGrid(Gtk.Overlay):
# to add another flag for this function. # to add another flag for this function.
else: else:
self.grid_top.unselect_all() self.grid_top.unselect_all()
self.grid_bottom.unselect_all()
# If we had to change the page to select the index, then update the window. It # If we had to change the page to select the index, then update the window. It
# should basically be a no-op. # should basically be a no-op.

View File

@@ -96,7 +96,6 @@ class ArtistList(Gtk.Box):
margin=12, margin=12,
halign=Gtk.Align.START, halign=Gtk.Align.START,
ellipsize=Pango.EllipsizeMode.END, ellipsize=Pango.EllipsizeMode.END,
max_width_chars=30,
) )
) )
row.show_all() row.show_all()
@@ -414,6 +413,7 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
def get_artist_song_ids(self) -> List[str]: def get_artist_song_ids(self) -> List[str]:
songs = [] songs = []
for album in AdapterManager.get_artist(self.artist_id).result().albums or []: for album in AdapterManager.get_artist(self.artist_id).result().albums or []:
assert album.id
album_songs = AdapterManager.get_album(album.id).result() album_songs = AdapterManager.get_album(album.id).result()
for song in album_songs.songs or []: for song in album_songs.songs or []:
songs.append(song.id) songs.append(song.id)
@@ -455,7 +455,7 @@ class AlbumsListWithSongs(Gtk.Overlay):
self.spinner.hide() self.spinner.hide()
return return
new_albums = list(artist.albums or []) new_albums = sorted(artist.albums or [], key=lambda a: a.name)
if self.albums == new_albums: if self.albums == new_albums:
# Just go through all of the colidren and update them. # Just go through all of the colidren and update them.

View File

@@ -1,5 +1,5 @@
from functools import partial from functools import partial
from typing import Any, cast, Optional, Tuple from typing import Any, cast, List, Optional, Tuple
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
@@ -61,9 +61,7 @@ class BrowsePanel(Gtk.Overlay):
return return
# TODO pass order token here? # TODO pass order token here?
self.root_directory_listing.update( self.root_directory_listing.update(id_stack.result(), app_config, force)
id_stack.result(), app_config, force=force,
)
self.spinner.hide() self.spinner.hide()
def calculate_path() -> Tuple[str, ...]: def calculate_path() -> Tuple[str, ...]:
@@ -253,8 +251,9 @@ class MusicDirectoryList(Gtk.Box):
self.directory_id, force=force, order_token=self.update_order_token, self.directory_id, force=force, order_token=self.update_order_token,
) )
# TODO this causes probalems because the callback may try and call an object that _current_child_ids: List[str] = []
# doesn't exist anymore since we delete these panels a lot. songs: List[API.Song] = []
@util.async_callback( @util.async_callback(
AdapterManager.get_directory, AdapterManager.get_directory,
before_download=lambda self: self.loading_indicator.show(), before_download=lambda self: self.loading_indicator.show(),
@@ -270,49 +269,70 @@ class MusicDirectoryList(Gtk.Box):
if order_token != self.update_order_token: if order_token != self.update_order_token:
return return
new_directories_store = [] # This doesn't look efficient, since it's doing a ton of passses over the data,
new_songs_store = [] # but there is some annoying memory overhead for generating the stores to diff,
# so we are short-circuiting by checking to see if any of the the IDs have
# changed.
#
# The entire algorithm ends up being O(2n), but the first loop is very tight,
# and the expensive parts of the second loop are avoided if the IDs haven't
# changed.
children_ids, children = [], []
selected_dir_idx = None selected_dir_idx = None
for i, c in enumerate(directory.children):
if i >= len(self._current_child_ids) or c.id != self._current_child_ids[i]:
force = True
for el in directory.children: if c.id == self.selected_id:
selected_dir_idx = i
children_ids.append(c.id)
children.append(c)
if force:
new_directories_store = []
self._current_child_ids = children_ids
self.songs = []
for el in children:
if hasattr(el, "children"): if hasattr(el, "children"):
new_directories_store.append( new_directories_store.append(
MusicDirectoryList.DrilldownElement(cast(API.Directory, el)) MusicDirectoryList.DrilldownElement(cast(API.Directory, el))
) )
else: else:
song = cast(API.Song, el) self.songs.append(cast(API.Song, el))
new_songs_store.append(
util.diff_model_store(
self.drilldown_directories_store, new_directories_store
)
new_songs_store = [
[ [
util.get_cached_status_icon(song), status_icon,
util.esc(song.title), util.esc(song.title),
util.format_song_duration(song.duration), util.format_song_duration(song.duration),
song.id, song.id,
] ]
for status_icon, song in zip(
util.get_cached_status_icons(self.songs), self.songs
) )
]
# TODO figure out a way to push the sorting into the AdapterManager. else:
# start = time() new_songs_store = [
new_directories_store = AdapterManager.sort_by_ignored_articles( [status_icon] + song_model[1:]
new_directories_store, key=lambda d: d.name, use_ground_truth_adapter=force for status_icon, song_model in zip(
util.get_cached_status_icons(self.songs), self.directory_song_store
) )
new_songs_store = AdapterManager.sort_by_ignored_articles( ]
new_songs_store, key=lambda s: s[1], use_ground_truth_adapter=force
)
# print("CONSTRUCTING STORE TOOK", time() - start, force)
for idx, el in enumerate(new_directories_store):
if el.id == self.selected_id:
selected_dir_idx = idx
util.diff_model_store(self.drilldown_directories_store, new_directories_store)
util.diff_song_store(self.directory_song_store, new_songs_store) util.diff_song_store(self.directory_song_store, new_songs_store)
if len(new_directories_store) == 0: if len(self.drilldown_directories_store) == 0:
self.list.hide() self.list.hide()
else: else:
self.list.show() self.list.show()
if len(new_songs_store) == 0: if len(self.directory_song_store) == 0:
self.directory_song_list.hide() self.directory_song_list.hide()
self.scroll_window.set_min_content_width(275) self.scroll_window.set_min_content_width(275)
else: else:

View File

@@ -120,12 +120,11 @@ class AlbumWithSongs(Gtk.Box):
) )
) )
self.album_song_store = Gtk.ListStore( self.loading_indicator_container = Gtk.Box()
str, str, str, str, # cache status, title, duration, song ID album_details.add(self.loading_indicator_container)
)
self.loading_indicator = Gtk.Spinner(name="album-list-song-list-spinner") # cache status, title, duration, song ID
album_details.add(self.loading_indicator) self.album_song_store = Gtk.ListStore(str, str, str, str)
self.album_songs = Gtk.TreeView( self.album_songs = Gtk.TreeView(
model=self.album_song_store, model=self.album_song_store,
@@ -244,11 +243,16 @@ class AlbumWithSongs(Gtk.Box):
def set_loading(self, loading: bool): def set_loading(self, loading: bool):
if loading: if loading:
self.loading_indicator.start() if len(self.loading_indicator_container.get_children()) == 0:
self.loading_indicator.show() self.loading_indicator_container.pack_start(Gtk.Box(), True, True, 0)
spinner = Gtk.Spinner(name="album-list-song-list-spinner")
spinner.start()
self.loading_indicator_container.add(spinner)
self.loading_indicator_container.pack_start(Gtk.Box(), True, True, 0)
self.loading_indicator_container.show_all()
else: else:
self.loading_indicator.stop() self.loading_indicator_container.hide()
self.loading_indicator.hide()
@util.async_callback( @util.async_callback(
AdapterManager.get_album, AdapterManager.get_album,
@@ -264,12 +268,14 @@ class AlbumWithSongs(Gtk.Box):
): ):
new_store = [ new_store = [
[ [
util.get_cached_status_icon(song), cached_status,
util.esc(song.title), util.esc(song.title),
util.format_song_duration(song.duration), util.format_song_duration(song.duration),
song.id, song.id,
] ]
for song in list(album.songs or []) for cached_status, song in zip(
util.get_cached_status_icons(list(album.songs or [])), album.songs or []
)
] ]
song_ids = [song[-1] for song in new_store] song_ids = [song[-1] for song in new_store]
@@ -284,4 +290,6 @@ class AlbumWithSongs(Gtk.Box):
self.add_to_queue_btn.set_action_name("app.play-next") self.add_to_queue_btn.set_action_name("app.play-next")
util.diff_song_store(self.album_song_store, new_store) util.diff_song_store(self.album_song_store, new_store)
self.loading_indicator.hide()
# Have to idle_add here so that his happens after the component is rendered.
self.set_loading(False)

View File

@@ -395,7 +395,7 @@ class MainWindow(Gtk.ApplicationWindow):
f"<b>{util.esc(song.title)}</b>", f"<b>{util.esc(song.title)}</b>",
util.esc(song.artist.name if song.artist else None), util.esc(song.artist.name if song.artist else None),
) )
assert song.album assert song.album and song.album.id
self.song_results.add( self.song_results.add(
self._create_search_result_row( self._create_search_result_row(
label_text, "album", song.album.id, song.cover_art label_text, "album", song.album.id, song.cover_art
@@ -412,6 +412,7 @@ class MainWindow(Gtk.ApplicationWindow):
f"<b>{util.esc(album.name)}</b>", f"<b>{util.esc(album.name)}</b>",
util.esc(album.artist.name if album.artist else None), util.esc(album.artist.name if album.artist else None),
) )
assert album.id
self.album_results.add( self.album_results.add(
self._create_search_result_row( self._create_search_result_row(
label_text, "album", album.id, album.cover_art label_text, "album", album.id, album.cover_art
@@ -425,6 +426,7 @@ class MainWindow(Gtk.ApplicationWindow):
self._remove_all_from_widget(self.artist_results) self._remove_all_from_widget(self.artist_results)
for artist in search_results.artists: for artist in search_results.artists:
label_text = util.esc(artist.name) label_text = util.esc(artist.name)
assert artist.id
self.artist_results.add( self.artist_results.add(
self._create_search_result_row( self._create_search_result_row(
label_text, "artist", artist.id, artist.artist_image_url label_text, "artist", artist.id, artist.artist_image_url

View File

@@ -477,7 +477,7 @@ class PlayerControls(Gtk.ActionBar):
"refresh-window", "refresh-window",
{ {
"current_song_index": currently_playing_index, "current_song_index": currently_playing_index,
"play_queue": [s[-1] for s in self.play_queue_store], "play_queue": tuple(s[-1] for s in self.play_queue_store),
}, },
False, False,
) )

View File

@@ -1,12 +1,11 @@
from functools import lru_cache from functools import lru_cache
from random import randint from random import randint
from typing import Any, Iterable, List, Tuple from typing import Any, cast, Iterable, List, Tuple
from fuzzywuzzy import process from fuzzywuzzy import process
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
from sublime.adapters import AdapterManager from sublime.adapters import AdapterManager, api_objects as API
from sublime.adapters.api_objects import Playlist, PlaylistDetails
from sublime.config import AppConfiguration from sublime.config import AppConfiguration
from sublime.ui import util from sublime.ui import util
from sublime.ui.common import ( from sublime.ui.common import (
@@ -177,7 +176,7 @@ class PlaylistList(Gtk.Box):
) )
def update_list( def update_list(
self, self,
playlists: List[Playlist], playlists: List[API.Playlist],
app_config: AppConfiguration, app_config: AppConfiguration,
force: bool = False, force: bool = False,
order_token: int = None, order_token: int = None,
@@ -436,6 +435,9 @@ class PlaylistDetailPanel(Gtk.Overlay):
order_token=self.update_playlist_view_order_token, order_token=self.update_playlist_view_order_token,
) )
_current_song_ids: List[str] = []
songs: List[API.Song] = []
@util.async_callback( @util.async_callback(
AdapterManager.get_playlist_details, AdapterManager.get_playlist_details,
before_download=lambda self: self.show_loading_all(), before_download=lambda self: self.show_loading_all(),
@@ -443,7 +445,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
) )
def update_playlist_view( def update_playlist_view(
self, self,
playlist: PlaylistDetails, playlist: API.PlaylistDetails,
app_config: AppConfiguration = None, app_config: AppConfiguration = None,
force: bool = False, force: bool = False,
order_token: int = None, order_token: int = None,
@@ -469,27 +471,53 @@ class PlaylistDetailPanel(Gtk.Overlay):
self.playlist_stats.set_markup(self._format_stats(playlist)) self.playlist_stats.set_markup(self._format_stats(playlist))
# Update the artwork. # Update the artwork.
self.update_playlist_artwork( self.update_playlist_artwork(playlist.cover_art, order_token=order_token)
playlist.cover_art, order_token=order_token,
)
# Update the song list model. This requires some fancy diffing to # Update the song list model. This requires some fancy diffing to
# update the list. # update the list.
self.editing_playlist_song_list = True self.editing_playlist_song_list = True
new_store = [ # This doesn't look efficient, since it's doing a ton of passses over the data,
# but there is some annoying memory overhead for generating the stores to diff,
# so we are short-circuiting by checking to see if any of the the IDs have
# changed.
#
# The entire algorithm ends up being O(2n), but the first loop is very tight,
# and the expensive parts of the second loop are avoided if the IDs haven't
# changed.
song_ids, songs = [], []
for i, c in enumerate(playlist.songs):
if i >= len(self._current_song_ids) or c.id != self._current_song_ids[i]:
force = True
song_ids.append(c.id)
songs.append(c)
if force:
self._current_song_ids = song_ids
self.songs = [cast(API.Song, s) for s in songs]
new_songs_store = [
[ [
util.get_cached_status_icon(song), status_icon,
song.title, song.title,
album.name if (album := song.album) else None, album.name if (album := song.album) else None,
artist.name if (artist := song.artist) else None, artist.name if (artist := song.artist) else None,
util.format_song_duration(song.duration), util.format_song_duration(song.duration),
song.id, song.id,
] ]
for song in playlist.songs for status_icon, song in zip(
util.get_cached_status_icons(self.songs), self.songs
)
]
else:
new_songs_store = [
[status_icon] + song_model[1:]
for status_icon, song_model in zip(
util.get_cached_status_icons(self.songs), self.playlist_song_store
)
] ]
util.diff_song_store(self.playlist_song_store, new_store) util.diff_song_store(self.playlist_song_store, new_songs_store)
self.editing_playlist_song_list = False self.editing_playlist_song_list = False
@@ -713,7 +741,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
@util.async_callback(AdapterManager.get_playlist_details) @util.async_callback(AdapterManager.get_playlist_details)
def _update_playlist_order( def _update_playlist_order(
self, playlist: PlaylistDetails, app_config: AppConfiguration, **kwargs, self, playlist: API.PlaylistDetails, app_config: AppConfiguration, **kwargs,
): ):
self.playlist_view_loading_box.show_all() self.playlist_view_loading_box.show_all()
update_playlist_future = AdapterManager.update_playlist( update_playlist_future = AdapterManager.update_playlist(
@@ -730,7 +758,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
) )
) )
def _format_stats(self, playlist: PlaylistDetails) -> str: def _format_stats(self, playlist: API.PlaylistDetails) -> str:
created_date_text = "" created_date_text = ""
if playlist.created: if playlist.created:
created_date_text = f" on {playlist.created.strftime('%B %d, %Y')}" created_date_text = f" on {playlist.created.strftime('%B %d, %Y')}"

View File

@@ -68,12 +68,14 @@ class UIState:
state = self.__dict__.copy() state = self.__dict__.copy()
del state["song_stream_cache_progress"] del state["song_stream_cache_progress"]
del state["current_notification"] del state["current_notification"]
del state["playing"]
return state return state
def __setstate__(self, state: Dict[str, Any]): def __setstate__(self, state: Dict[str, Any]):
self.__dict__.update(state) self.__dict__.update(state)
self.song_stream_cache_progress = None self.song_stream_cache_progress = None
self.current_notification = None self.current_notification = None
self.playing = False
class _DefaultGenre(Genre): class _DefaultGenre(Genre):
def __init__(self): def __init__(self):

View File

@@ -35,12 +35,13 @@ def format_song_duration(duration_secs: Union[int, timedelta, None]) -> str:
>>> format_song_duration(None) >>> format_song_duration(None)
'-:--' '-:--'
""" """
# TODO remove int compatibility eventually?
if isinstance(duration_secs, timedelta): if isinstance(duration_secs, timedelta):
duration_secs = round(duration_secs.total_seconds()) duration_secs = round(duration_secs.total_seconds())
if duration_secs is None: if duration_secs is None:
return "-:--" return "-:--"
duration_secs = max(duration_secs, 0)
return f"{duration_secs // 60}:{duration_secs % 60:02}" return f"{duration_secs // 60}:{duration_secs % 60:02}"
@@ -121,14 +122,16 @@ def dot_join(*items: Any) -> str:
return "".join(map(str, filter(lambda x: x is not None, items))) return "".join(map(str, filter(lambda x: x is not None, items)))
def get_cached_status_icon(song: Song) -> str: def get_cached_status_icons(songs: List[Song]) -> List[str]:
cache_icon = { cache_icon = {
SongCacheStatus.NOT_CACHED: "",
SongCacheStatus.CACHED: "folder-download-symbolic", SongCacheStatus.CACHED: "folder-download-symbolic",
SongCacheStatus.PERMANENTLY_CACHED: "view-pin-symbolic", SongCacheStatus.PERMANENTLY_CACHED: "view-pin-symbolic",
SongCacheStatus.DOWNLOADING: "emblem-synchronizing-symbolic", SongCacheStatus.DOWNLOADING: "emblem-synchronizing-symbolic",
} }
return cache_icon[AdapterManager.get_cached_status(song)] return [
cache_icon.get(cache_status, "")
for cache_status in AdapterManager.get_cached_statuses(songs)
]
def _parse_diff_location(location: str) -> Tuple: def _parse_diff_location(location: str) -> Tuple:
@@ -223,22 +226,22 @@ def show_song_popover(
# Determine if we should enable the download button. # Determine if we should enable the download button.
download_sensitive, remove_download_sensitive = False, False download_sensitive, remove_download_sensitive = False, False
albums, artists, parents = set(), set(), set() albums, artists, parents = set(), set(), set()
for song_id in song_ids:
# TODO lazy load these # TODO lazy load these
details = AdapterManager.get_song_details(song_id).result() song_details = [
status = AdapterManager.get_cached_status(details) AdapterManager.get_song_details(song_id).result() for song_id in song_ids
albums.add(album.id if (album := details.album) else None) ]
artists.add(artist.id if (artist := details.artist) else None) song_cache_statuses = AdapterManager.get_cached_statuses(song_details)
parents.add(parent_id if (parent_id := details.parent_id) else None) for song, status in zip(song_details, song_cache_statuses):
# TODO lazy load these
albums.add(album.id if (album := song.album) else None)
artists.add(artist.id if (artist := song.artist) else None)
parents.add(parent_id if (parent_id := song.parent_id) else None)
if download_sensitive or status == SongCacheStatus.NOT_CACHED: download_sensitive |= status == SongCacheStatus.NOT_CACHED
download_sensitive = True remove_download_sensitive |= status in (
if remove_download_sensitive or status in (
SongCacheStatus.CACHED, SongCacheStatus.CACHED,
SongCacheStatus.PERMANENTLY_CACHED, SongCacheStatus.PERMANENTLY_CACHED,
): )
remove_download_sensitive = True
go_to_album_button = Gtk.ModelButton( go_to_album_button = Gtk.ModelButton(
text="Go to album", action_name="app.go-to-album" text="Go to album", action_name="app.go-to-album"
@@ -386,9 +389,14 @@ def async_callback(
) )
if is_immediate: if is_immediate:
GLib.idle_add(fn) # The data is available now, no need to wait for the future to
else: # finish, and no need to incur the overhead of adding to the GLib
# event queue.
fn() fn()
else:
# We don'h have the data, and we have to idle add so that we don't
# seg fault GTK.
GLib.idle_add(fn)
result: Result = future_fn( result: Result = future_fn(
*args, before_download=on_before_download, force=force, **kwargs, *args, before_download=on_before_download, force=force, **kwargs,

View File

@@ -9,7 +9,12 @@ import pytest
from peewee import SelectQuery from peewee import SelectQuery
from sublime.adapters import api_objects as SublimeAPI, CacheMissError, SongCacheStatus from sublime.adapters import (
AlbumSearchQuery,
api_objects as SublimeAPI,
CacheMissError,
SongCacheStatus,
)
from sublime.adapters.filesystem import FilesystemAdapter from sublime.adapters.filesystem import FilesystemAdapter
from sublime.adapters.subsonic import api_objects as SubsonicAPI from sublime.adapters.subsonic import api_objects as SubsonicAPI
@@ -374,36 +379,31 @@ def test_malformed_song_path(cache_adapter: FilesystemAdapter):
assert song_uri2.endswith("fine/path/song2.mp3") assert song_uri2.endswith("fine/path/song2.mp3")
def test_get_cached_status(cache_adapter: FilesystemAdapter): def test_get_cached_statuses(cache_adapter: FilesystemAdapter):
cache_adapter.ingest_new_data(KEYS.SONG, "1", MOCK_SUBSONIC_SONGS[1]) cache_adapter.ingest_new_data(KEYS.SONG, "1", MOCK_SUBSONIC_SONGS[1])
assert ( assert cache_adapter.get_cached_statuses([cache_adapter.get_song_details("1")]) == [
cache_adapter.get_cached_status(cache_adapter.get_song_details("1")) SongCacheStatus.NOT_CACHED
== SongCacheStatus.NOT_CACHED ]
)
cache_adapter.ingest_new_data(KEYS.SONG_FILE, "1", (None, MOCK_SONG_FILE)) cache_adapter.ingest_new_data(KEYS.SONG_FILE, "1", (None, MOCK_SONG_FILE))
assert ( assert cache_adapter.get_cached_statuses([cache_adapter.get_song_details("1")]) == [
cache_adapter.get_cached_status(cache_adapter.get_song_details("1")) SongCacheStatus.CACHED
== SongCacheStatus.CACHED ]
)
cache_adapter.ingest_new_data(KEYS.SONG_FILE_PERMANENT, "1", None) cache_adapter.ingest_new_data(KEYS.SONG_FILE_PERMANENT, "1", None)
assert ( assert cache_adapter.get_cached_statuses([cache_adapter.get_song_details("1")]) == [
cache_adapter.get_cached_status(cache_adapter.get_song_details("1")) SongCacheStatus.PERMANENTLY_CACHED
== SongCacheStatus.PERMANENTLY_CACHED ]
)
cache_adapter.invalidate_data(KEYS.SONG_FILE, "1") cache_adapter.invalidate_data(KEYS.SONG_FILE, "1")
assert ( assert cache_adapter.get_cached_statuses([cache_adapter.get_song_details("1")]) == [
cache_adapter.get_cached_status(cache_adapter.get_song_details("1")) SongCacheStatus.CACHED_STALE
== SongCacheStatus.CACHED_STALE ]
)
cache_adapter.delete_data(KEYS.SONG_FILE, "1") cache_adapter.delete_data(KEYS.SONG_FILE, "1")
assert ( assert cache_adapter.get_cached_statuses([cache_adapter.get_song_details("1")]) == [
cache_adapter.get_cached_status(cache_adapter.get_song_details("1")) SongCacheStatus.NOT_CACHED
== SongCacheStatus.NOT_CACHED ]
)
def test_delete_playlists(cache_adapter: FilesystemAdapter): def test_delete_playlists(cache_adapter: FilesystemAdapter):
@@ -544,9 +544,9 @@ def test_caching_get_song_details(cache_adapter: FilesystemAdapter):
song = cache_adapter.get_song_details("1") song = cache_adapter.get_song_details("1")
assert song.id == "1" assert song.id == "1"
assert song.title == "Song 1" assert song.title == "Song 1"
assert song.album assert song.album and song.artist
assert (song.album.id, song.album.name) == ("a2", "bar") assert (song.album.id, song.album.name) == ("a2", "bar")
assert song.artist and song.artist.name == "bar" assert (song.artist.id, song.artist.name) == ("art2", "bar")
assert song.parent_id == "bar" assert song.parent_id == "bar"
assert song.duration == timedelta(seconds=10.2) assert song.duration == timedelta(seconds=10.2)
assert song.path == "bar/song1.mp3" assert song.path == "bar/song1.mp3"
@@ -556,6 +556,71 @@ def test_caching_get_song_details(cache_adapter: FilesystemAdapter):
cache_adapter.get_playlist_details("2") cache_adapter.get_playlist_details("2")
def test_caching_get_song_details_missing_data(cache_adapter: FilesystemAdapter):
with pytest.raises(CacheMissError):
cache_adapter.get_song_details("1")
# Ingest a song without an album ID and artist ID, but with album and artist name.
cache_adapter.ingest_new_data(
KEYS.SONG,
"1",
SubsonicAPI.Song(
"1",
title="Song 1",
parent_id="bar",
_album="bar",
_artist="foo",
duration=timedelta(seconds=10.2),
path="foo/bar/song1.mp3",
_genre="Bar",
),
)
song = cache_adapter.get_song_details("1")
assert song.id == "1"
assert song.title == "Song 1"
assert song.album
assert (song.album.id, song.album.name) == (
"invalid:62cdb7020ff920e5aa642c3d4066950dd1f01f4d",
"bar",
)
assert (song.artist.id, song.artist.name) == (
"invalid:0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33",
"foo",
)
assert song.parent_id == "bar"
assert song.duration == timedelta(seconds=10.2)
assert song.path == "foo/bar/song1.mp3"
assert song.genre and song.genre.name == "Bar"
# Because the album and artist are invalid (doesn't have an album/artist ID), it
# shouldn't show up in any results.
try:
list(
cache_adapter.get_albums(
AlbumSearchQuery(AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME)
)
)
except CacheMissError as e:
assert e.partial_data is not None
assert len(e.partial_data) == 0
albums = list(cache_adapter.get_all_albums())
assert len(albums) == 0
with pytest.raises(CacheMissError):
cache_adapter.get_album("invalid:62cdb7020ff920e5aa642c3d4066950dd1f01f4d")
try:
list(cache_adapter.get_artists())
except CacheMissError as e:
assert e.partial_data is not None
assert len(e.partial_data) == 0
with pytest.raises(CacheMissError):
cache_adapter.get_artist("invalid:0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33")
def test_caching_less_info(cache_adapter: FilesystemAdapter): def test_caching_less_info(cache_adapter: FilesystemAdapter):
cache_adapter.ingest_new_data( cache_adapter.ingest_new_data(
KEYS.SONG, KEYS.SONG,
@@ -600,8 +665,10 @@ def test_caching_get_artists(cache_adapter: FilesystemAdapter):
KEYS.ARTISTS, KEYS.ARTISTS,
None, None,
[ [
SubsonicAPI.ArtistAndArtistInfo("1", "test1", album_count=3, albums=[]), SubsonicAPI.ArtistAndArtistInfo(
SubsonicAPI.ArtistAndArtistInfo("2", "test2", album_count=4), id="1", name="test1", album_count=3, albums=[]
),
SubsonicAPI.ArtistAndArtistInfo(id="2", name="test2", album_count=4),
], ],
) )
@@ -615,8 +682,8 @@ def test_caching_get_artists(cache_adapter: FilesystemAdapter):
KEYS.ARTISTS, KEYS.ARTISTS,
None, None,
[ [
SubsonicAPI.ArtistAndArtistInfo("1", "test1", album_count=3), SubsonicAPI.ArtistAndArtistInfo(id="1", name="test1", album_count=3),
SubsonicAPI.ArtistAndArtistInfo("3", "test3", album_count=8), SubsonicAPI.ArtistAndArtistInfo(id="3", name="test3", album_count=8),
], ],
) )
@@ -651,17 +718,17 @@ def test_caching_get_artist(cache_adapter: FilesystemAdapter):
KEYS.ARTIST, KEYS.ARTIST,
"1", "1",
SubsonicAPI.ArtistAndArtistInfo( SubsonicAPI.ArtistAndArtistInfo(
"1", id="1",
"Bar", name="Bar",
album_count=1, album_count=1,
artist_image_url="image", artist_image_url="image",
similar_artists=[ similar_artists=[
SubsonicAPI.ArtistAndArtistInfo("A", "B"), SubsonicAPI.ArtistAndArtistInfo(id="A", name="B"),
SubsonicAPI.ArtistAndArtistInfo("C", "D"), SubsonicAPI.ArtistAndArtistInfo(id="C", name="D"),
], ],
biography="this is a bio", biography="this is a bio",
music_brainz_id="mbid", music_brainz_id="mbid",
albums=[SubsonicAPI.Album("1", "Foo", artist_id="1")], albums=[SubsonicAPI.Album(id="1", name="Foo", artist_id="1")],
), ),
) )
@@ -675,30 +742,32 @@ def test_caching_get_artist(cache_adapter: FilesystemAdapter):
artist.music_brainz_id, artist.music_brainz_id,
) == ("1", "Bar", 1, "image", "this is a bio", "mbid") ) == ("1", "Bar", 1, "image", "this is a bio", "mbid")
assert artist.similar_artists == [ assert artist.similar_artists == [
SubsonicAPI.ArtistAndArtistInfo("A", "B"), SubsonicAPI.ArtistAndArtistInfo(id="A", name="B"),
SubsonicAPI.ArtistAndArtistInfo("C", "D"), SubsonicAPI.ArtistAndArtistInfo(id="C", name="D"),
] ]
assert artist.albums and len(artist.albums) == 1 assert artist.albums and len(artist.albums) == 1
assert cast(SelectQuery, artist.albums).dicts() == [SubsonicAPI.Album("1", "Foo")] assert cast(SelectQuery, artist.albums).dicts() == [
SubsonicAPI.Album(id="1", name="Foo")
]
# Simulate "force refreshing" the artist details being retrieved from Subsonic. # Simulate "force refreshing" the artist details being retrieved from Subsonic.
cache_adapter.ingest_new_data( cache_adapter.ingest_new_data(
KEYS.ARTIST, KEYS.ARTIST,
"1", "1",
SubsonicAPI.ArtistAndArtistInfo( SubsonicAPI.ArtistAndArtistInfo(
"1", id="1",
"Foo", name="Foo",
album_count=2, album_count=2,
artist_image_url="image2", artist_image_url="image2",
similar_artists=[ similar_artists=[
SubsonicAPI.ArtistAndArtistInfo("A", "B"), SubsonicAPI.ArtistAndArtistInfo(id="A", name="B"),
SubsonicAPI.ArtistAndArtistInfo("E", "F"), SubsonicAPI.ArtistAndArtistInfo(id="E", name="F"),
], ],
biography="this is a bio2", biography="this is a bio2",
music_brainz_id="mbid2", music_brainz_id="mbid2",
albums=[ albums=[
SubsonicAPI.Album("1", "Foo", artist_id="1"), SubsonicAPI.Album(id="1", name="Foo", artist_id="1"),
SubsonicAPI.Album("2", "Bar", artist_id="1"), SubsonicAPI.Album(id="2", name="Bar", artist_id="1"),
], ],
), ),
) )
@@ -713,13 +782,13 @@ def test_caching_get_artist(cache_adapter: FilesystemAdapter):
artist.music_brainz_id, artist.music_brainz_id,
) == ("1", "Foo", 2, "image2", "this is a bio2", "mbid2") ) == ("1", "Foo", 2, "image2", "this is a bio2", "mbid2")
assert artist.similar_artists == [ assert artist.similar_artists == [
SubsonicAPI.ArtistAndArtistInfo("A", "B"), SubsonicAPI.ArtistAndArtistInfo(id="A", name="B"),
SubsonicAPI.ArtistAndArtistInfo("E", "F"), SubsonicAPI.ArtistAndArtistInfo(id="E", name="F"),
] ]
assert artist.albums and len(artist.albums) == 2 assert artist.albums and len(artist.albums) == 2
assert cast(SelectQuery, artist.albums).dicts() == [ assert cast(SelectQuery, artist.albums).dicts() == [
SubsonicAPI.Album("1", "Foo"), SubsonicAPI.Album(id="1", name="Foo"),
SubsonicAPI.Album("2", "Bar"), SubsonicAPI.Album(id="2", name="Bar"),
] ]
@@ -732,8 +801,8 @@ def test_caching_get_album(cache_adapter: FilesystemAdapter):
KEYS.ALBUM, KEYS.ALBUM,
"a1", "a1",
SubsonicAPI.Album( SubsonicAPI.Album(
"a1", id="a1",
"foo", name="foo",
cover_art="c", cover_art="c",
song_count=2, song_count=2,
year=2020, year=2020,
@@ -767,31 +836,31 @@ def test_caching_invalidate_artist(cache_adapter: FilesystemAdapter):
KEYS.ARTIST, KEYS.ARTIST,
"artist1", "artist1",
SubsonicAPI.ArtistAndArtistInfo( SubsonicAPI.ArtistAndArtistInfo(
"artist1", id="artist1",
"Bar", name="Bar",
album_count=1, album_count=1,
artist_image_url="image", artist_image_url="image",
similar_artists=[ similar_artists=[
SubsonicAPI.ArtistAndArtistInfo("A", "B"), SubsonicAPI.ArtistAndArtistInfo(id="A", name="B"),
SubsonicAPI.ArtistAndArtistInfo("C", "D"), SubsonicAPI.ArtistAndArtistInfo(id="C", name="D"),
], ],
biography="this is a bio", biography="this is a bio",
music_brainz_id="mbid", music_brainz_id="mbid",
albums=[ albums=[
SubsonicAPI.Album("1", "Foo", artist_id="1"), SubsonicAPI.Album(id="1", name="Foo", artist_id="1"),
SubsonicAPI.Album("2", "Bar", artist_id="1"), SubsonicAPI.Album(id="2", name="Bar", artist_id="1"),
], ],
), ),
) )
cache_adapter.ingest_new_data( cache_adapter.ingest_new_data(
KEYS.ALBUM, KEYS.ALBUM,
"1", "1",
SubsonicAPI.Album("1", "Foo", artist_id="artist1", cover_art="1"), SubsonicAPI.Album(id="1", name="Foo", artist_id="artist1", cover_art="1"),
) )
cache_adapter.ingest_new_data( cache_adapter.ingest_new_data(
KEYS.ALBUM, KEYS.ALBUM,
"2", "2",
SubsonicAPI.Album("2", "Bar", artist_id="artist1", cover_art="2"), SubsonicAPI.Album(id="2", name="Bar", artist_id="artist1", cover_art="2"),
) )
cache_adapter.ingest_new_data(KEYS.COVER_ART_FILE, "image", MOCK_ALBUM_ART3) cache_adapter.ingest_new_data(KEYS.COVER_ART_FILE, "image", MOCK_ALBUM_ART3)
cache_adapter.ingest_new_data(KEYS.COVER_ART_FILE, "1", MOCK_ALBUM_ART) cache_adapter.ingest_new_data(KEYS.COVER_ART_FILE, "1", MOCK_ALBUM_ART)
@@ -899,15 +968,21 @@ def test_search(cache_adapter: FilesystemAdapter):
search_result.add_results( search_result.add_results(
"albums", "albums",
[ [
SubsonicAPI.Album("album1", "Foo", artist_id="artist1", cover_art="cal1"), SubsonicAPI.Album(
SubsonicAPI.Album("album2", "Boo", artist_id="artist1", cover_art="cal2"), id="album1", name="Foo", artist_id="artist1", cover_art="cal1"
),
SubsonicAPI.Album(
id="album2", name="Boo", artist_id="artist1", cover_art="cal2"
),
], ],
) )
search_result.add_results( search_result.add_results(
"artists", "artists",
[ [
SubsonicAPI.ArtistAndArtistInfo("artist1", "foo", cover_art="car1"), SubsonicAPI.ArtistAndArtistInfo(id="artist1", name="foo", cover_art="car1"),
SubsonicAPI.ArtistAndArtistInfo("artist2", "better boo", cover_art="car2"), SubsonicAPI.ArtistAndArtistInfo(
id="artist2", name="better boo", cover_art="car2"
),
], ],
) )
search_result.add_results( search_result.add_results(

View File

@@ -0,0 +1,30 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"song": {
"id": "1",
"parent": "544",
"isDir": false,
"title": "Sweet Caroline",
"album": "50th Anniversary Collection",
"artist": "Neil Diamond",
"track": 16,
"year": 2017,
"genre": "Pop",
"coverArt": "544",
"size": 7437928,
"contentType": "audio/mpeg",
"suffix": "mp3",
"duration": 203,
"bitRate": 288,
"path": "Neil Diamond/50th Anniversary Collection/16 - Sweet Caroline.mp3",
"isVideo": false,
"playCount": 7,
"discNumber": 1,
"created": "2020-03-27T05:25:52.000Z",
"artistId": "60",
"type": "music"
}
}
}

View File

@@ -0,0 +1,30 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"song": {
"id": "1",
"parent": "544",
"isDir": false,
"title": "Sweet Caroline",
"album": "50th Anniversary Collection",
"artist": "Neil Diamond",
"track": 16,
"year": 2017,
"genre": "Pop",
"coverArt": "544",
"size": 7437928,
"contentType": "audio/mpeg",
"suffix": "mp3",
"duration": 203,
"bitRate": 288,
"path": "Neil Diamond/50th Anniversary Collection/16 - Sweet Caroline.mp3",
"isVideo": false,
"playCount": 7,
"discNumber": 1,
"created": "2020-03-27T05:25:52.000Z",
"albumId": "88",
"type": "music"
}
}
}

View File

@@ -253,6 +253,50 @@ def test_get_song_details(adapter: SubsonicAdapter):
assert song.genre and song.genre.name == "Pop" assert song.genre and song.genre.name == "Pop"
def test_get_song_details_missing_data(adapter: SubsonicAdapter):
for filename, data in mock_data_files("get_song_details_no_albumid"):
logging.info(filename)
logging.debug(data)
adapter._set_mock_data(data)
song = adapter.get_song_details("1")
assert (song.id, song.title, song.year, song.cover_art, song.duration) == (
"1",
"Sweet Caroline",
2017,
"544",
timedelta(seconds=203),
)
assert song.path and song.path.endswith("Sweet Caroline.mp3")
assert song.parent_id == "544"
assert song.artist
assert (song.artist.id, song.artist.name) == ("60", "Neil Diamond")
assert song.album
assert (song.album.id, song.album.name) == (None, "50th Anniversary Collection")
assert song.genre and song.genre.name == "Pop"
for filename, data in mock_data_files("get_song_details_no_artistid"):
logging.info(filename)
logging.debug(data)
adapter._set_mock_data(data)
song = adapter.get_song_details("1")
assert (song.id, song.title, song.year, song.cover_art, song.duration) == (
"1",
"Sweet Caroline",
2017,
"544",
timedelta(seconds=203),
)
assert song.path and song.path.endswith("Sweet Caroline.mp3")
assert song.parent_id == "544"
assert song.artist
assert (song.artist.id, song.artist.name) == (None, "Neil Diamond")
assert song.album
assert (song.album.id, song.album.name) == ("88", "50th Anniversary Collection")
assert song.genre and song.genre.name == "Pop"
def test_get_genres(adapter: SubsonicAdapter): def test_get_genres(adapter: SubsonicAdapter):
for filename, data in mock_data_files("get_genres"): for filename, data in mock_data_files("get_genres"):
logging.info(filename) logging.info(filename)