From 92384b47590b372e51e440e8711816346c7466dd Mon Sep 17 00:00:00 2001 From: Jakob Meier Date: Wed, 21 Feb 2024 09:34:30 +0100 Subject: [PATCH] Added basic file data import screen currently only single file picking is implemented --- melon/home/__init__.py | 1 + melon/import_providers/__init__.py | 25 ++++ melon/import_providers/utils.py | 62 ++++++++ melon/importer.py | 98 ++++++++++++ melon/models/__init__.py | 229 ++++++++++++++++++----------- melon/models/callbacks.py | 12 ++ melon/servers/__init__.py | 4 +- melon/widgets/feeditem.py | 12 +- melon/window.py | 5 + 9 files changed, 363 insertions(+), 85 deletions(-) create mode 100644 melon/import_providers/__init__.py create mode 100644 melon/import_providers/utils.py create mode 100644 melon/importer.py diff --git a/melon/home/__init__.py b/melon/home/__init__.py index ef8a7c3..e96e011 100644 --- a/melon/home/__init__.py +++ b/melon/home/__init__.py @@ -43,6 +43,7 @@ class HomeScreen(Adw.NavigationPage): self.menu_button.set_icon_name("open-menu-symbolic") model = Gio.Menu() model.append("Preferences", "win.prefs") + model.append("Import Data", "win.import") model.append("About Melon", "win.about") self.menu_popover = Gtk.PopoverMenu() self.menu_popover.set_menu_model(model) diff --git a/melon/import_providers/__init__.py b/melon/import_providers/__init__.py new file mode 100644 index 0000000..5ecdcc6 --- /dev/null +++ b/melon/import_providers/__init__.py @@ -0,0 +1,25 @@ +from enum import Enum, auto +from abc import ABC,abstractmethod + +class PickerMode(Enum): + FILE = auto() + FOLDER = auto() +class ImportProvider(ABC): + id: str + title: str + description: str + picker_title: str + is_multi: bool + mode: PickerMode + # dictionary/map with filter name as key + # and mime types in an array as the value + # only used for PickerMode.FILE + filters: dict[str, list[str]] = {} + @abstractmethod + def load(self, selection:list[str]): + """ + Called with a list of file/folder paths + after the selection dialog was shown + should take care of importing by accessing the internal model + """ + pass diff --git a/melon/import_providers/utils.py b/melon/import_providers/utils.py new file mode 100644 index 0000000..668942b --- /dev/null +++ b/melon/import_providers/utils.py @@ -0,0 +1,62 @@ +import sys +import gi +gi.require_version('Gtk', '4.0') +gi.require_version('Adw', '1') +from gi.repository import Gtk, Adw, GLib, Gio +from melon.import_providers import ImportProvider, PickerMode + +class ImportPicker(Gtk.FileDialog): + cb = None + def __init__(self, provider:ImportProvider, *args, **kwargs): + super().__init__(*args, **kwargs) + self.set_title(provider.picker_title) + self.set_modal(True) + self.provider = provider + def set_onselect(self, cb): + self.cb = cb + def done(self, paths: list[str]): + if self.cb is None: + return + self.cb(self.provider, paths) + def open_single_file(self, dialog, result): + try: + file = dialog.open_finish(result) + if file is not None: + self.done([file.get_path()]) + except GLib.Error as error: + print(f"Error opening file: {error.message}") + def open_multi_files(self, dialog, result): + try: + data = dialog.open_multiple_finish(result) + files = [] + for i in range(data.get_n_items()): + file = data.get_item(i) + if file is not None: + files.append(file.get_path()) + self.done(files) + except GLib.Error as error: + print(f"Error opening file: {error.message}") + def show(self, win): + if self.provider.mode == PickerMode.FILE: + # generate filter + it = self.provider.filters.items() + if len(it) > 0: + filters = Gio.ListStore.new(Gtk.FileFilter) + first = True + for name, mimes in it: + f = Gtk.FileFilter() + f.set_name(name) + for mime in mimes: + f.add_mime_type(mime) + filters.append(f) + if first: + first = False + self.set_default_filter(f) + self.set_filters(filters) + # construct dialog + if self.provider.is_multi: + self.open_multiple(win, None, self.open_multi_files) + else: + self.open(win, None, self.open_single_file) + else: + pass diff --git a/melon/importer.py b/melon/importer.py new file mode 100644 index 0000000..752c14a --- /dev/null +++ b/melon/importer.py @@ -0,0 +1,98 @@ +import sys +import gi +gi.require_version('Gtk', '4.0') +gi.require_version('Adw', '1') +from gi.repository import Gtk, Adw, Gio + +from melon.import_providers.utils import ImportPicker +from melon.import_providers import ImportProvider, PickerMode +from melon.models.callbacks import freeze, unfreeze +from melon.servers.utils import get_allowed_servers_list, get_server_instance +from melon.models import get_app_settings + +class ImporterScreen(Adw.NavigationPage): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.set_title("Import") + + self.header_bar = Adw.HeaderBar() + + self.toolbar_view = Adw.ToolbarView() + self.toolbar_view.add_top_bar(self.header_bar) + + self.wrapper = Adw.Clamp() + self.scrollview = Gtk.ScrolledWindow() + self.wrapper.set_child(self.scrollview) + self.toolbar_view.set_content(self.wrapper) + self.set_child(self.toolbar_view) + + self.reset() + + def reset(self): + servers = get_allowed_servers_list(get_app_settings()) + self.box = Adw.PreferencesPage() + self.box.set_title("Import") + self.box.set_description("The following import methods have been found") + + count = 0 + for server in servers: + inst = get_server_instance(server) + providers = inst.get_import_providers() + if len(providers) == 0: + continue + group = Adw.PreferencesGroup() + group.set_title(server["name"]) + group.set_description(server["description"]) + for provider in providers: + count += 1 + row = Adw.ActionRow() + row.set_title(provider.title) + row.set_subtitle(provider.description) + row.add_suffix(Gtk.Image.new_from_icon_name("go-next-symbolic")) + row.set_activatable(True) + row.connect("activated", pass_me(self.do_import, provider)) + group.add(row) + self.box.add(group) + + if count == 0: + # if no servers are allowed, show the nothing here box + status = Adw.StatusPage() + status.set_title("*crickets chirping*") + status.set_description("There are no available importers") + status.set_icon_name("weather-few-clouds-night-symbolic") + icon_button = IconButton("Enable servers in the settings menu", "list-add-symbolic") + icon_button.set_action_name("win.prefs") + box = Gtk.CenterBox() + box.set_center_widget(icon_button) + status.set_child(box) + self.scrollview.set_child(status) + else: + self.scrollview.set_child(self.box) + + def do_import(self, _, provider): + picker = ImportPicker(provider) + picker.show(None) + picker.set_onselect(self.on_select) + + def on_select(self, provider:ImportProvider, files): + # show spinner + spinner = Gtk.Spinner() + spinner.set_size_request(50, 50) + spinner.start() + cb = Gtk.CenterBox() + cb.set_center_widget(spinner) + self.scrollview.set_child(cb) + # freeze model callbacks + # otherwise this would result in a lot of reloads and requests + # which may lead to ddos-like server requests + freeze() + # long task + provider.load(files) + # reset once done + self.reset() + unfreeze() + # automatically go back home + self.activate_action("win.home", None) + +def pass_me(func, *args): + return lambda x: func(x, *args) diff --git a/melon/models/__init__.py b/melon/models/__init__.py index 0ebd8e9..d112b8c 100644 --- a/melon/models/__init__.py +++ b/melon/models/__init__.py @@ -67,7 +67,8 @@ def execute_sql(conn:sqlite3.Connection, sql, *sqlargs, many=False) -> bool: c.execute(sql, *sqlargs) conn.commit() c.close() - except: + except Exception as e: + print('SQLite-error:', e) return False else: return True @@ -101,8 +102,6 @@ server_settings_template = { } def die(): - # TODO remove - print("ERROR") raise "DB ERROR" def init_db(): @@ -203,8 +202,9 @@ def init_db(): id, video_id, video_server, + position, FOREIGN KEY(video_id, video_server) REFERENCES videos(id, server), - PRIMARY KEY(id, video_id, video_server) + PRIMARY KEY(id, video_id, video_server, position) ) """) or die() # history @@ -234,6 +234,14 @@ def init_db(): FOREIGN KEY(server, playlist_id) REFERENCES playlists(server, id) ) """) + execute_sql(conn, """ + CREATE TABLE news( + timestamp, + video_id, + video_server, + FOREIGN KEY(video_id, video_server) REFERENCES videos(id, server) + ) + """) # the initial setup should always initialize the newest version # and not depend on migrations @@ -261,7 +269,7 @@ def init_server_settings(sid:str) -> bool: "SELECT * FROM servers WHERE server = ?", (sid,)).fetchall() if len(results) == 0: # initialize enabled config - execute_sql(conn, "INSERT INTO servers VALUES (?, ?)", (sid, server_settings_template["enabled"])) + execute_sql(conn, "INSERT OR REPLACE INTO servers VALUES (?, ?)", (sid, server_settings_template["enabled"])) notify("settings_changed") return True return False @@ -278,7 +286,7 @@ def set_server_setting(sid:str, pref:str, value): init_server_settings(sid) conn = connect_to_db() execute_sql(conn, - "INSERT INTO server_settings VALUE (?,?,?)", + "INSERT OR REPLACE INTO server_settings VALUE (?,?,?)", (sid, pref, value)) notify("settings_changed") def get_server_settings(sid:str): @@ -363,23 +371,14 @@ def has_channel(channel_server, channel_id): """, (channel_server, channel_id)).fetchall() return len(results) != 0 def ensure_channel(channel:Channel): - if has_channel(channel.server, channel.id): - # Update channel data - conn = connect_to_db() - execute_sql(conn, """ - UPDATE channels - SET url = ?, name = ?, bio = ?, avatar = ? - WHERE server = ?, id = ? - """, - (channel.url, channel.name, channel.bio, channel.avatar, channel.server, channel.id)) - else: - # insert channel data - conn = connect_to_db() - execute_sql(conn, """ - INSERT INTO channels - VALUES (?,?,?,?,?,?) - """, - (channel.server, channel.id, channel.url, channel.name, channel.bio, channel.avatar)) + # insert channel data + conn = connect_to_db() + execute_sql(conn, """ + INSERT OR REPLACE INTO channels + VALUES (?,?,?,?,?,?) + """, + (channel.server, channel.id, channel.url, channel.name, channel.bio, channel.avatar)) + conn.close() notify("channels_changed") def ensure_subscribed_to_channel(channel: Channel): if is_subscribed_to_channel(channel.server, channel.id): @@ -387,9 +386,10 @@ def ensure_subscribed_to_channel(channel: Channel): ensure_channel(channel) conn = connect_to_db() execute_sql(conn, """ - INSERT INTO subscriptions + INSERT OR REPLACE INTO subscriptions VALUES (?,?) """, (channel.id, channel.server)) + conn.close() notify("channels_changed") def ensure_unsubscribed_from_channel(server_id: str, channel_id:str): conn = connect_to_db() @@ -397,6 +397,7 @@ def ensure_unsubscribed_from_channel(server_id: str, channel_id:str): DELETE FROM subscriptions WHERE server = ?, id = ? """, (server_id, channel_id)) + conn.close() notify("channels_changed") def get_subscribed_channels() -> list[Channel]: conn = connect_to_db() @@ -405,6 +406,7 @@ def get_subscribed_channels() -> list[Channel]: FROM channels, subscriptions WHERE channels.id = subscriptions.video_id AND channels.server == subscriptions.server """).fetchall() + conn.close() return [ Channel(d[0], d[2], d[1], d[3], d[4], d[5]) for d in results ] def has_video(server_id, video_id): @@ -416,24 +418,25 @@ def has_video(server_id, video_id): """, (server_id, video_id)).fetchall() return len(results) != 0 def ensure_video(vid): - if has_video(vid.server, vid.id): - # update video data - conn = connect_to_db() - execute_sql(conn, """ - UPDATE videos - SET url = ?, title = ?, description = ?, thumbnail = ?, channel_id = ?, channel_name = ? - WHERE server = ?, id = ? - """, - (vid.url, vid.title, vid.description, vid.thumbnail, vid.channel[1], vid.channel[0], vid.server, vid.id)) - else: - # create video - conn = connect_to_db() - execute_sql(conn, """ - INSERT INTO videos + # create video + conn = connect_to_db() + execute_sql(conn, """ + INSERT OR REPLACE INTO videos + VALUES (?,?,?,?,?,?,?,?) + """, + (vid.server, vid.id, vid.url, vid.title, vid.description, vid.thumbnail, vid.channel[1], vid.channel[0])) +def add_videos(vids:list[Video]): + # TODO deduplicate entries + conn = connect_to_db() + execute_sql( + conn, + """ + INSERT OR REPLACE INTO videos VALUES (?,?,?,?,?,?,?,?) """, - (vid.server, vid.id, vid.url, vid.title, vid.description, vid.thumbnail, vid.channel[1], vid.channel[0]) - ) + [ (vid.server, vid.id, vid.url, vid.title, vid.description, vid.thumbnail, vid.channel[1], vid.channel[0]) for vid in vids], + many=True) + notify("history_changed") def has_bookmarked_external_playlist(server_id: str, playlist_id:str) -> bool: conn = connect_to_db() @@ -460,44 +463,23 @@ def has_playlist(p: PlaylistWrapper) -> bool: WHERE server = ? AND playlist_id = ? """, (p.inner.server, p.inner.id)).fetchall() return len(results) != 0 - return False def ensure_playlist(playlist: PlaylistWrapper): if playlist.type == PlaylistWrapperType.LOCAL: - if has_playlist(playlist): - # update playlist data - conn = connect_to_db() - execute_sql(conn, """ - UPDATE local_playlists - SET title = ?, description = ?, thumbnail = ? - WHERE id = ? - """, - (playlist.inner.title, playlist.inner.description, playlist.inner.thumbnail, playlist.inner.id)) - else: - # insert new playlist - conn = connect_to_db() - execute_sql(conn, """ - INSERT INTO local_playlists - VALUES (?,?,?,?) - """, - (playlist.inner.id, playlist.inner.title, playlist.inner.description, playlist.inner.thumbnail)) + # insert new playlist + conn = connect_to_db() + execute_sql(conn, """ + INSERT OR REPLACE INTO local_playlists + VALUES (?,?,?,?) + """, + (playlist.inner.id, playlist.inner.title, playlist.inner.description, playlist.inner.thumbnail)) else: - if has_playlist(playlist): - # update playlist data - conn = connect_to_db() - execute_sql(conn, """ - UPDATE playlists - SET url = ?, title = ?, description = ?, channel_id = ?, channel_name = ?, thumbnail = ? - WHERE server = ?, id = ? - """, - (playlist.inner.url, playlist.inner.title, playlist.inner.description, playlist.inner.channel[1], playlist.inner.channel[0], playlist.inner.thumbnail, playlist.inner.server, playlist.inner.id)) - else: - # insert new playlist - conn = connect_to_db() - execute_sql(conn, """ - INSERT INTO playlists - VALUES (?,?,?,?,?,?,?,?) - """, - (playlist.inner.server, playlist.inner.id, playlist.inner.url, playlist.inner.title, playlist.inner.description,playlist.inner.channel[1], playlist.inner.channel[0], playlist.inner.thumbnail)) + # insert new playlist + conn = connect_to_db() + execute_sql(conn, """ + INSERT OR REPLACE INTO playlists + VALUES (?,?,?,?,?,?,?,?) + """, + (playlist.inner.server, playlist.inner.id, playlist.inner.url, playlist.inner.title, playlist.inner.description,playlist.inner.channel[1], playlist.inner.channel[0], playlist.inner.thumbnail)) notify("playlists_changed") def ensure_bookmark_external_playlist(playlist: Playlist): wrapped = PlaylistWrapper.from_external(playlist) @@ -505,7 +487,7 @@ def ensure_bookmark_external_playlist(playlist: Playlist): ensure_playlist(wrapped) conn = connect_to_db() execute_sql(conn, """ - INSERT INTO bookmarked_playlists + INSERT OR REPLACE INTO bookmarked_playlists VALUES (?,?) """, (playlist.server, playlist.id)) @@ -529,6 +511,10 @@ def new_local_playlist(name: str, description: str, videos=[]) -> int: VALUES (?,?,?,?) """, (id, name, description, None)) + index = 0 + for vid in videos: + add_to_local_playlist(id, vid, index) + index += 1 notify("playlists_changed") return id def ensure_delete_local_playlist(playlist_id:int): @@ -551,7 +537,8 @@ def get_playlists() -> list[PlaylistWrapper]: FROM local_playlists """).fetchall() for collection in local: - p = LocalPlaylist(collection[0], collection[2], collection[1]) + p = LocalPlaylist(collection[0], collection[1], collection[2]) + p.thumbnail = collection[3] results.append(PlaylistWrapper.from_local(p)) # get all bookmark playlists glob = conn.execute(""" @@ -563,13 +550,20 @@ def get_playlists() -> list[PlaylistWrapper]: p = Playlist(collection[0], collection[1], collection[2], collection[3], (collection[5], collection[4]), collection[6]) results.append(PlaylistWrapper.from_external(p)) return results -def add_to_local_playlist(playlist_id:int, vid): +def add_to_local_playlist(playlist_id:int, vid, pos=None): ensure_video(vid) conn = connect_to_db() + if pos is None: + vids = conn.execute(""" + SELECT server, videos.id, url, title, description, thumbnail, channel_id, channel_name + FROM local_playlist_content, videos + WHERE local_playlist_content.id = ? AND local_playlist_content.video_id = videos.id AND local_playlist_content.video_server = videos.server + """, (playlist_id,)).fetchall() + pos = len(vids) execute_sql(conn, """ INSERT INTO local_playlist_content - VALUES (?, ?, ?) - """, (playlist_id, vid.id, vid.server)) + VALUES (?, ?, ?, ?) + """, (playlist_id, vid.id, vid.server, pos)) notify("playlists_changed") def get_local_playlist(playlist_id:int) -> LocalPlaylist: conn = connect_to_db() @@ -584,16 +578,26 @@ def get_local_playlist(playlist_id:int) -> LocalPlaylist: SELECT server, videos.id, url, title, description, thumbnail, channel_id, channel_name FROM local_playlist_content, videos WHERE local_playlist_content.id = ? AND local_playlist_content.video_id = videos.id AND local_playlist_content.video_server = videos.server + ORDER BY position """, (playlist_id,)).fetchall() p.content = [ Video(d[0], d[2], d[1], d[3], (d[7], d[6]), d[4], d[5]) for d in vids ] return p +def set_local_playlist_thumbnail(playlist_id: int, thumb: str): + conn = connect_to_db() + execute_sql(conn, """ + UPDATE local_playlists + SET thumbnail = ? + WHERE id = ? + """, (thumb, playlist_id)) + notify("playlists_changed") -def add_to_history(vid): +def add_to_history(vid, uts=None): """ Takes :Video type and adds the video to the history automatically adds a uts to the video """ - uts = datetime.now().timestamp() + if uts == None: + uts = datetime.now().timestamp() ensure_video(vid) conn = connect_to_db() execute_sql(conn, """ @@ -601,6 +605,20 @@ def add_to_history(vid): VALUES (?, ?, ?) """, (uts, vid.id, vid.server)) + notify("history_changed") +def add_history_items(items:list[(Video, int)]): + # NOTE: you have to ensure the videos exist yourself + conn = connect_to_db() + execute_sql( + conn, + """ + INSERT INTO history + VALUES (?, ?, ?) + """, + [(d[1], d[0].id, d[0].server) for d in items], + many=True) + notify("history_changed") + def get_history(): """ Returns a list of (Video, uts) tuples @@ -612,3 +630,50 @@ def get_history(): WHERE history.video_id = id AND history.video_server = server """).fetchall() return [ (Video(d[1], d[3], d[2], d[4], (d[8], d[7]), d[5], d[6]), d[0]) for d in results ] + +def get_cached_feed(): + """ + Returns a list of (Video, uts) tuples + """ + conn = connect_to_db() + results = conn.execute(""" + SELECT timestamp, server, id, url, title, description, thumbnail, channel_id, channel_name + FROM news,videos + WHERE news.video_id = id AND news.video_server = server + """).fetchall() + return [ (Video(d[1], d[3], d[2], d[4], (d[8], d[7]), d[5], d[6]), d[0]) for d in results ] +def clear_cached_feed(): + conn = connect_to_db() + execute_sql(conn, """ + DELETE FROM news + """) +def update_cached_feed(ls: list[(Video, int)]): + uts = datetime.now().timestamp() + conn = connect_to_db() + # update last refresh time + # stored in app settings + # code same as set_app_setting + # but the notify's in the normal function would cause an infinite loop + app_conf = get_app_settings() + execute_sql( + conn, + """ + INSERT OR REPLACE INTO appconf + VALUES (?,?) + """, ("news-feed-refresh", uts)) + # update cached items + for entry in ls: + vid = entry[0] + uts = entry[1] + ensure_video(vid) + execute_sql(conn, """ + INSERT INTO news + VALUES (?, ?, ?) + """, + (uts, vid.id, vid.server)) +def get_last_feed_refresh() -> int: + conn = connect_to_db() + app_conf = get_app_settings() + if "news-feed-refresh" in app_conf: + return app_conf["news-feed-refresh"] + return None diff --git a/melon/models/callbacks.py b/melon/models/callbacks.py index 80194d6..de647bd 100644 --- a/melon/models/callbacks.py +++ b/melon/models/callbacks.py @@ -12,6 +12,16 @@ callbacks = { "history_changed": {}, "settings_changed": {} } +frozen = False +def freeze(): + global frozen + frozen = True +def unfreeze(): + global frozen + frozen = False + # notify everything - to refresh everything we might have missed + for key in callbacks: + notify(key) def register_callback(target: str, callback_id:str, function): if not target in callbacks: return @@ -22,6 +32,8 @@ def unregister_callback(target: str, callback_id: str): if callback_id in callbacks[target]: callbacks[target].pop(callback_id) def notify(target:str): + if frozen: + return if not target in callbacks: return for _, func in callbacks[target].items(): diff --git a/melon/servers/__init__.py b/melon/servers/__init__.py index a790c55..94bb56f 100644 --- a/melon/servers/__init__.py +++ b/melon/servers/__init__.py @@ -1,6 +1,7 @@ from enum import Flag,Enum,auto from abc import ABC,abstractmethod from melon.servers.loader import server_finder +from melon.import_providers import ImportProvider REQUESTS_TIMEOUT = 5 @@ -235,10 +236,11 @@ class Server(ABC): """ pass @abstractmethod - @abstractmethod def get_video_streams(self, vid:str) -> list[Stream]: """ Returns a list of available streams i.e hd, 720p """ return [] + def get_import_providers(self) -> list[ImportProvider]: + return [] diff --git a/melon/widgets/feeditem.py b/melon/widgets/feeditem.py index 024a58e..bb76458 100644 --- a/melon/widgets/feeditem.py +++ b/melon/widgets/feeditem.py @@ -25,13 +25,21 @@ class AdaptiveFeedItem(Adw.ActionRow): if show_preview: pixbuf = pixbuf_from_url(resource.thumbnail) self.set_title(resource.title.replace("&","&")) - self.set_subtitle(resource.description.replace("&","&")) + pad = "" + if len(resource.description) > 80: + pad = "..." + sub = resource.description[:80] + pad + self.set_subtitle(sub.replace("&","&")) self.set_action_name("win.browse_playlist") elif isinstance(resource, Channel): if show_preview: pixbuf = pixbuf_from_url(resource.avatar) self.set_title(resource.name.replace("&","&")) - self.set_subtitle(resource.bio.replace("&","&")) + pad = "" + if len(resource.bio) > 80: + pad = "..." + sub = resource.bio[:80] + pad + self.set_subtitle(sub.replace("&","&")) self.set_action_name("win.browse_channel") self.preview = Adw.Avatar() self.preview.set_size(48) diff --git a/melon/window.py b/melon/window.py index e461c7a..f62d452 100644 --- a/melon/window.py +++ b/melon/window.py @@ -12,6 +12,7 @@ from melon.browse.playlist import BrowsePlaylistScreen from melon.browse.search import GlobalSearchScreen from melon.servers.utils import get_servers_list, get_server_instance from melon.settings import SettingsScreen +from melon.importer import ImporterScreen from melon.player import PlayerScreen from melon.widgets.simpledialog import SimpleDialog from melon.playlist import LocalPlaylistScreen @@ -89,6 +90,9 @@ class MainWindow(Adw.ApplicationWindow): # opens the setting panel def open_settings(self,action,prefs): self.view.push(SettingsScreen()) + # opens the data import panel + def open_import(self,action,prefs): + self.view.push(ImporterScreen()) # navigate back to the home screen def go_home(self,action,prefs): self.view.pop_to_page(self.home) @@ -114,6 +118,7 @@ class MainWindow(Adw.ApplicationWindow): # Add actions self.reg_action("about", self.open_about) self.reg_action("prefs", self.open_settings) + self.reg_action("import", self.open_import) self.reg_action("browse", self.open_browse) self.reg_action("home", self.go_home) self.reg_action("global_search", self.open_global_search) -- 2.38.5