From 4aa1010f02e302b00b9614b0ab01fab0ee8274c4 Mon Sep 17 00:00:00 2001 From: zehkira <9485872-zehkira@users.noreply.gitlab.com> Date: Mon, 7 Aug 2023 19:26:33 +0200 Subject: [PATCH 1/5] Prototype importing --- source/monophony/backend/playlists.py | 121 +++++++++++------ source/monophony/backend/yt.py | 2 + .../monophony/frontend/pages/library_page.py | 17 ++- .../monophony/frontend/pages/results_page.py | 10 +- ...l_group_row.py => importable_group_row.py} | 2 +- .../frontend/windows/import_window.py | 123 ++++++++++++++++++ .../monophony/frontend/windows/main_window.py | 12 +- 7 files changed, 238 insertions(+), 49 deletions(-) rename source/monophony/frontend/rows/{external_group_row.py => importable_group_row.py} (93%) create mode 100644 source/monophony/frontend/windows/import_window.py diff --git a/source/monophony/backend/playlists.py b/source/monophony/backend/playlists.py index 4a7a8f6..798f026 100644 --- a/source/monophony/backend/playlists.py +++ b/source/monophony/backend/playlists.py @@ -6,16 +6,26 @@ import ytmusicapi # playlists = { -# 'my playlist': [ -# {'id': 'ASvGDFQwe', 'title': 'Cool song'} -# ] +# 'my playlist': [ +# {'id': 'ASvGDFQwe', 'title': 'Cool song'} +# ] # } +# external_playlists = [ +# { +# 'title': 'my playlist', +# 'url': 'ASDFfghjklZXbcnmhjklzxbcn', +# 'contents': [ +# {'id': 'ASvGDFQwe', 'title': 'Cool song'} +# ] +# } +# ] + ### --- PLAYLIST FUNCTIONS --- ### -def add_playlist(name: str, songs: dict=None): +def add_playlist(name: str, songs: list=None): new_lists = read_playlists() name = get_unique_name(name) @@ -25,7 +35,7 @@ def add_playlist(name: str, songs: dict=None): if song not in new_lists[name]: new_lists[name].append(song) - write_playlists(new_lists) + write_playlists(playlists=new_lists) def rename_playlist(name: str, new_name: str) -> bool: @@ -35,43 +45,55 @@ def rename_playlist(name: str, new_name: str) -> bool: new_lists = read_playlists() if new_name not in new_lists: new_lists[new_name] = new_lists.pop(name) - write_playlists(new_lists) + write_playlists(playlists=new_lists) return True return False -def import_playlist(name: str, data: str) -> bool: +def import_playlist(name: str, url: str, local: bool) -> bool: new_lists = read_playlists() - playlist = [] - - yt = ytmusicapi.YTMusic() - playlist_id = data.split('list=')[1].split('&')[0] - songs = yt.get_playlist(playlist_id, limit=None)['tracks'] + new_ext_lists = read_external_playlists() + songs = [] + playlist_id = url.split('list=')[-1].split('&')[0] - for song in songs: - if not song['videoId']: + try: + yt = ytmusicapi.YTMusic() + data = yt.get_playlist(playlist_id, limit=None)['tracks'] + except: + print('Could not get playlist') + return False + + for item in data: + if not item['videoId']: continue parsed_song = { - 'title': song['title'], - 'author': song['artists'][0]['name'], - 'length': song['duration'] if 'duration' in song else '', - 'id': song['videoId'], + 'title': item['title'], + 'author': item['artists'][0]['name'], + 'author_id': item['artists'][0]['id'], + 'length': item['duration'] if 'duration' in item else '', + 'id': item['videoId'], + 'thumbnail': item['thumbnails'][0]['url'] } - if parsed_song not in playlist: - playlist.append(parsed_song) + if parsed_song not in songs: + songs.append(parsed_song) name = get_unique_name(name) - new_lists[name] = playlist - write_playlists(new_lists) + if local: + new_lists[name] = songs + write_playlists(playlists=new_lists) + else: + new_ext_lists.append({'title': name, 'id': playlist_id, 'contents': songs}) + write_playlists(external_playlists=new_ext_lists) + return True def remove_playlist(name: str): new_lists = read_playlists() new_lists.pop(name) - write_playlists(new_lists) + write_playlists(playlists=new_lists) ### --- SONG FUNCTIONS --- ### @@ -90,13 +112,13 @@ def add_song(song: dict, playlist: str): new_lists = read_playlists() if song not in new_lists[playlist]: new_lists[playlist].append(song) - write_playlists(new_lists) + write_playlists(playlists=new_lists) def rename_song(index: int, playlist: str, new_name: str): new_lists = read_playlists() new_lists[playlist][index]['title'] = new_name - write_playlists(new_lists) + write_playlists(playlists=new_lists) def swap_songs(p_name: str, i: int, j: int): @@ -104,7 +126,7 @@ def swap_songs(p_name: str, i: int, j: int): i = 0 if i >= len(lists[p_name]) else i j = 0 if j >= len(lists[p_name]) else j lists[p_name][i], lists[p_name][j] = lists[p_name][j], lists[p_name][i] - write_playlists(lists) + write_playlists(playlists=lists) def move_song(p_name: str, from_i: int, to_i: int): @@ -114,25 +136,28 @@ def move_song(p_name: str, from_i: int, to_i: int): from_song = lists[p_name].pop(from_i) lists[p_name].insert(lists[p_name].index(to_song), from_song) - write_playlists(lists) + write_playlists(playlists=lists) def remove_song(id_: str, playlist: str): new_lists = read_playlists() new_lists[playlist] = [s for s in new_lists[playlist] if s['id'] != id_] - write_playlists(new_lists) + write_playlists(playlists=new_lists) ### --- UTILITY FUNCTIONS --- ### def get_unique_name(base: str) -> str: - lists = read_playlists() + taken_names = ( + list(read_playlists().keys()) + + [p['title'] for p in read_external_playlists()] + ) name = base - if name in lists: + if name in taken_names: i = 1 - while f'{name} ({str(i)})' in lists: + while f'{name} ({str(i)})' in taken_names: i += 1 name = f'{name} ({str(i)})' @@ -140,22 +165,23 @@ def get_unique_name(base: str) -> str: return name -def write_playlists(playlists: dict): +def write_playlists(playlists: dict=None, external_playlists: list=None): dir_path = os.getenv( 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') ) + '/monophony' lists_path = dir_path + '/playlists.json' - - all_songs = [] - for songs in playlists.values(): - all_songs.extend(songs) + ext_lists_path = dir_path + '/external-playlists.json' try: - with open(str(lists_path), 'w') as lists_file: - json.dump(playlists, lists_file) + if playlists is not None: + with open(str(lists_path), 'w') as lists_file: + json.dump(playlists, lists_file) + if external_playlists is not None: + with open(str(ext_lists_path), 'w') as ext_lists_file: + json.dump(external_playlists, ext_lists_file) except FileNotFoundError: os.makedirs(str(dir_path)) - write_playlists(playlists) + write_playlists(playlists=playlists, external_playlists=external_playlists) def read_playlists() -> dict: @@ -181,6 +207,21 @@ def read_playlists() -> dict: updated = True if updated: - write_playlists(lists) + write_playlists(playlists=lists) + + return lists + + +def read_external_playlists() -> list: + lists_path = os.getenv( + 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') + ) + '/monophony/external-playlists.json' + + lists = [] + try: + with open(lists_path, 'r') as lists_file: + lists = json.load(lists_file) + except OSError: + return [] return lists diff --git a/source/monophony/backend/yt.py b/source/monophony/backend/yt.py index 59d5a1e..9e36539 100644 --- a/source/monophony/backend/yt.py +++ b/source/monophony/backend/yt.py @@ -38,6 +38,7 @@ def _parse_results(data: list) -> list: try: album = yt.get_album(result['browseId']) item['author'] = result['artists'][0]['name'] + item['id'] = result['browseId'] item['title'] = result['title'] item['contents'] = [ { @@ -58,6 +59,7 @@ def _parse_results(data: list) -> list: try: album = yt.get_playlist(result['browseId']) item['author'] = result['author'] + item['id'] = result['browseId'] item['title'] = result['title'] item['contents'] = [ { diff --git a/source/monophony/frontend/pages/library_page.py b/source/monophony/frontend/pages/library_page.py index 39d5dfd..308da4a 100644 --- a/source/monophony/frontend/pages/library_page.py +++ b/source/monophony/frontend/pages/library_page.py @@ -1,6 +1,7 @@ import monophony.backend.history import monophony.backend.playlists from monophony.frontend.rows.local_group_row import MonophonyLocalGroupRow +from monophony.frontend.rows.importable_group_row import MonophonyImportableGroupRow from monophony.frontend.rows.song_row import MonophonySongRow import gi @@ -57,15 +58,17 @@ class MonophonyLibraryPage(Gtk.Box): GLib.timeout_add(100, self.update) def _on_play_all(self, _b): - playlists = monophony.backend.playlists.read_playlists() all_songs = [] - for title, content in playlists.items(): + for _title, content in monophony.backend.playlists.read_playlists().items(): all_songs.extend(content) + for playlist in monophony.backend.playlists.read_external_playlists(): + all_songs.extend(playlist['songs']) GLib.Thread.new(None, self.player.play_queue, all_songs, 0) def update(self) -> True: new_playlists = monophony.backend.playlists.read_playlists() + new_ext_lists = monophony.backend.playlists.read_external_playlists() remaining_widgets = [] for widget in self.playlist_widgets: @@ -84,6 +87,16 @@ class MonophonyLibraryPage(Gtk.Box): self.playlist_widgets.append(new_widget) self.box_playlists.add(new_widget) + for playlist in new_ext_lists: + title = GLib.markup_escape_text(playlist['title'], -1) + for widget in self.playlist_widgets: + if widget.get_title() == title: + break + else: # nobreak + new_widget = MonophonyImportableGroupRow(playlist, self.player) + self.playlist_widgets.append(new_widget) + self.box_playlists.add(new_widget) + self.box_playlists.set_visible(len(self.playlist_widgets) > 0) # player could be adding to recents at this moment diff --git a/source/monophony/frontend/pages/results_page.py b/source/monophony/frontend/pages/results_page.py index 8d116a0..d12fb5e 100644 --- a/source/monophony/frontend/pages/results_page.py +++ b/source/monophony/frontend/pages/results_page.py @@ -1,5 +1,5 @@ import monophony.backend.yt -from monophony.frontend.rows.external_group_row import MonophonyExternalGroupRow +from monophony.frontend.rows.importable_group_row import MonophonyImportableGroupRow from monophony.frontend.rows.song_row import MonophonySongRow from monophony.frontend.rows.artist_row import MonophonyArtistRow @@ -156,18 +156,18 @@ class MonophonyResultsPage(Gtk.Box): non_empty.append(box_videos) elif item['type'] == 'album': if item['top']: - box_top.add(MonophonyExternalGroupRow(item, self.player)) + box_top.add(MonophonyImportableGroupRow(item, self.player)) non_empty.append(box_top) continue - box_albums.add(MonophonyExternalGroupRow(item, self.player)) + box_albums.add(MonophonyImportableGroupRow(item, self.player)) if box_albums not in non_empty: non_empty.append(box_albums) elif item['type'] == 'playlist': if item['top']: - box_top.add(MonophonyExternalGroupRow(item, self.player)) + box_top.add(MonophonyImportableGroupRow(item, self.player)) non_empty.append(box_top) continue - box_playlists.add(MonophonyExternalGroupRow(item, self.player)) + box_playlists.add(MonophonyImportableGroupRow(item, self.player)) if box_playlists not in non_empty: non_empty.append(box_playlists) elif item['type'] == 'artist': diff --git a/source/monophony/frontend/rows/external_group_row.py b/source/monophony/frontend/rows/importable_group_row.py similarity index 93% rename from source/monophony/frontend/rows/external_group_row.py rename to source/monophony/frontend/rows/importable_group_row.py index b02834f..1f87a5e 100644 --- a/source/monophony/frontend/rows/external_group_row.py +++ b/source/monophony/frontend/rows/importable_group_row.py @@ -7,7 +7,7 @@ gi.require_version('Gtk', '4.0') from gi.repository import Gtk -class MonophonyExternalGroupRow(MonophonyGroupRow): +class MonophonyImportableGroupRow(MonophonyGroupRow): def __init__(self, group: dict, player: object): super().__init__(group, player) diff --git a/source/monophony/frontend/windows/import_window.py b/source/monophony/frontend/windows/import_window.py new file mode 100644 index 0000000..d1d1daf --- /dev/null +++ b/source/monophony/frontend/windows/import_window.py @@ -0,0 +1,123 @@ +import monophony.backend.playlists +import monophony.backend.yt +from monophony.frontend.windows.message_window import MonophonyMessageWindow + +import gi +gi.require_version('Adw', '1') +gi.require_version('Gtk', '4.0') +from gi.repository import Adw, GLib, GObject, Gtk + + +class MonophonyImportWindow(Adw.Window): + def __init__(self, name: str='', url: str=''): + super().__init__() + + self.import_lock = GLib.Mutex() + self.error = False + + self.ent_name = Gtk.Entry.new() + self.ent_name.set_text(name) + self.ent_name.set_placeholder_text(_('Enter Playlist Name...')) + self.ent_name.set_hexpand(True) + self.ent_name.set_margin_start(10) + self.ent_name.set_margin_end(10) + self.ent_name.set_halign(Gtk.Align.FILL) + self.ent_url = Gtk.Entry.new() + self.ent_url.set_text(url) + self.ent_url.set_placeholder_text(_('Enter Playlist URL...')) + self.ent_url.set_hexpand(True) + self.ent_url.set_halign(Gtk.Align.FILL) + self.ent_url.set_margin_start(10) + self.ent_url.set_margin_end(10) + chk_sync = Gtk.CheckButton.new_with_label(_('External Playlist')) + self.chk_local = Gtk.CheckButton.new_with_label(_('Local Playlist')) + self.chk_local.set_group(chk_sync) + self.chk_local.set_active(True) + box_type = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + box_type.set_spacing(10) + box_type.set_margin_start(10) + box_type.set_margin_end(10) + box_type.append(chk_sync) + box_type.append(self.chk_local) + + self.spn_import = Gtk.Spinner.new() + self.spn_import.set_margin_end(5) + self.spn_import.set_visible(False) + self.spn_import.bind_property( + 'visible', + self.spn_import, + 'spinning', + GObject.BindingFlags.SYNC_CREATE | GObject.BindingFlags.BIDIRECTIONAL + ) + btn_cancel = Gtk.Button.new_with_label(_('Cancel')) + btn_cancel.connect('clicked', lambda _b: self.destroy()) + self.btn_import = Gtk.Button.new_with_label(_('Import')) + self.btn_import.add_css_class('suggested-action') + self.btn_import.connect('clicked', lambda _b: self._on_submit()) + headerbar = Adw.HeaderBar.new() + headerbar.set_decoration_layout('') + headerbar.pack_start(btn_cancel) + headerbar.pack_end(self.btn_import) + headerbar.pack_end(self.spn_import) + + self.box_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.box_content.set_spacing(10) + self.box_content.set_margin_bottom(10) + self.box_content.append(headerbar) + self.box_content.append(self.ent_name) + self.box_content.append(self.ent_url) + self.box_content.append(box_type) + + self.set_title(_('Import playlist...')) + self.set_modal(True) + self.set_resizable(False) + self.set_content(self.box_content) + self.connect('close-request', lambda w: not w.box_content.get_sensitive()) + + def do_import(self, name: str, url: str, local: bool): + self.import_lock.lock() + if not monophony.backend.playlists.import_playlist(name, url, local): + self.error = True + self.import_lock.unlock() + + def await_import(self) -> bool: + if self.import_lock.trylock(): + self.import_lock.unlock() + if self.error: + self.error = False + self.box_content.set_sensitive(True) + self.spn_import.set_visible(False) + self.btn_import.set_visible(True) + MonophonyMessageWindow( + self, + _('Could not Import Playlist'), + _('Failed to retrieve playlist data from server.') + ).present() + else: + self.destroy() + + return False + return True + + def _on_submit(self): + name = self.ent_name.get_text() + url = self.ent_url.get_text() + local = self.chk_local.get_active() + + if not name: + MonophonyMessageWindow( + self, _('Could not Import Playlist'), _('A name is required.') + ).present() + return + + if not url: + MonophonyMessageWindow( + self, _('Could not Import Playlist'), _('A URL is required.') + ).present() + return + + self.box_content.set_sensitive(False) + self.spn_import.set_visible(True) + self.btn_import.set_visible(False) + GLib.Thread.new(None, self.do_import, name, url, local) + GLib.timeout_add(1000, self.await_import) diff --git a/source/monophony/frontend/windows/main_window.py b/source/monophony/frontend/windows/main_window.py index e0683db..a5b47fb 100644 --- a/source/monophony/frontend/windows/main_window.py +++ b/source/monophony/frontend/windows/main_window.py @@ -9,6 +9,7 @@ from monophony.frontend.pages.queue_page import MonophonyQueuePage from monophony.frontend.pages.results_page import MonophonyResultsPage from monophony.frontend.widgets.player import MonophonyPlayer from monophony.frontend.windows.add_window import MonophonyAddWindow +from monophony.frontend.windows.import_window import MonophonyImportWindow import gi gi.require_version('Adw', '1') @@ -46,9 +47,13 @@ class MonophonyMainWindow(Adw.ApplicationWindow): self.btn_back.connect('clicked', self._on_back_clicked) mnu_main = Gio.Menu() + mnu_main.append(_('Import Playlist'), 'import-playlist') + self.install_action( + 'import-playlist', None, (lambda w, _a, _t: w._on_import_clicked()) + ) mnu_main.append(_('About Monophony'), 'about-app') self.install_action( - 'about-app', None, (lambda w, a, t: w._on_about_clicked()) + 'about-app', None, (lambda w, _a, _t: w._on_about_clicked()) ) btn_menu = Gtk.MenuButton() btn_menu.set_primary(True) @@ -146,6 +151,11 @@ class MonophonyMainWindow(Adw.ApplicationWindow): win_about.set_transient_for(self) win_about.present() + def _on_import_clicked(self, name: str='', url: str=''): + popup = MonophonyImportWindow(name=name, url=url) + popup.set_transient_for(self) + popup.present() + def _on_add_clicked(self, song: dict): popup = MonophonyAddWindow(song, self.player) popup.set_transient_for(self) -- GitLab From 94d068a3e0fb8ffcb462018d233b25ed5697f4ef Mon Sep 17 00:00:00 2001 From: zehkira <9485872-zehkira@users.noreply.gitlab.com> Date: Tue, 8 Aug 2023 23:02:03 +0200 Subject: [PATCH 2/5] Add external group rows --- .../monophony/frontend/pages/library_page.py | 4 +- .../frontend/rows/external_group_row.py | 37 ++++++++++++++++ .../frontend/rows/importable_group_row.py | 42 +++++++++++++------ 3 files changed, 68 insertions(+), 15 deletions(-) create mode 100644 source/monophony/frontend/rows/external_group_row.py diff --git a/source/monophony/frontend/pages/library_page.py b/source/monophony/frontend/pages/library_page.py index 308da4a..808d9a4 100644 --- a/source/monophony/frontend/pages/library_page.py +++ b/source/monophony/frontend/pages/library_page.py @@ -1,7 +1,7 @@ import monophony.backend.history import monophony.backend.playlists from monophony.frontend.rows.local_group_row import MonophonyLocalGroupRow -from monophony.frontend.rows.importable_group_row import MonophonyImportableGroupRow +from monophony.frontend.rows.external_group_row import MonophonyExternalGroupRow from monophony.frontend.rows.song_row import MonophonySongRow import gi @@ -93,7 +93,7 @@ class MonophonyLibraryPage(Gtk.Box): if widget.get_title() == title: break else: # nobreak - new_widget = MonophonyImportableGroupRow(playlist, self.player) + new_widget = MonophonyExternalGroupRow(playlist, self.player) self.playlist_widgets.append(new_widget) self.box_playlists.add(new_widget) diff --git a/source/monophony/frontend/rows/external_group_row.py b/source/monophony/frontend/rows/external_group_row.py new file mode 100644 index 0000000..5dc6cbe --- /dev/null +++ b/source/monophony/frontend/rows/external_group_row.py @@ -0,0 +1,37 @@ +from monophony.frontend.rows.group_row import MonophonyGroupRow +from monophony.frontend.rows.song_row import MonophonySongRow + +import gi +gi.require_version('Gtk', '4.0') +from gi.repository import Gtk, Gio + + +class MonophonyExternalGroupRow(MonophonyGroupRow): + def __init__(self, group: dict, player: object): + super().__init__(group, player) + + for item in group['contents']: + self.add_row(MonophonySongRow(item, player, group)) + + btn_more = Gtk.MenuButton() + btn_more.set_tooltip_text(_('More actions')) + btn_more.set_icon_name('view-more-symbolic') + btn_more.set_has_frame(False) + btn_more.set_vexpand(False) + btn_more.set_valign(Gtk.Align.CENTER) + btn_more.set_create_popup_func(self._on_show_actions) + self.add_action(btn_more) + + def _on_show_actions(self, btn: Gtk.MenuButton): + window = self.get_ancestor(Gtk.Window) + mnu_actions = Gio.Menu() + mnu_actions.append(_('Download'), 'cache-playlist') + window.install_action( + 'cache-playlist', + None, + lambda w, *_: w._on_cache_playlist(self.group['contents']) + ) + mnu_actions.append(_('Delete'), 'delete-playlist') + pop_menu = Gtk.PopoverMenu() + pop_menu.set_menu_model(mnu_actions) + btn.set_popover(pop_menu) diff --git a/source/monophony/frontend/rows/importable_group_row.py b/source/monophony/frontend/rows/importable_group_row.py index 1f87a5e..7a990ab 100644 --- a/source/monophony/frontend/rows/importable_group_row.py +++ b/source/monophony/frontend/rows/importable_group_row.py @@ -2,9 +2,8 @@ from monophony.frontend.rows.group_row import MonophonyGroupRow from monophony.frontend.rows.song_row import MonophonySongRow import gi -gi.require_version('Adw', '1') gi.require_version('Gtk', '4.0') -from gi.repository import Gtk +from gi.repository import Gtk, Gio class MonophonyImportableGroupRow(MonophonyGroupRow): @@ -14,15 +13,32 @@ class MonophonyImportableGroupRow(MonophonyGroupRow): for item in group['contents']: self.add_row(MonophonySongRow(item, player, group)) - btn_save = Gtk.Button.new_from_icon_name('list-add-symbolic') - btn_save.set_tooltip_text(_('Add to library')) - btn_save.set_vexpand(False) - btn_save.set_valign(Gtk.Align.CENTER) - btn_save.set_has_frame(False) - btn_save.connect( - 'clicked', - lambda b, t, c: b.get_ancestor(Gtk.Window)._on_save_playlist(t, c), - self.group['title'], - self.group['contents'] + btn_more = Gtk.MenuButton() + btn_more.set_tooltip_text(_('More actions')) + btn_more.set_icon_name('view-more-symbolic') + btn_more.set_has_frame(False) + btn_more.set_vexpand(False) + btn_more.set_valign(Gtk.Align.CENTER) + btn_more.set_create_popup_func(self._on_show_actions) + self.add_action(btn_more) + + def _on_show_actions(self, btn: Gtk.MenuButton): + window = self.get_ancestor(Gtk.Window) + mnu_actions = Gio.Menu() + mnu_actions.append(_('Download'), 'cache-playlist') + window.install_action( + 'cache-playlist', + None, + lambda w, *_: w._on_cache_playlist(self.group['contents']) + ) + mnu_actions.append(_('Import...'), 'import-playlist') + window.install_action( + 'import-playlist', + None, + lambda w, *_: w._on_import_clicked( + self.group['title'], self.group['url'] # TODO + ) ) - self.add_action(btn_save) + pop_menu = Gtk.PopoverMenu() + pop_menu.set_menu_model(mnu_actions) + btn.set_popover(pop_menu) -- GitLab From f978596881f9db21e698b101674b84be064b1aa5 Mon Sep 17 00:00:00 2001 From: zehkira <9485872-zehkira@users.noreply.gitlab.com> Date: Thu, 10 Aug 2023 00:16:38 +0200 Subject: [PATCH 3/5] Implement external playlist management --- source/monophony/backend/playlists.py | 36 ++++++++++----- .../frontend/rows/external_group_row.py | 44 +++++++++++++++++-- .../frontend/rows/importable_group_row.py | 4 +- .../frontend/windows/import_window.py | 19 +++++--- .../monophony/frontend/windows/main_window.py | 26 +++++++---- 5 files changed, 96 insertions(+), 33 deletions(-) diff --git a/source/monophony/backend/playlists.py b/source/monophony/backend/playlists.py index 798f026..44654c7 100644 --- a/source/monophony/backend/playlists.py +++ b/source/monophony/backend/playlists.py @@ -38,6 +38,12 @@ def add_playlist(name: str, songs: list=None): write_playlists(playlists=new_lists) +def add_external_playlist(playlist: dict): + lists = read_external_playlists() + lists.append(playlist) + write_playlists(external_playlists=lists) + + def rename_playlist(name: str, new_name: str) -> bool: if name == new_name: return True @@ -56,10 +62,15 @@ def import_playlist(name: str, url: str, local: bool) -> bool: new_ext_lists = read_external_playlists() songs = [] playlist_id = url.split('list=')[-1].split('&')[0] + is_album = playlist_id.startswith('MPREb_') try: yt = ytmusicapi.YTMusic() - data = yt.get_playlist(playlist_id, limit=None)['tracks'] + if is_album: + album = yt.get_album(playlist_id) + data = album['tracks'] + else: + data = yt.get_playlist(playlist_id, limit=None)['tracks'] except: print('Could not get playlist') return False @@ -73,9 +84,13 @@ def import_playlist(name: str, url: str, local: bool) -> bool: 'author': item['artists'][0]['name'], 'author_id': item['artists'][0]['id'], 'length': item['duration'] if 'duration' in item else '', - 'id': item['videoId'], - 'thumbnail': item['thumbnails'][0]['url'] + 'id': item['videoId'] } + if is_album: + parsed_song['thumbnail'] = album['thumbnails'][0]['url'] + else: + parsed_song['thumbnail'] = item['thumbnails'][0]['url'] + if parsed_song not in songs: songs.append(parsed_song) @@ -96,16 +111,15 @@ def remove_playlist(name: str): write_playlists(playlists=new_lists) -### --- SONG FUNCTIONS --- ### - +def remove_external_playlist(name: str): + write_playlists( + external_playlists=[ + l for l in read_external_playlists() if l['title'] != name + ] + ) -def is_song_in_any_playlist(id_: str) -> bool: - for playlist in read_playlists().values(): - for song in playlist: - if song['id'] == id_: - return True - return False +### --- SONG FUNCTIONS --- ### def add_song(song: dict, playlist: str): diff --git a/source/monophony/frontend/rows/external_group_row.py b/source/monophony/frontend/rows/external_group_row.py index 5dc6cbe..9f84b48 100644 --- a/source/monophony/frontend/rows/external_group_row.py +++ b/source/monophony/frontend/rows/external_group_row.py @@ -1,17 +1,20 @@ +import monophony.backend.playlists + from monophony.frontend.rows.group_row import MonophonyGroupRow from monophony.frontend.rows.song_row import MonophonySongRow import gi +gi.require_version('Adw', '1') gi.require_version('Gtk', '4.0') -from gi.repository import Gtk, Gio +from gi.repository import Adw, Gtk, Gio, GLib class MonophonyExternalGroupRow(MonophonyGroupRow): def __init__(self, group: dict, player: object): super().__init__(group, player) - for item in group['contents']: - self.add_row(MonophonySongRow(item, player, group)) + self.song_widgets = [] + self.group['contents'] = [] btn_more = Gtk.MenuButton() btn_more.set_tooltip_text(_('More actions')) @@ -22,16 +25,49 @@ class MonophonyExternalGroupRow(MonophonyGroupRow): btn_more.set_create_popup_func(self._on_show_actions) self.add_action(btn_more) + GLib.timeout_add(100, self.update) + def _on_show_actions(self, btn: Gtk.MenuButton): window = self.get_ancestor(Gtk.Window) mnu_actions = Gio.Menu() + mnu_actions.append(_('Delete'), 'delete-playlist') + window.install_action( + 'delete-playlist', + None, + lambda w, *_: w._on_delete_playlist(self, local=False) + ) mnu_actions.append(_('Download'), 'cache-playlist') window.install_action( 'cache-playlist', None, lambda w, *_: w._on_cache_playlist(self.group['contents']) ) - mnu_actions.append(_('Delete'), 'delete-playlist') pop_menu = Gtk.PopoverMenu() pop_menu.set_menu_model(mnu_actions) btn.set_popover(pop_menu) + + def update(self) -> bool: + self.set_enable_expansion(self.song_widgets != []) + playlists = monophony.backend.playlists.read_external_playlists() + for playlist in playlists: + if playlist['title'] == self.group['title']: + contents = playlist['contents'] + break + else: # nobreak + self.get_ancestor(Adw.PreferencesGroup).remove(self) + return False + + if self.group['contents'] == contents: + return True + + for widget in self.song_widgets: + self.remove(widget) + + self.song_widgets = [] + self.group['contents'] = contents + for song in self.group['contents']: + row = MonophonySongRow(song, self.player, self.group) + self.add_row(row) + self.song_widgets.append(row) + + return True diff --git a/source/monophony/frontend/rows/importable_group_row.py b/source/monophony/frontend/rows/importable_group_row.py index 7a990ab..36b3c15 100644 --- a/source/monophony/frontend/rows/importable_group_row.py +++ b/source/monophony/frontend/rows/importable_group_row.py @@ -35,9 +35,7 @@ class MonophonyImportableGroupRow(MonophonyGroupRow): window.install_action( 'import-playlist', None, - lambda w, *_: w._on_import_clicked( - self.group['title'], self.group['url'] # TODO - ) + lambda w, *_: w._on_import_clicked(group=self.group) ) pop_menu = Gtk.PopoverMenu() pop_menu.set_menu_model(mnu_actions) diff --git a/source/monophony/frontend/windows/import_window.py b/source/monophony/frontend/windows/import_window.py index d1d1daf..8cd5100 100644 --- a/source/monophony/frontend/windows/import_window.py +++ b/source/monophony/frontend/windows/import_window.py @@ -9,14 +9,15 @@ from gi.repository import Adw, GLib, GObject, Gtk class MonophonyImportWindow(Adw.Window): - def __init__(self, name: str='', url: str=''): + def __init__(self, url: str='', group: list=None): super().__init__() self.import_lock = GLib.Mutex() self.error = False + self.group = group self.ent_name = Gtk.Entry.new() - self.ent_name.set_text(name) + self.ent_name.set_text(group['title'] if group else '') self.ent_name.set_placeholder_text(_('Enter Playlist Name...')) self.ent_name.set_hexpand(True) self.ent_name.set_margin_start(10) @@ -65,7 +66,8 @@ class MonophonyImportWindow(Adw.Window): self.box_content.set_margin_bottom(10) self.box_content.append(headerbar) self.box_content.append(self.ent_name) - self.box_content.append(self.ent_url) + if not self.group: + self.box_content.append(self.ent_url) self.box_content.append(box_type) self.set_title(_('Import playlist...')) @@ -76,7 +78,9 @@ class MonophonyImportWindow(Adw.Window): def do_import(self, name: str, url: str, local: bool): self.import_lock.lock() - if not monophony.backend.playlists.import_playlist(name, url, local): + if self.group and local: + monophony.backend.playlists.add_playlist(name, self.group['contents']) + elif not monophony.backend.playlists.import_playlist(name, url, local): self.error = True self.import_lock.unlock() @@ -101,7 +105,9 @@ class MonophonyImportWindow(Adw.Window): def _on_submit(self): name = self.ent_name.get_text() - url = self.ent_url.get_text() + url = ( + 'https://www.youtube.com/playlist?list=' + self.group['id'] + ) if self.group else self.ent_url.get_text() local = self.chk_local.get_active() if not name: @@ -110,7 +116,7 @@ class MonophonyImportWindow(Adw.Window): ).present() return - if not url: + if not url and not self.group: MonophonyMessageWindow( self, _('Could not Import Playlist'), _('A URL is required.') ).present() @@ -119,5 +125,6 @@ class MonophonyImportWindow(Adw.Window): self.box_content.set_sensitive(False) self.spn_import.set_visible(True) self.btn_import.set_visible(False) + GLib.Thread.new(None, self.do_import, name, url, local) GLib.timeout_add(1000, self.await_import) diff --git a/source/monophony/frontend/windows/main_window.py b/source/monophony/frontend/windows/main_window.py index a5b47fb..ee124d5 100644 --- a/source/monophony/frontend/windows/main_window.py +++ b/source/monophony/frontend/windows/main_window.py @@ -151,8 +151,8 @@ class MonophonyMainWindow(Adw.ApplicationWindow): win_about.set_transient_for(self) win_about.present() - def _on_import_clicked(self, name: str='', url: str=''): - popup = MonophonyImportWindow(name=name, url=url) + def _on_import_clicked(self, url: str='', group: list=None): + popup = MonophonyImportWindow(url=url, group=group) popup.set_transient_for(self) popup.present() @@ -183,26 +183,34 @@ class MonophonyMainWindow(Adw.ApplicationWindow): None, monophony.backend.cache.cache_songs, [s['id'] for s in songs] ) - def _on_delete_playlist(self, widget: object): - self.removed_playlists.append(widget.group) + def _on_delete_playlist(self, widget: object, local: bool=True): + group = widget.group.copy() + group['local'] = local + self.removed_playlists.append(group) toast_undo = Adw.Toast.new( - _('Deleted "{playlist_name}"').format(playlist_name=widget.group['title']) + _('Deleted "{playlist_name}"').format(playlist_name=group['title']) ) toast_undo.set_priority(Adw.ToastPriority.HIGH) toast_undo.set_button_label(_('Undo')) toast_undo.set_action_name('playlist-delete-undo') toast_undo.connect('dismissed', self._on_toast_dismissed) self.toaster.add_toast(toast_undo) - monophony.backend.playlists.remove_playlist(widget.group['title']) + if local: + monophony.backend.playlists.remove_playlist(group['title']) + else: + monophony.backend.playlists.remove_external_playlist(group['title']) def _on_toast_dismissed(self, _toast: object): self.removed_playlists.pop() def _on_undo_deletion(self): playlist = self.removed_playlists[len(self.removed_playlists) - 1] - monophony.backend.playlists.add_playlist( - playlist['title'], playlist['contents'] - ) + if playlist['local']: + monophony.backend.playlists.add_playlist( + playlist['title'], playlist['contents'] + ) + else: + monophony.backend.playlists.add_external_playlist(playlist) def _on_duplicate_playlist(self, widget: object): monophony.backend.playlists.add_playlist( -- GitLab From 2e27ad14c8cebff1f85e4bc13e39994d3826fbdd Mon Sep 17 00:00:00 2001 From: zehkira <9485872-zehkira@users.noreply.gitlab.com> Date: Thu, 10 Aug 2023 11:58:53 +0200 Subject: [PATCH 4/5] Add automatic updates --- source/monophony/backend/playlists.py | 12 ++++++ .../monophony/frontend/pages/library_page.py | 39 +++++++++++++------ .../frontend/rows/external_group_row.py | 7 +++- source/monophony/frontend/rows/group_row.py | 1 + .../frontend/widgets/progress_bar.py | 20 ++++++++++ 5 files changed, 66 insertions(+), 13 deletions(-) create mode 100644 source/monophony/frontend/widgets/progress_bar.py diff --git a/source/monophony/backend/playlists.py b/source/monophony/backend/playlists.py index 44654c7..c4c0d6f 100644 --- a/source/monophony/backend/playlists.py +++ b/source/monophony/backend/playlists.py @@ -119,6 +119,18 @@ def remove_external_playlist(name: str): ) +def update_external_playlists(loader: object): + loader.lock.lock() + lists = read_external_playlists() + loader.target = len(lists) + for playlist in lists: + remove_external_playlist(playlist['title']) + import_playlist(playlist['title'], playlist['id'], False) + loader.progress() + + loader.lock.unlock() + + ### --- SONG FUNCTIONS --- ### diff --git a/source/monophony/frontend/pages/library_page.py b/source/monophony/frontend/pages/library_page.py index 808d9a4..246dc76 100644 --- a/source/monophony/frontend/pages/library_page.py +++ b/source/monophony/frontend/pages/library_page.py @@ -3,11 +3,12 @@ import monophony.backend.playlists from monophony.frontend.rows.local_group_row import MonophonyLocalGroupRow from monophony.frontend.rows.external_group_row import MonophonyExternalGroupRow from monophony.frontend.rows.song_row import MonophonySongRow +from monophony.frontend.widgets.progress_bar import MonophonyProgressBar import gi gi.require_version('Adw', '1') gi.require_version('Gtk', '4.0') -from gi.repository import Adw, GLib, GObject, Gtk +from gi.repository import Adw, GLib, Gtk class MonophonyLibraryPage(Gtk.Box): @@ -22,27 +23,31 @@ class MonophonyLibraryPage(Gtk.Box): self.box_meta = Adw.PreferencesPage.new() self.box_meta.set_vexpand(True) + self.box_meta.set_visible(False) self.box_meta.set_valign(Gtk.Align.FILL) self.append(self.box_meta) self.pge_status = Adw.StatusPage() self.pge_status.set_vexpand(True) + self.pge_status.set_visible(False) self.pge_status.set_valign(Gtk.Align.FILL) self.pge_status.set_icon_name('io.gitlab.zehkira.Monophony') self.pge_status.set_title(_('Your Library is Empty')) self.pge_status.set_description( _('Find songs to play using the search bar above') ) - self.pge_status.bind_property( - 'visible', - self.box_meta, - 'visible', - GObject.BindingFlags.SYNC_CREATE - | GObject.BindingFlags.BIDIRECTIONAL - | GObject.BindingFlags.INVERT_BOOLEAN - ) self.append(self.pge_status) + bar_loading = MonophonyProgressBar(_('Loading Library...')) + bar_loading.set_halign(Gtk.Align.CENTER) + bar_loading.set_valign(Gtk.Align.CENTER) + bar_loading.set_vexpand(True) + self.box_loading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.box_loading.set_margin_bottom(10) + self.box_loading.append(bar_loading) + self.append(self.box_loading) + self.box_loading.set_visible(True) + self.btn_play = Gtk.Button.new_with_label(_('Play All')) self.btn_play.connect('clicked', self._on_play_all) self.box_playlists = Adw.PreferencesGroup() @@ -55,6 +60,9 @@ class MonophonyLibraryPage(Gtk.Box): self.box_recents.set_title(_('Recently Played')) self.box_meta.add(self.box_recents) + GLib.Thread.new( + None, monophony.backend.playlists.update_external_playlists, bar_loading + ) GLib.timeout_add(100, self.update) def _on_play_all(self, _b): @@ -62,11 +70,16 @@ class MonophonyLibraryPage(Gtk.Box): for _title, content in monophony.backend.playlists.read_playlists().items(): all_songs.extend(content) for playlist in monophony.backend.playlists.read_external_playlists(): - all_songs.extend(playlist['songs']) + all_songs.extend(playlist['content']) GLib.Thread.new(None, self.player.play_queue, all_songs, 0) - def update(self) -> True: + def update(self) -> bool: + if not self.box_loading.get_last_child().lock.trylock(): + return True + self.box_loading.get_last_child().lock.unlock() + self.box_loading.set_visible(False) + new_playlists = monophony.backend.playlists.read_playlists() new_ext_lists = monophony.backend.playlists.read_external_playlists() @@ -107,7 +120,6 @@ class MonophonyLibraryPage(Gtk.Box): new_recents.reverse() if new_recents != self.old_recents: self.box_recents.set_visible(True) - self.pge_status.set_visible(False) for widget in self.recents_widgets: self.box_recents.remove(widget) @@ -122,4 +134,7 @@ class MonophonyLibraryPage(Gtk.Box): self.box_meta.set_visible( self.box_playlists.get_visible() or self.box_recents.get_visible() ) + self.pge_status.set_visible( + not (self.box_meta.get_visible() or self.box_loading.get_visible()) + ) return True diff --git a/source/monophony/frontend/rows/external_group_row.py b/source/monophony/frontend/rows/external_group_row.py index 9f84b48..926195a 100644 --- a/source/monophony/frontend/rows/external_group_row.py +++ b/source/monophony/frontend/rows/external_group_row.py @@ -14,7 +14,10 @@ class MonophonyExternalGroupRow(MonophonyGroupRow): super().__init__(group, player) self.song_widgets = [] - self.group['contents'] = [] + for song in self.group['contents']: + row = MonophonySongRow(song, self.player, self.group) + self.add_row(row) + self.song_widgets.append(row) btn_more = Gtk.MenuButton() btn_more.set_tooltip_text(_('More actions')) @@ -24,6 +27,7 @@ class MonophonyExternalGroupRow(MonophonyGroupRow): btn_more.set_valign(Gtk.Align.CENTER) btn_more.set_create_popup_func(self._on_show_actions) self.add_action(btn_more) + self.set_subtitle(_('External')) GLib.timeout_add(100, self.update) @@ -60,6 +64,7 @@ class MonophonyExternalGroupRow(MonophonyGroupRow): if self.group['contents'] == contents: return True + print('change', self) for widget in self.song_widgets: self.remove(widget) diff --git a/source/monophony/frontend/rows/group_row.py b/source/monophony/frontend/rows/group_row.py index c43a6df..cd257e2 100644 --- a/source/monophony/frontend/rows/group_row.py +++ b/source/monophony/frontend/rows/group_row.py @@ -25,6 +25,7 @@ class MonophonyGroupRow(Adw.ExpanderRow): btn_play.set_valign(Gtk.Align.CENTER) btn_play.connect('clicked', self._on_play_clicked) self.add_prefix(btn_play) + self.set_expanded(False) def _on_play_clicked(self, _b): if not self.group: diff --git a/source/monophony/frontend/widgets/progress_bar.py b/source/monophony/frontend/widgets/progress_bar.py new file mode 100644 index 0000000..fc8f46d --- /dev/null +++ b/source/monophony/frontend/widgets/progress_bar.py @@ -0,0 +1,20 @@ +import gi +gi.require_version('Gtk', '4.0') +from gi.repository import GLib, Gtk + + +class MonophonyProgressBar(Gtk.ProgressBar): + def __init__(self, text: str=''): + super().__init__() + + self.lock = GLib.Mutex() + self.target = 100 + self._progress = 0 + + if text: + self.set_text(text) + self.set_show_text(True) + + def progress(self, step: int=1): + self._progress += step + self.set_fraction(self._progress / self.target) -- GitLab From fe21e1b54003e2cc97d3e320f5808a64ac4e5936 Mon Sep 17 00:00:00 2001 From: zehkira <9485872-zehkira@users.noreply.gitlab.com> Date: Thu, 10 Aug 2023 12:32:36 +0200 Subject: [PATCH 5/5] Add external playlist renaming --- source/monophony/backend/playlists.py | 15 ++++-- .../frontend/rows/external_group_row.py | 48 ++++++++++++++++++- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/source/monophony/backend/playlists.py b/source/monophony/backend/playlists.py index c4c0d6f..4719fab 100644 --- a/source/monophony/backend/playlists.py +++ b/source/monophony/backend/playlists.py @@ -44,16 +44,25 @@ def add_external_playlist(playlist: dict): write_playlists(external_playlists=lists) -def rename_playlist(name: str, new_name: str) -> bool: +def rename_playlist(name: str, new_name: str, local: bool=True) -> bool: if name == new_name: return True + if get_unique_name(new_name) != new_name: + return False - new_lists = read_playlists() - if new_name not in new_lists: + if local: + new_lists = read_playlists() new_lists[new_name] = new_lists.pop(name) write_playlists(playlists=new_lists) return True + new_lists = read_external_playlists() + for i, playlist in enumerate(new_lists): + if playlist['title'] == name: + new_lists[i]['title'] = new_name + write_playlists(external_playlists=new_lists) + return True + return False diff --git a/source/monophony/frontend/rows/external_group_row.py b/source/monophony/frontend/rows/external_group_row.py index 926195a..750bb59 100644 --- a/source/monophony/frontend/rows/external_group_row.py +++ b/source/monophony/frontend/rows/external_group_row.py @@ -2,6 +2,7 @@ import monophony.backend.playlists from monophony.frontend.rows.group_row import MonophonyGroupRow from monophony.frontend.rows.song_row import MonophonySongRow +from monophony.frontend.windows.message_window import MonophonyMessageWindow import gi gi.require_version('Adw', '1') @@ -46,10 +47,56 @@ class MonophonyExternalGroupRow(MonophonyGroupRow): None, lambda w, *_: w._on_cache_playlist(self.group['contents']) ) + mnu_actions.append(_('Rename...'), 'rename-playlist') + window.install_action( + 'rename-playlist', + None, + lambda *_: self._on_open_rename_menu(btn) + ) pop_menu = Gtk.PopoverMenu() pop_menu.set_menu_model(mnu_actions) btn.set_popover(pop_menu) + def _on_open_rename_menu(self, btn: Gtk.MenuButton): + pop_rename = Gtk.Popover.new() + ent_name = Gtk.Entry.new() + ent_name.set_text(self.group['title']) + ent_name.connect( + 'activate', lambda e: self._on_rename(e.get_text(), pop_rename) + ) + btn_rename = Gtk.Button.new_with_label(_('Rename')) + btn_rename.add_css_class('suggested-action') + btn_rename.connect( + 'clicked', lambda _b: self._on_rename(ent_name.get_text(), pop_rename) + ) + box_rename = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + box_rename.set_spacing(5) + box_rename.set_margin_top(5) + box_rename.set_margin_bottom(5) + box_rename.set_margin_start(5) + box_rename.set_margin_end(5) + box_rename.append(ent_name) + box_rename.append(btn_rename) + pop_rename.set_child(box_rename) + pop_rename.set_parent(btn) + btn.popdown() + pop_rename.popup() + + def _on_rename(self, name: str, pop: Gtk.Popover): + pop.popdown() + success = monophony.backend.playlists.rename_playlist( + self.group['title'], name, False + ) + if success: + self.group['title'] = name + self.set_title(name) + else: + MonophonyMessageWindow( + self.get_ancestor(Gtk.Window), + _('Could not Rename'), + _('Playlist already exists') + ).present() + def update(self) -> bool: self.set_enable_expansion(self.song_widgets != []) playlists = monophony.backend.playlists.read_external_playlists() @@ -64,7 +111,6 @@ class MonophonyExternalGroupRow(MonophonyGroupRow): if self.group['contents'] == contents: return True - print('change', self) for widget in self.song_widgets: self.remove(widget) -- GitLab