Artist view with album and songs
This commit is contained in:
@@ -139,7 +139,7 @@ class CacheManager(metaclass=Singleton):
|
|||||||
|
|
||||||
# Non-ID3 caches
|
# Non-ID3 caches
|
||||||
('albums', Child, list),
|
('albums', Child, list),
|
||||||
('album_details', Child, dict),
|
('album_details', Directory, dict),
|
||||||
('artists', Artist, list),
|
('artists', Artist, list),
|
||||||
('artist_details', Directory, dict),
|
('artist_details', Directory, dict),
|
||||||
('artist_infos', ArtistInfo, dict),
|
('artist_infos', ArtistInfo, dict),
|
||||||
@@ -432,6 +432,31 @@ class CacheManager(metaclass=Singleton):
|
|||||||
|
|
||||||
return CacheManager.executor.submit(do_get_albums)
|
return CacheManager.executor.submit(do_get_albums)
|
||||||
|
|
||||||
|
def get_album(
|
||||||
|
self,
|
||||||
|
album_id,
|
||||||
|
before_download: Callable[[], None] = lambda: None,
|
||||||
|
force: bool = False,
|
||||||
|
) -> Future:
|
||||||
|
def do_get_album() -> Union[AlbumWithSongsID3, Child]:
|
||||||
|
# TODO: implement the non-ID3 version
|
||||||
|
cache_name = self.id3ify('album_details')
|
||||||
|
server_fn = (self.server.get_album if self.browse_by_tags else
|
||||||
|
self.server.get_music_directory)
|
||||||
|
|
||||||
|
if album_id not in self.cache.get(cache_name, {}) or force:
|
||||||
|
before_download()
|
||||||
|
album = server_fn(album_id)
|
||||||
|
|
||||||
|
with self.cache_lock:
|
||||||
|
self.cache[cache_name][album_id] = album
|
||||||
|
|
||||||
|
self.save_cache_info()
|
||||||
|
|
||||||
|
return self.cache[cache_name][album_id]
|
||||||
|
|
||||||
|
return CacheManager.executor.submit(do_get_album)
|
||||||
|
|
||||||
def batch_delete_cached_songs(
|
def batch_delete_cached_songs(
|
||||||
self,
|
self,
|
||||||
song_ids: List[int],
|
song_ids: List[int],
|
||||||
|
@@ -143,3 +143,14 @@
|
|||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#artist-album-list-artwork {
|
||||||
|
margin: 10px;
|
||||||
|
min-width: 200px;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#artist-album-list-album-name {
|
||||||
|
margin: 10px 10px 5px 10px;
|
||||||
|
font-size: 25px;
|
||||||
|
}
|
||||||
|
@@ -4,7 +4,7 @@ from typing import List, Union, Optional
|
|||||||
import gi
|
import gi
|
||||||
|
|
||||||
gi.require_version('Gtk', '3.0')
|
gi.require_version('Gtk', '3.0')
|
||||||
from gi.repository import Gtk, GObject, Pango
|
from gi.repository import Gtk, GObject, Pango, GLib
|
||||||
|
|
||||||
from libremsonic.state_manager import ApplicationState
|
from libremsonic.state_manager import ApplicationState
|
||||||
from libremsonic.cache_manager import CacheManager
|
from libremsonic.cache_manager import CacheManager
|
||||||
@@ -16,21 +16,14 @@ from libremsonic.server.api_objects import (
|
|||||||
ArtistID3,
|
ArtistID3,
|
||||||
ArtistInfo2,
|
ArtistInfo2,
|
||||||
ArtistWithAlbumsID3,
|
ArtistWithAlbumsID3,
|
||||||
|
AlbumWithSongsID3,
|
||||||
Child,
|
Child,
|
||||||
|
Directory,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .albums import AlbumsGrid
|
from .albums import AlbumsGrid
|
||||||
|
|
||||||
|
|
||||||
class ArtistModel(GObject.Object):
|
|
||||||
def __init__(self, id, name, cover_art, album_count=0):
|
|
||||||
self.id = id
|
|
||||||
self.name = name
|
|
||||||
self.cover_art = cover_art
|
|
||||||
self.album_count = album_count
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
|
|
||||||
class ArtistsPanel(Gtk.Paned):
|
class ArtistsPanel(Gtk.Paned):
|
||||||
"""Defines the arist panel."""
|
"""Defines the arist panel."""
|
||||||
|
|
||||||
@@ -48,11 +41,17 @@ class ArtistsPanel(Gtk.Paned):
|
|||||||
self.selected_artist = None
|
self.selected_artist = None
|
||||||
|
|
||||||
self.artist_list = ArtistList()
|
self.artist_list = ArtistList()
|
||||||
self.artist_list.connect('selection-changed',
|
self.artist_list.connect(
|
||||||
self.on_list_selection_changed)
|
'selection-changed',
|
||||||
|
self.on_list_selection_changed,
|
||||||
|
)
|
||||||
self.pack1(self.artist_list, False, False)
|
self.pack1(self.artist_list, False, False)
|
||||||
|
|
||||||
self.artist_detail_panel = ArtistDetailPanel()
|
self.artist_detail_panel = ArtistDetailPanel()
|
||||||
|
self.artist_detail_panel.connect(
|
||||||
|
'song-clicked',
|
||||||
|
lambda _, song, queue: self.emit('song-clicked', song, queue),
|
||||||
|
)
|
||||||
self.pack2(self.artist_detail_panel, True, False)
|
self.pack2(self.artist_detail_panel, True, False)
|
||||||
|
|
||||||
def update(self, state: ApplicationState):
|
def update(self, state: ApplicationState):
|
||||||
@@ -239,10 +238,12 @@ class ArtistDetailPanel(Gtk.Box):
|
|||||||
|
|
||||||
artist_info_box.pack_start(self.big_info_panel, False, True, 0)
|
artist_info_box.pack_start(self.big_info_panel, False, True, 0)
|
||||||
|
|
||||||
self.albums_grid = AlbumsGrid()
|
self.albums_list = AlbumsListWithSongs()
|
||||||
self.albums_grid.grid.set_halign(Gtk.Align.START)
|
self.albums_list.connect(
|
||||||
self.albums_grid.get_model_list_future = self.get_model_list_future
|
'song-clicked',
|
||||||
artist_info_box.pack_start(self.albums_grid, True, True, 0)
|
lambda _, song, queue: self.emit('song-clicked', song, queue),
|
||||||
|
)
|
||||||
|
artist_info_box.pack_start(self.albums_list, True, True, 0)
|
||||||
|
|
||||||
self.add(artist_info_box)
|
self.add(artist_info_box)
|
||||||
|
|
||||||
@@ -252,6 +253,8 @@ class ArtistDetailPanel(Gtk.Box):
|
|||||||
|
|
||||||
return CacheManager.executor.submit(do_get_model_list)
|
return CacheManager.executor.submit(do_get_model_list)
|
||||||
|
|
||||||
|
# TODO need to handle when this is force updated. Need to delete a bunch of
|
||||||
|
# stuff and un-cache things.
|
||||||
@util.async_callback(
|
@util.async_callback(
|
||||||
lambda *a, **k: CacheManager.get_artist(*a, **k),
|
lambda *a, **k: CacheManager.get_artist(*a, **k),
|
||||||
before_download=lambda self: self.artist_artwork.set_loading(True),
|
before_download=lambda self: self.artist_artwork.set_loading(True),
|
||||||
@@ -267,7 +270,7 @@ class ArtistDetailPanel(Gtk.Box):
|
|||||||
self.update_artist_artwork(artist)
|
self.update_artist_artwork(artist)
|
||||||
|
|
||||||
self.albums = artist.get('album', artist.get('child', []))
|
self.albums = artist.get('album', artist.get('child', []))
|
||||||
self.albums_grid.update()
|
self.albums_list.update(artist)
|
||||||
|
|
||||||
@util.async_callback(
|
@util.async_callback(
|
||||||
lambda *a, **k: CacheManager.get_artist_info(*a, **k),
|
lambda *a, **k: CacheManager.get_artist_info(*a, **k),
|
||||||
@@ -341,3 +344,206 @@ class ArtistDetailPanel(Gtk.Box):
|
|||||||
]
|
]
|
||||||
|
|
||||||
return util.dot_join(*components)
|
return util.dot_join(*components)
|
||||||
|
|
||||||
|
|
||||||
|
class AlbumsListWithSongs(Gtk.Overlay):
|
||||||
|
__gsignals__ = {
|
||||||
|
'song-clicked': (
|
||||||
|
GObject.SIGNAL_RUN_FIRST,
|
||||||
|
GObject.TYPE_NONE,
|
||||||
|
(str, object),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
Gtk.Overlay.__init__(self)
|
||||||
|
self.scrolled_window = Gtk.ScrolledWindow(vexpand=True)
|
||||||
|
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
self.scrolled_window.add(self.box)
|
||||||
|
self.add(self.scrolled_window)
|
||||||
|
|
||||||
|
self.spinner = Gtk.Spinner(
|
||||||
|
name='albumslist-with-songs-spinner',
|
||||||
|
active=False,
|
||||||
|
halign=Gtk.Align.CENTER,
|
||||||
|
valign=Gtk.Align.CENTER,
|
||||||
|
)
|
||||||
|
self.add_overlay(self.spinner)
|
||||||
|
|
||||||
|
def update(self, artist):
|
||||||
|
for c in self.box.get_children():
|
||||||
|
self.box.remove(c)
|
||||||
|
|
||||||
|
for album in artist.get('album', artist.get('child', [])):
|
||||||
|
album_with_songs = AlbumWithSongs(album)
|
||||||
|
album_with_songs.connect(
|
||||||
|
'song-clicked',
|
||||||
|
lambda _, song, queue: self.emit('song-clicked', song, queue),
|
||||||
|
)
|
||||||
|
self.box.add(album_with_songs)
|
||||||
|
|
||||||
|
self.scrolled_window.show_all()
|
||||||
|
|
||||||
|
|
||||||
|
class AlbumWithSongs(Gtk.Box):
|
||||||
|
__gsignals__ = {
|
||||||
|
'song-clicked': (
|
||||||
|
GObject.SIGNAL_RUN_FIRST,
|
||||||
|
GObject.TYPE_NONE,
|
||||||
|
(str, object),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, album):
|
||||||
|
Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
|
||||||
|
self.album = album
|
||||||
|
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
artist_artwork = SpinnerImage(
|
||||||
|
loading=False,
|
||||||
|
image_name='artist-album-list-artwork',
|
||||||
|
spinner_name='artist-artwork-spinner',
|
||||||
|
)
|
||||||
|
box.pack_start(artist_artwork, False, False, 0)
|
||||||
|
box.pack_start(Gtk.Box(), True, True, 0)
|
||||||
|
self.pack_start(box, False, False, 0)
|
||||||
|
|
||||||
|
def cover_art_future_done(f):
|
||||||
|
artist_artwork.set_from_file(f.result())
|
||||||
|
artist_artwork.set_loading(False)
|
||||||
|
|
||||||
|
cover_art_filename_future = CacheManager.get_cover_art_filename(
|
||||||
|
album.coverArt,
|
||||||
|
before_download=lambda: artist_artwork.set_loading(True),
|
||||||
|
size=200,
|
||||||
|
)
|
||||||
|
cover_art_filename_future.add_done_callback(cover_art_future_done)
|
||||||
|
|
||||||
|
album_details = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
album_details.add(
|
||||||
|
Gtk.Label(
|
||||||
|
label=album.get('name', album.get('title')),
|
||||||
|
name='artist-album-list-album-name',
|
||||||
|
halign=Gtk.Align.START,
|
||||||
|
))
|
||||||
|
|
||||||
|
stats = [album.year, album.genre]
|
||||||
|
if album.get('duration'):
|
||||||
|
stats.append(util.format_sequence_duration(album.duration))
|
||||||
|
|
||||||
|
album_details.add(
|
||||||
|
Gtk.Label(
|
||||||
|
label=util.dot_join(*stats),
|
||||||
|
halign=Gtk.Align.START,
|
||||||
|
margin_left=10,
|
||||||
|
))
|
||||||
|
|
||||||
|
self.album_songs_model = Gtk.ListStore(
|
||||||
|
str, # cache status
|
||||||
|
str, # title
|
||||||
|
str, # duration
|
||||||
|
str, # song ID
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_column(header, text_idx, bold=False, align=0, width=None):
|
||||||
|
renderer = Gtk.CellRendererText(
|
||||||
|
xalign=align,
|
||||||
|
weight=Pango.Weight.BOLD if bold else Pango.Weight.NORMAL,
|
||||||
|
ellipsize=Pango.EllipsizeMode.END,
|
||||||
|
)
|
||||||
|
renderer.set_fixed_size(width or -1, 35)
|
||||||
|
|
||||||
|
column = Gtk.TreeViewColumn(header, renderer, text=text_idx)
|
||||||
|
column.set_resizable(True)
|
||||||
|
column.set_expand(not width)
|
||||||
|
return column
|
||||||
|
|
||||||
|
album_songs = Gtk.TreeView(
|
||||||
|
model=self.album_songs_model,
|
||||||
|
name='album-songs-list',
|
||||||
|
margin_top=15,
|
||||||
|
margin_left=10,
|
||||||
|
margin_right=10,
|
||||||
|
)
|
||||||
|
album_songs.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE)
|
||||||
|
|
||||||
|
# Song status column.
|
||||||
|
renderer = Gtk.CellRendererPixbuf()
|
||||||
|
renderer.set_fixed_size(30, 35)
|
||||||
|
column = Gtk.TreeViewColumn('', renderer, icon_name=0)
|
||||||
|
column.set_resizable(True)
|
||||||
|
album_songs.append_column(column)
|
||||||
|
|
||||||
|
album_songs.append_column(create_column('TITLE', 1, bold=True))
|
||||||
|
album_songs.append_column(
|
||||||
|
create_column('DURATION', 2, align=1, width=40))
|
||||||
|
|
||||||
|
album_songs.connect('row-activated', self.on_song_activated)
|
||||||
|
album_songs.connect('button-press-event', self.on_song_button_press)
|
||||||
|
album_details.add(album_songs)
|
||||||
|
|
||||||
|
self.pack_end(album_details, True, True, 0)
|
||||||
|
|
||||||
|
self.update_album_songs(album.id)
|
||||||
|
|
||||||
|
def on_song_activated(self, treeview, idx, column):
|
||||||
|
# The song ID is in the last column of the model.
|
||||||
|
song_id = self.album_songs_model[idx][-1]
|
||||||
|
self.emit('song-clicked', song_id,
|
||||||
|
[m[-1] for m in self.album_songs_model])
|
||||||
|
|
||||||
|
def on_song_button_press(self, tree, event):
|
||||||
|
if event.button == 3: # Right click
|
||||||
|
clicked_path = tree.get_path_at_pos(event.x, event.y)
|
||||||
|
if not clicked_path:
|
||||||
|
return False
|
||||||
|
|
||||||
|
store, paths = tree.get_selection().get_selected_rows()
|
||||||
|
allow_deselect = False
|
||||||
|
|
||||||
|
def on_download_state_change(song_id=None):
|
||||||
|
GLib.idle_add(self.update_album_songs, self.album.id)
|
||||||
|
|
||||||
|
# Use the new selection instead of the old one for calculating what
|
||||||
|
# to do the right click on.
|
||||||
|
if clicked_path[0] not in paths:
|
||||||
|
paths = [clicked_path[0]]
|
||||||
|
allow_deselect = True
|
||||||
|
|
||||||
|
song_ids = [self.album_songs_model[p][-1] for p in paths]
|
||||||
|
|
||||||
|
# Used to adjust for the header row.
|
||||||
|
bin_coords = tree.convert_tree_to_bin_window_coords(
|
||||||
|
event.x, event.y)
|
||||||
|
widget_coords = tree.convert_tree_to_widget_coords(
|
||||||
|
event.x, event.y)
|
||||||
|
|
||||||
|
util.show_song_popover(
|
||||||
|
song_ids,
|
||||||
|
event.x,
|
||||||
|
event.y + abs(bin_coords.by - widget_coords.wy),
|
||||||
|
tree,
|
||||||
|
on_download_state_change=on_download_state_change,
|
||||||
|
)
|
||||||
|
|
||||||
|
# If the click was on a selected row, don't deselect anything.
|
||||||
|
if not allow_deselect:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@util.async_callback(
|
||||||
|
lambda *a, **k: CacheManager.get_album(*a, **k),
|
||||||
|
before_download=lambda *a: print('before'),
|
||||||
|
on_failure=lambda *a: print('failure', *a),
|
||||||
|
)
|
||||||
|
def update_album_songs(
|
||||||
|
self,
|
||||||
|
album: Union[AlbumWithSongsID3, Child, Directory],
|
||||||
|
):
|
||||||
|
new_model = [[
|
||||||
|
util.get_cached_status_icon(CacheManager.get_cached_status(song)),
|
||||||
|
util.esc(song.title),
|
||||||
|
util.format_song_duration(song.duration),
|
||||||
|
song.id,
|
||||||
|
] for song in album.get('child', album.get('song', []))]
|
||||||
|
|
||||||
|
util.diff_model(self.album_songs_model, new_model)
|
||||||
|
@@ -1,8 +1,6 @@
|
|||||||
import re
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import List, OrderedDict
|
from typing import List, OrderedDict
|
||||||
|
|
||||||
from deepdiff import DeepDiff
|
|
||||||
from fuzzywuzzy import process
|
from fuzzywuzzy import process
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
@@ -542,16 +540,9 @@ class PlaylistsPanel(Gtk.Paned):
|
|||||||
# 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
|
||||||
old_model = [row[:] for row in self.playlist_song_model]
|
|
||||||
|
|
||||||
cache_icon = {
|
|
||||||
SongCacheStatus.NOT_CACHED: '',
|
|
||||||
SongCacheStatus.CACHED: 'folder-download-symbolic',
|
|
||||||
SongCacheStatus.PERMANENTLY_CACHED: 'view-pin-symbolic',
|
|
||||||
SongCacheStatus.DOWNLOADING: 'emblem-synchronizing-symbolic',
|
|
||||||
}
|
|
||||||
new_model = [[
|
new_model = [[
|
||||||
cache_icon[CacheManager.get_cached_status(song)],
|
util.get_cached_status_icon(CacheManager.get_cached_status(song)),
|
||||||
song.title,
|
song.title,
|
||||||
song.album,
|
song.album,
|
||||||
song.artist,
|
song.artist,
|
||||||
@@ -559,27 +550,7 @@ class PlaylistsPanel(Gtk.Paned):
|
|||||||
song.id,
|
song.id,
|
||||||
] for song in (playlist.entry or [])]
|
] for song in (playlist.entry or [])]
|
||||||
|
|
||||||
# Diff the lists to determine what needs to be changed.
|
util.diff_model(self.playlist_song_model, new_model)
|
||||||
diff = DeepDiff(old_model, new_model)
|
|
||||||
changed = diff.get('values_changed', {})
|
|
||||||
added = diff.get('iterable_item_added', {})
|
|
||||||
removed = diff.get('iterable_item_removed', {})
|
|
||||||
|
|
||||||
def parse_location(location):
|
|
||||||
match = re.match(r'root\[(\d*)\](?:\[(\d*)\])?', location)
|
|
||||||
return tuple(map(int,
|
|
||||||
(g for g in match.groups() if g is not None)))
|
|
||||||
|
|
||||||
for edit_location, diff in changed.items():
|
|
||||||
idx, field = parse_location(edit_location)
|
|
||||||
self.playlist_song_model[idx][field] = diff['new_value']
|
|
||||||
|
|
||||||
for add_location, value in added.items():
|
|
||||||
self.playlist_song_model.append(value)
|
|
||||||
|
|
||||||
for remove_location, value in reversed(list(removed.items())):
|
|
||||||
remove_at = parse_location(remove_location)[0]
|
|
||||||
del self.playlist_song_model[remove_at]
|
|
||||||
|
|
||||||
self.editing_playlist_song_list = False
|
self.editing_playlist_song_list = False
|
||||||
self.set_playlist_view_loading(False)
|
self.set_playlist_view_loading(False)
|
||||||
|
@@ -1,8 +1,11 @@
|
|||||||
import functools
|
import functools
|
||||||
from typing import Callable, List, Tuple, Any
|
from typing import Callable, List, Tuple, Any
|
||||||
|
import re
|
||||||
|
|
||||||
from concurrent.futures import Future
|
from concurrent.futures import Future
|
||||||
|
|
||||||
|
from deepdiff import DeepDiff
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
gi.require_version('Gtk', '3.0')
|
gi.require_version('Gtk', '3.0')
|
||||||
from gi.repository import Gio, Gtk, GObject, GLib, Gdk
|
from gi.repository import Gio, Gtk, GObject, GLib, Gdk
|
||||||
@@ -68,6 +71,41 @@ def dot_join(*items):
|
|||||||
return ' • '.join(map(str, items))
|
return ' • '.join(map(str, items))
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_status_icon(cache_status: SongCacheStatus):
|
||||||
|
cache_icon = {
|
||||||
|
SongCacheStatus.NOT_CACHED: '',
|
||||||
|
SongCacheStatus.CACHED: 'folder-download-symbolic',
|
||||||
|
SongCacheStatus.PERMANENTLY_CACHED: 'view-pin-symbolic',
|
||||||
|
SongCacheStatus.DOWNLOADING: 'emblem-synchronizing-symbolic',
|
||||||
|
}
|
||||||
|
return cache_icon[cache_status]
|
||||||
|
|
||||||
|
|
||||||
|
def diff_model(model_to_edit, new_model):
|
||||||
|
old_model = [row[:] for row in model_to_edit]
|
||||||
|
|
||||||
|
# Diff the lists to determine what needs to be changed.
|
||||||
|
diff = DeepDiff(old_model, new_model)
|
||||||
|
changed = diff.get('values_changed', {})
|
||||||
|
added = diff.get('iterable_item_added', {})
|
||||||
|
removed = diff.get('iterable_item_removed', {})
|
||||||
|
|
||||||
|
def parse_location(location):
|
||||||
|
match = re.match(r'root\[(\d*)\](?:\[(\d*)\])?', location)
|
||||||
|
return tuple(map(int, (g for g in match.groups() if g is not None)))
|
||||||
|
|
||||||
|
for edit_location, diff in changed.items():
|
||||||
|
idx, field = parse_location(edit_location)
|
||||||
|
model_to_edit[idx][field] = diff['new_value']
|
||||||
|
|
||||||
|
for add_location, value in added.items():
|
||||||
|
model_to_edit.append(value)
|
||||||
|
|
||||||
|
for remove_location, value in reversed(list(removed.items())):
|
||||||
|
remove_at = parse_location(remove_location)[0]
|
||||||
|
del model_to_edit[remove_at]
|
||||||
|
|
||||||
|
|
||||||
def show_song_popover(
|
def show_song_popover(
|
||||||
song_ids,
|
song_ids,
|
||||||
x: int,
|
x: int,
|
||||||
@@ -151,7 +189,7 @@ def show_song_popover(
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
Gtk.ModelButton(
|
Gtk.ModelButton(
|
||||||
text=f"Demove {pluralize('download', song_count)}",
|
text=f"Remove {pluralize('download', song_count)}",
|
||||||
sensitive=remove_download_sensitive,
|
sensitive=remove_download_sensitive,
|
||||||
),
|
),
|
||||||
on_remove_downloads_click,
|
on_remove_downloads_click,
|
||||||
|
Reference in New Issue
Block a user