From b14f13aab68aef7cd6071f11f31e5f80629c9103 Mon Sep 17 00:00:00 2001 From: Dzheremi-belpois Date: Mon, 23 Dec 2024 01:00:59 +0300 Subject: [PATCH] feat: added directory parsing --- .gitignore | 1 - .vscode/settings.json | 25 ++++++++++ chronograph/main.py | 3 ++ chronograph/shared.pyi | 16 ++++++ chronograph/ui/BoxDialog.py | 40 +++++++++++++++ chronograph/ui/SongCard.py | 21 ++++++++ chronograph/utils/file.py | 36 ++++++++------ chronograph/utils/file_mutagen_id3.py | 45 ++++++++++------- chronograph/utils/file_mutagen_vorbis.py | 43 ++++++++++------ chronograph/utils/parsers.py | 49 +++++++++++++++++++ chronograph/utils/select_data.py | 32 ++++++++++++ chronograph/window.py | 39 ++++++++++----- data/Chronograph.gresource.xml.in | 1 + data/gtk/ui/BoxDialog.blp | 45 +++++++++++++++++ data/gtk/window.blp | 24 +++++---- ...io.github.dzheremi2.Chronograph.service.in | 2 +- data/meson.build | 3 +- 17 files changed, 347 insertions(+), 78 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 chronograph/shared.pyi create mode 100644 chronograph/ui/BoxDialog.py create mode 100644 chronograph/utils/parsers.py create mode 100644 chronograph/utils/select_data.py create mode 100644 data/gtk/ui/BoxDialog.blp diff --git a/.gitignore b/.gitignore index 291301b..46f1110 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ .idea/ COPYING .flatpak/ -.vscode/ builddir/ data/icons/icons/symbolic/lyrics-symbolic.svg data/icons/icons/symbolic/publish-symbolic.svg diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b80e5e1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,25 @@ +{ + "files.watcherExclude": { + "**/.git/objects/**": true, + "**/.git/subtree-cache/**": true, + "**/.hg/store/**": true, + "**/.dart_tool": true, + ".flatpak/**": true, + "_build/**": true + }, + "mesonbuild.configureOnOpen": false, + "mesonbuild.buildFolder": "_build", + "search.followSymlinks": false, + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, + "editor.rulers": [88], + }, + "isort.args":["--profile", "black"], + "python.analysis.diagnosticSeverityOverrides": { + "reportMissingModuleSource": "none" + } +} \ No newline at end of file diff --git a/chronograph/main.py b/chronograph/main.py index 1de55ef..7b03b95 100644 --- a/chronograph/main.py +++ b/chronograph/main.py @@ -13,6 +13,8 @@ class ChronographApplication(Adw.Application): + """Application class""" + win: ChronographWindow def __init__(self) -> None: @@ -32,6 +34,7 @@ def do_activate(self) -> None: # pylint: disable=arguments-differ ("quit", ("q",)), ("toggle_sidebar", ("F9",), shared.win), ("toggle_search", ("f",), shared.win), + ("select_dir", ("o",), shared.win), } ) diff --git a/chronograph/shared.pyi b/chronograph/shared.pyi new file mode 100644 index 0000000..3e94144 --- /dev/null +++ b/chronograph/shared.pyi @@ -0,0 +1,16 @@ +from pathlib import Path + +from gi.repository import Gio, Adw # type: ignore + +APP_ID: str +VERSION: str +PREFIX: str + +config_dir: Path +cache_dir: Path + +schema: Gio.Settings +state_schema: Gio.Settings + +app: Adw.Application +win: Adw.ApplicationWindow diff --git a/chronograph/ui/BoxDialog.py b/chronograph/ui/BoxDialog.py new file mode 100644 index 0000000..af7d614 --- /dev/null +++ b/chronograph/ui/BoxDialog.py @@ -0,0 +1,40 @@ +from gi.repository import Adw, Gtk # type: ignore + +from chronograph import shared + + +@Gtk.Template(resource_path=shared.PREFIX + "/gtk/ui/BoxDialog.ui") +class BoxDialog(Adw.Dialog): + """Dialog with lines of `Adw.ActionRow(s)` with provided content + + Parameters + ---------- + label : str + Label of the dialog + lines_content : tuple + titles and subtitles of `Adw.ActionRow(s)`. Like `(("1st Title", "1st subtitle"), ("2nd title", "2nd subtitle"), ...)` + + GTK Objects + ---------- + :: + + diaglog_title_label : Gtk.Label -> Label of the dialog + props_list : Gtk.ListBox -> ListBox with `Adw.ActionRow(s)` with provided data + """ + + __gtype_name__ = "BoxDialog" + + dialog_title_label: Gtk.Label = Gtk.Template.Child() + props_list: Gtk.ListBox = Gtk.Template.Child() + + def __init__(self, label: str, lines_content: tuple) -> None: + super().__init__() + + for entry in lines_content: + self.props_list.append( + Adw.ActionRow( + title=entry[0], subtitle=entry[1], css_classes=["property"] + ) + ) + + self.dialog_title_label.set_label(label) diff --git a/chronograph/ui/SongCard.py b/chronograph/ui/SongCard.py index f474fc2..5cd673b 100644 --- a/chronograph/ui/SongCard.py +++ b/chronograph/ui/SongCard.py @@ -9,6 +9,26 @@ @Gtk.Template(resource_path=shared.PREFIX + "/gtk/ui/SongCard.ui") class SongCard(Gtk.Box): + """Card with Title, Artist and Cover of provided file + + Parameters + ---------- + file : Union[FileID3, FileVorbis] + File of `.ogg`, `.flac`, `.mp3` and `.wav` formats + + GTK Objects + ---------- + :: + + buttons_revealer: Gtk.Revealer -> Revealer for Play and Edit buttons + play_button: Gtk.Button -> Play button + metadata_editor_button: Gtk.Button -> Metadata editor button + cover_button: Gtk.Button -> Clickable cover of song + cover: Gtk.Image -> Cover image of song + title_label: Gtk.Label -> Title of song + artist_label: Gtk.Label -> Artist of song + """ + __gtype_name__ = "SongCard" buttons_revealer: Gtk.Revealer = Gtk.Template.Child() @@ -35,6 +55,7 @@ def __init__(self, file: Union[FileID3, FileVorbis]) -> None: self.cover.props.paintable = _texture def toggle_buttons(self, *_args) -> None: + """Sets if buttons should be visible or not""" self.buttons_revealer.set_reveal_child( not self.buttons_revealer.get_reveal_child() ) diff --git a/chronograph/utils/file.py b/chronograph/utils/file.py index 2a70b0c..f20e636 100644 --- a/chronograph/utils/file.py +++ b/chronograph/utils/file.py @@ -1,28 +1,36 @@ -from typing import Any, Union +from typing import Union import mutagen from gi.repository import Gdk, GLib # type: ignore class BaseFile: - """A base class for mutagen filetypes classes""" + """A base class for mutagen filetypes classes + + Parameters + ---------- + path : str + A path to file for loading + + Props + -------- + :: + + title : str -> Title of song + artist : str -> Artist of song + album : str -> Album of song + cover : Gdk.Texture | str -> Cover of song + """ __gtype_name__ = "BaseFile" - _title: str = None - _artist: str = None - _album: str = None - _cover: Any = None - _mutagen_file = None + _title: str = "Unknown" + _artist: str = "Unknown" + _album: str = "Unknown" + _cover: Union[Gdk.Texture, str] = None + _mutagen_file: dict = None def __init__(self, path: str) -> None: - """ - Parameters - ---------- - path : str - A path to file for loading - """ - self._path: str = path self.load_from_file(path) diff --git a/chronograph/utils/file_mutagen_id3.py b/chronograph/utils/file_mutagen_id3.py index 24698c3..6ca480a 100644 --- a/chronograph/utils/file_mutagen_id3.py +++ b/chronograph/utils/file_mutagen_id3.py @@ -4,36 +4,43 @@ class FileID3(BaseFile): + """A ID3 compatible file class. Inherited from `BaseFile` + + Parameters + -------- + path : str + A path to file for loading + """ + __gtype_name__ = "FileID3" - def __init__(self, path): + def __init__(self, path) -> None: super().__init__(path) self.load_cover() self.load_str_data() # pylint: disable=attribute-defined-outside-init def load_cover(self) -> None: - """Extracts cover from song file. If no cover, then sets cover as 'icon'""" - pictures = self._mutagen_file.tags.getall("APIC") - if len(pictures) != 0: - self._cover = pictures[0].data - if len(pictures) == 0: + """Extracts cover from song file. If no cover, then sets cover as `icon`""" + if self._mutagen_file.tags is not None: + pictures = self._mutagen_file.tags.getall("APIC") + if len(pictures) != 0: + self._cover = pictures[0].data + if len(pictures) == 0: + self._cover = "icon" + else: self._cover = "icon" def load_str_data(self) -> None: - """Sets all string data from tags. If data is unavailable, then sets 'Unknown' - """ - if (_title := self._mutagen_file.tags["TIT2"].text[0]) is not None: - self._title = _title - else: - self._title = os.path.basename(self._path) + """Sets all string data from tags. If data is unavailable, then sets `Unknown`""" + if self._mutagen_file.tags is not None: + if (_title := self._mutagen_file.tags["TIT2"].text[0]) is not None: + self._title = _title - if (_artist := self._mutagen_file.tags["TPE1"].text[0]) is not None: - self._artist = _artist - else: - self._artist = "Unknown" + if (_artist := self._mutagen_file.tags["TPE1"].text[0]) is not None: + self._artist = _artist - if (_album := self._mutagen_file.tags["TALB"].text[0]) is not None: - self._album = _album + if (_album := self._mutagen_file.tags["TALB"].text[0]) is not None: + self._album = _album else: - self._album = "Unknown" + self._title = os.path.basename(self._path) diff --git a/chronograph/utils/file_mutagen_vorbis.py b/chronograph/utils/file_mutagen_vorbis.py index f135e24..a5bc99d 100644 --- a/chronograph/utils/file_mutagen_vorbis.py +++ b/chronograph/utils/file_mutagen_vorbis.py @@ -1,4 +1,5 @@ import base64 +import os from mutagen.flac import FLAC, Picture from mutagen.flac import error as FLACError @@ -7,9 +8,17 @@ class FileVorbis(BaseFile): + """A Vorbis (ogg, flac) compatible file class. Inherited from `BaseFile` + + Parameters + -------- + path : str + A path to file for loading + """ + __gtype_name__ = "FileVorbis" - def __init__(self, path): + def __init__(self, path) -> None: super().__init__(path) self.load_cover() @@ -18,7 +27,7 @@ def __init__(self, path): def load_cover(self) -> None: """Loads cover for Vorbis format audio""" if isinstance(self._mutagen_file, FLAC) and self._mutagen_file.pictures: - if self._mutagen_file.pictures[0].data != None: + if self._mutagen_file.pictures[0].data is not None: self._cover = self._mutagen_file.pictures[0].data else: self._cover = "icon" @@ -40,7 +49,6 @@ def load_cover(self) -> None: self._cover = "icon" else: self._cover = _data - print(_data) else: self._cover = "icon" @@ -50,23 +58,26 @@ def load_str_data(self, tags: list = ["title", "artist", "album"]) -> None: Parameters ---------- tags : list, persistent - list of tags for parsing in vorbis comment, by default ["title", "artist", "album"] + list of tags for parsing in vorbis comment, by default `["title", "artist", "album"]` """ - for tag in tags: - try: - text = ( - "Unknown" - if not self._mutagen_file.tags[tag.lower()][0] - else self._mutagen_file.tags[tag.lower()][0] - ) - setattr(self, f"_{tag}", text) - except KeyError: + if self._mutagen_file.tags is not None: + for tag in tags: try: text = ( "Unknown" - if not self._mutagen_file.tags[tag.upper()][0] - else self._mutagen_file.tags[tag.upper()][0] + if not self._mutagen_file.tags[tag.lower()][0] + else self._mutagen_file.tags[tag.lower()][0] ) setattr(self, f"_{tag}", text) except KeyError: - setattr(self, f"_{tag}", "Unknown") + try: + text = ( + "Unknown" + if not self._mutagen_file.tags[tag.upper()][0] + else self._mutagen_file.tags[tag.upper()][0] + ) + setattr(self, f"_{tag}", text) + except KeyError: + setattr(self, f"_{tag}", "Unknown") + if self._title == "Unknown": + self._title = os.path.basename(self._path) diff --git a/chronograph/utils/parsers.py b/chronograph/utils/parsers.py new file mode 100644 index 0000000..18a7605 --- /dev/null +++ b/chronograph/utils/parsers.py @@ -0,0 +1,49 @@ +import os +from pathlib import Path +from typing import Union + +from gi.repository import GLib # type: ignore + +from chronograph import shared +from chronograph.ui.SongCard import SongCard +from chronograph.utils.file_mutagen_id3 import FileID3 +from chronograph.utils.file_mutagen_vorbis import FileVorbis + + +def dir_parser(path: str, *_args) -> None: + """Parses directory and creates `SongCard` instances for files in directory of formats `ogg`, `flac`, `mp3` and `wav` + + Parameters + ---------- + path : str + Path to directory to parse + """ + shared.win.library.remove_all() + path = path + "/" + mutagen_files = [] + for file in os.listdir(path): + if not os.path.isdir(path + file): + if Path(file).suffix in (".ogg", ".flac"): + mutagen_files.append(FileVorbis(path + file)) + elif Path(file).suffix in (".mp3", ".wav"): + mutagen_files.append(FileID3(path + file)) + + for file in mutagen_files: + GLib.idle_add(songcard_idle, file) + shared.win.library_scrolled_window.set_child(shared.win.library) + # NOTE: This should be implemented in ALL parsers functions + # for child in shared.win.library: + # child.set_focusable(False) + + +def songcard_idle(file: Union[FileID3, FileVorbis]) -> None: + """Appends new `SongCard` instance to `ChronographWindow.library` + + Parameters + ---------- + file : Union[FileID3, FileVorbis] + File of song + """ + song_card = SongCard(file) + shared.win.library.append(song_card) + song_card.get_parent().set_focusable(False) diff --git a/chronograph/utils/select_data.py b/chronograph/utils/select_data.py new file mode 100644 index 0000000..e248fb6 --- /dev/null +++ b/chronograph/utils/select_data.py @@ -0,0 +1,32 @@ +import threading +from typing import Any + +from gi.repository import Gio, Gtk # type: ignore + +from chronograph import shared +from chronograph.utils.parsers import dir_parser + + +def select_dir(*_args) -> None: + """Creates `Gtk.FileDialog` to select directory for parsing by `chronograph.utils.parsers.dir_parser`""" + dialog = Gtk.FileDialog( + default_filter=Gtk.FileFilter(mime_types=["inode/directory"]) + ) + dialog.select_folder(shared.win, None, on_selected_dir) + + +def on_selected_dir(file_dialog: Gtk.FileDialog, result: Gio.Task) -> None: + """Callbacked by `select_dir`. Creates thread for `chronograph.utils.parsers.dir_parser` launches this function in it + + Parameters + ---------- + file_dialog : Gtk.FileDialog + FileDialog, callbacked from `select_dir` + result : Gio.Task + Task for reading, callbacked from `select_dir` + """ + print(result) + dir = file_dialog.select_folder_finish(result) + thread = threading.Thread(target=lambda: (dir_parser(dir.get_path()))) + thread.daemon = True + thread.start() diff --git a/chronograph/window.py b/chronograph/window.py index 2b603ef..587b8fc 100644 --- a/chronograph/window.py +++ b/chronograph/window.py @@ -1,14 +1,30 @@ from gi.repository import Adw, Gtk # type: ignore from chronograph import shared -from chronograph.ui.SongCard import SongCard -from chronograph.utils.file_mutagen_id3 import FileID3 -from chronograph.utils.file_mutagen_vorbis import FileVorbis +from chronograph.utils.select_data import select_dir @Gtk.Template(resource_path=shared.PREFIX + "/gtk/window.ui") class ChronographWindow(Adw.ApplicationWindow): - """App window class""" + """App window class + + GTK Objects + ---------- + :: + + # Status pages + no_source_opened: Adw.StatusPage -> Status page> displayed when no items in library + + # Library view widgets + navigation_view: Adw.NavigationView -> Main Navigation view + library_nav_page: Adw.NavigationPage -> Library Navigation page + overlay_split_view: Adw.OverlaySplitView -> Split view for Sidebar and Library + search_bar: Gtk.SearchBar -> Search bar + search_entry: Gtk.SearchEntry -> Search field + library_overlay: Gtk.Overlay -> Library overlay + library_scrolled_window: Gtk.ScrolledWindow -> Library scroll possibility + library: Gtk.FlowBox -> Library itself + """ __gtype_name__ = "ChronographWindow" @@ -29,25 +45,18 @@ def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self.search_bar.connect_entry(self.search_entry) - self.library.append( - SongCard( - FileVorbis("/home/dzheremi/Music/Symphony No.6 (1st movement).flac") - ) - ) - - # TODO: This should be implemented in ALL parsers functions - for child in self.library: - child.set_focusable(False) if self.library.get_child_at_index(0) is None: self.library_scrolled_window.set_child(self.no_source_opened) def on_toggle_sidebar_action(self, *_args) -> None: + """Toggles sidebar of `ChronographWindow`""" self.overlay_split_view.set_show_sidebar( not self.overlay_split_view.get_show_sidebar() ) def on_toggle_search_action(self, *_args) -> None: + """Toggles search filed of `ChronographWindow`""" if self.navigation_view.get_visible_page() == self.library_nav_page: search_bar = self.search_bar search_entry = self.search_entry @@ -60,3 +69,7 @@ def on_toggle_search_action(self, *_args) -> None: self.set_focus(search_entry) search_entry.set_text("") + + def on_select_dir_action(self, *_args) -> None: + """Creates directory selection dialog for adding Songs to `self.library`""" + select_dir() diff --git a/data/Chronograph.gresource.xml.in b/data/Chronograph.gresource.xml.in index 85064c8..8b0635c 100644 --- a/data/Chronograph.gresource.xml.in +++ b/data/Chronograph.gresource.xml.in @@ -4,6 +4,7 @@ @APP_ID@.metainfo.xml gtk/window.ui gtk/ui/SongCard.ui + gtk/ui/BoxDialog.ui gtk/css/style.css diff --git a/data/gtk/ui/BoxDialog.blp b/data/gtk/ui/BoxDialog.blp new file mode 100644 index 0000000..7dc1299 --- /dev/null +++ b/data/gtk/ui/BoxDialog.blp @@ -0,0 +1,45 @@ +using Gtk 4.0; +using Adw 1; + +template $BoxDialog : Adw.Dialog { + content-width: 300; + margin-bottom: 20; + + Adw.ToolbarView { + [top] + Adw.HeaderBar {} + + Box { + orientation: vertical; + spacing: 12; + + Label dialog_title_label { + label: "Label"; + ellipsize: end; + + styles ["title-3"] + } + + Adw.Clamp { + orientation: horizontal; + maximum-size: 300; + + ListBox props_list { + selection-mode: none; + margin-start: 12; + margin-end: 12; + + styles ["boxed-list"] + } + } + } + + [bottom] + Separator { + height-request: 12; + vexpand: true; + + styles ["spacer"] + } + } +} \ No newline at end of file diff --git a/data/gtk/window.blp b/data/gtk/window.blp index e625b04..6d267df 100644 --- a/data/gtk/window.blp +++ b/data/gtk/window.blp @@ -77,24 +77,22 @@ template $ChronographWindow : Adw.ApplicationWindow { visible: bind overlay_split_view.show-sidebar inverted; } + MenuButton open_source_button { + icon-name: "open-source-symbolic"; + tooltip-text: _("Select a directory or open a file"); + menu-model: open_source_menu; + } + [start] Revealer left_buttons_revealer { transition-type: none; valign: start; halign: start; - Box { - MenuButton open_source_button { - icon-name: "open-source-symbolic"; - tooltip-text: _("Select a directory or open a file"); - menu-model: open_source_menu; - } - - ToggleButton toggle_search_button { - icon-name: "search-symbolic"; - tooltip-text: _("Toggle search"); - action-name: "win.toggle_search"; - } + ToggleButton toggle_search_button { + icon-name: "search-symbolic"; + tooltip-text: _("Toggle search"); + action-name: "win.toggle_search"; } } } @@ -148,7 +146,7 @@ template $ChronographWindow : Adw.ApplicationWindow { menu open_source_menu { section { - item (_("Directory")) + item (_("Directory"), "win.select_dir") item (_("File")) } } \ No newline at end of file diff --git a/data/io.github.dzheremi2.Chronograph.service.in b/data/io.github.dzheremi2.Chronograph.service.in index eb7d42a..685e6e6 100644 --- a/data/io.github.dzheremi2.Chronograph.service.in +++ b/data/io.github.dzheremi2.Chronograph.service.in @@ -1,3 +1,3 @@ [D-BUS Service] Name=io.github.dzheremi2.Chronograph -Exec=@bindir@/lrcmake --gapplication-service +Exec=@bindir@/chronograph --gapplication-service diff --git a/data/meson.build b/data/meson.build index 8ea82e9..51ad624 100644 --- a/data/meson.build +++ b/data/meson.build @@ -1,7 +1,8 @@ blueprints = custom_target('blueprints', input: files( 'gtk/window.blp', - 'gtk/ui/SongCard.blp' + 'gtk/ui/SongCard.blp', + 'gtk/ui/BoxDialog.blp' ), output: '.', command: [find_program('blueprint-compiler'), 'batch-compile', '@OUTPUT@', '@CURRENT_SOURCE_DIR@', '@INPUT@'],