From fd34476d0087fc7548085e46557d1110f16a6141 Mon Sep 17 00:00:00 2001
From: The Wobbler <emil@i21k.de>
Date: Sat, 8 Mar 2025 17:50:45 +0100
Subject: [PATCH] Added popup on import where the user can configure how the
 tracks get imported.

---
 wobuzz/library/__init__.py            |   7 ++
 wobuzz/library/library.py             |  30 ++++---
 wobuzz/player/playlist.py             |  10 +--
 wobuzz/player/track.py                |  19 +++++
 wobuzz/types/__init__.py              |  15 ++++
 wobuzz/types/import_options.py        |  18 ++++
 wobuzz/types/types.py                 |   9 ++
 wobuzz/ui/custom_widgets/__init__.py  |   8 ++
 wobuzz/ui/custom_widgets/group_box.py |  18 ++++
 wobuzz/ui/library/import_dialog.py    | 116 ++++++++++++++++++++++++++
 wobuzz/ui/popups.py                   |  56 +++++++++++--
 wobuzz/ui/settings/sub_category.py    |  11 +--
 12 files changed, 289 insertions(+), 28 deletions(-)
 create mode 100644 wobuzz/types/__init__.py
 create mode 100644 wobuzz/types/import_options.py
 create mode 100644 wobuzz/types/types.py
 create mode 100644 wobuzz/ui/custom_widgets/__init__.py
 create mode 100644 wobuzz/ui/custom_widgets/group_box.py
 create mode 100644 wobuzz/ui/library/import_dialog.py

diff --git a/wobuzz/library/__init__.py b/wobuzz/library/__init__.py
index a93a4bf..8fc6dc1 100644
--- a/wobuzz/library/__init__.py
+++ b/wobuzz/library/__init__.py
@@ -1 +1,8 @@
 #!/usr/bin/python3
+
+def __getattr__(name):
+    match name:
+        case "Library":
+            from .library import Library
+
+            return Library
diff --git a/wobuzz/library/library.py b/wobuzz/library/library.py
index a59b1f7..56f88f8 100644
--- a/wobuzz/library/library.py
+++ b/wobuzz/library/library.py
@@ -1,12 +1,12 @@
 #!/usr/bin/python3
 
 import os
-import shutil
 from PyQt6.QtWidgets import QTabWidget, QAbstractItemView
 
 from ..player.playlist import Playlist
 from ..ui.library.library import LibraryWidget
 from ..ui.playlist_view import PlaylistView
+from ..types import Types
 
 
 class Library:
@@ -115,15 +115,28 @@ class Library:
 
         playlist.load()
 
-    def import_tracks(self, tracks: list[str]):
-        playlist = Playlist(self.app, "Temporary Playlist", tracks, True)
+    def import_tracks(
+        self,
+        tracks: list[str],
+        import_options: Types.ImportOptions
+    ):
+        playlist = Playlist(self.app, "Temporary Playlist", tracks, import_options)
 
         self.replace_temporary_playlist(playlist)
 
         self.load_playlist_views()
         playlist.load()
 
-    def import_track(self, track):
+    def import_track(self, track, import_options: Types.ImportOptions):
+        if import_options.artist is not None:
+            track.metadata.artist = import_options.artist
+
+        if import_options.album is not None:
+            track.metadata.album = import_options.album
+
+        if import_options.genre is not None:
+            track.metadata.genre = import_options.genre
+
         artist_path = os.path.expanduser(f"{self.app.settings.library_path}/artists/{track.metadata.artist}")
 
         if not os.path.exists(artist_path):
@@ -134,10 +147,7 @@ class Library:
         if track.path == new_track_path or os.path.exists(new_track_path):  # track is already in the library
             return
 
-        shutil.copyfile(track.path, new_track_path)
-
-        track.path = new_track_path
-        track.metadata.path = new_track_path
+        track.copy(new_track_path, import_options.copy_type)
 
     def open_playlist(self, playlist_path: str):
         playlist = Playlist(self.app, "Temporary Playlist", playlist_path)
@@ -148,8 +158,8 @@ class Library:
 
         playlist.load()
 
-    def import_playlist(self, playlist_path: str):
-        playlist = Playlist(self.app, "Temporary Playlist", playlist_path, import_tracks=True)
+    def import_playlist(self, playlist_path: str, import_options):
+        playlist = Playlist(self.app, "Temporary Playlist", playlist_path, import_options)
 
         self.replace_temporary_playlist(playlist)
 
diff --git a/wobuzz/player/playlist.py b/wobuzz/player/playlist.py
index fdc0580..0d5fd2e 100644
--- a/wobuzz/player/playlist.py
+++ b/wobuzz/player/playlist.py
@@ -2,16 +2,16 @@
 
 import os
 import threading
-
 from PyQt6.QtCore import Qt
 from PyQt6.QtWidgets import QAbstractItemView
 
 from .track import Track, TrackMetadata
 from ..wobuzzm3u import WobuzzM3U, WBZM3UData
+from ..types import Types
 
 
 class Playlist:
-    def __init__(self, app, title: str, load_from=None, import_tracks: bool=False):
+    def __init__(self, app, title: str, load_from=None, import_options: Types.ImportOptions=None):
         self.app = app
         self.title = title  # playlist title
 
@@ -20,7 +20,7 @@ class Playlist:
         # if None, playlist should be already in the library and will be loaded from a .wbz.m3u
         self.load_from = load_from
 
-        self.import_tracks = import_tracks
+        self.import_options = import_options
 
         # add to unique names so if the playlist is loaded from disk,
         # no other playlist can be created using the same name
@@ -96,9 +96,9 @@ class Playlist:
 
         self.loading = False
 
-        if self.import_tracks:
+        if self.import_options is not None:
             for track in self.tracks:
-                self.app.library.import_track(track)
+                self.app.library.import_track(track, self.import_options)
 
         for dock_id in self.views:  # enable drag and drop on every view
             view = self.views[dock_id]
diff --git a/wobuzz/player/track.py b/wobuzz/player/track.py
index face0dd..faf0f8b 100644
--- a/wobuzz/player/track.py
+++ b/wobuzz/player/track.py
@@ -1,11 +1,15 @@
 #!/usr/bin/python3
 
+import os
+import shutil
 from pydub import AudioSegment
 from pygame.mixer import Sound
 from tinytag import TinyTag
 from tinytag.tinytag import Images as TTImages
 from dataclasses import dataclass
 
+from ..types import Types
+
 
 @dataclass
 class TrackMetadata:
@@ -163,3 +167,18 @@ class Track:
                 self.items.remove(item)
 
         self.occurrences.pop(playlist)
+
+    def copy(self, dest: str, copy_type: int=Types.CopyType.symlink, moved: bool=True):
+        match copy_type:
+            case Types.CopyType.symlink:
+                os.symlink(self.path, dest)
+
+            case Types.CopyType.copy:
+                shutil.copyfile(self.path, dest)
+
+            case Types.CopyType.move:
+                shutil.move(self.path, dest)
+
+        if moved:  # update path variables
+            self.path = dest
+            self.metadata.path = dest
diff --git a/wobuzz/types/__init__.py b/wobuzz/types/__init__.py
new file mode 100644
index 0000000..052adfa
--- /dev/null
+++ b/wobuzz/types/__init__.py
@@ -0,0 +1,15 @@
+#!/usr/bin/python3
+
+def __getattr__(name):
+    match name:
+        case "Types":
+            from .types import Types
+            return Types
+
+        case "ImportOptions":
+            from .import_options import ImportOptions
+            return ImportOptions
+
+        case "CopyType":
+            from .import_options import CopyType
+            return CopyType
diff --git a/wobuzz/types/import_options.py b/wobuzz/types/import_options.py
new file mode 100644
index 0000000..ceed274
--- /dev/null
+++ b/wobuzz/types/import_options.py
@@ -0,0 +1,18 @@
+#!/usr/bin/python3
+
+from dataclasses import dataclass
+
+
+@dataclass
+class ImportOptions:
+    artist: str=None
+    album: str=None
+    genre: str=None
+
+    copy_type=0
+
+
+class CopyType:
+    symlink = 0
+    copy = 1
+    move = 2
diff --git a/wobuzz/types/types.py b/wobuzz/types/types.py
new file mode 100644
index 0000000..dbbab54
--- /dev/null
+++ b/wobuzz/types/types.py
@@ -0,0 +1,9 @@
+#!/usr/bin/python3
+
+from . import ImportOptions
+from . import CopyType
+
+
+class Types:
+    ImportOptions = ImportOptions
+    CopyType = CopyType
diff --git a/wobuzz/ui/custom_widgets/__init__.py b/wobuzz/ui/custom_widgets/__init__.py
new file mode 100644
index 0000000..6cfdb5a
--- /dev/null
+++ b/wobuzz/ui/custom_widgets/__init__.py
@@ -0,0 +1,8 @@
+#!/usr/bin/python3
+
+
+def __getattr__(name):
+    match name:
+        case "GroupBox":
+            from .group_box import GroupBox
+            return GroupBox
diff --git a/wobuzz/ui/custom_widgets/group_box.py b/wobuzz/ui/custom_widgets/group_box.py
new file mode 100644
index 0000000..8b61620
--- /dev/null
+++ b/wobuzz/ui/custom_widgets/group_box.py
@@ -0,0 +1,18 @@
+#!/usr/bin/python3
+
+from PyQt6.QtCore import Qt
+from PyQt6.QtWidgets import QGroupBox, QSizePolicy
+
+
+class GroupBox(QGroupBox):
+    """
+    Just a QGroupBox with some custom style I don't always want to rewrite.
+    """
+
+    def __init__(self, title, parent=None):
+        super().__init__(title, parent)
+
+        self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
+        self.setAlignment(Qt.AlignmentFlag.AlignLeading | Qt.AlignmentFlag.AlignVCenter)
+
+        self.setStyleSheet("QGroupBox{font-weight: bold;}")
diff --git a/wobuzz/ui/library/import_dialog.py b/wobuzz/ui/library/import_dialog.py
new file mode 100644
index 0000000..e37bfbe
--- /dev/null
+++ b/wobuzz/ui/library/import_dialog.py
@@ -0,0 +1,116 @@
+#!/usr/bin/python3
+
+from PyQt6.QtWidgets import (
+    QWidget,
+    QLabel,
+    QDialog,
+    QCheckBox,
+    QLineEdit,
+    QDialogButtonBox,
+    QButtonGroup,
+    QRadioButton,
+    QVBoxLayout,
+    QHBoxLayout,
+    QFormLayout,
+    QSizePolicy,
+)
+from PyQt6.QtCore import Qt
+
+from ..custom_widgets import GroupBox
+
+
+class ImportDialog(QDialog):
+    def __init__(self):
+        super().__init__()
+
+        self.setWindowTitle("Import")
+
+        layout = QVBoxLayout(self)
+        self.setLayout(layout)
+
+        self.tagging_section = GroupBox("Metadata And Tagging", self)
+        self.tagging_section.layout = QFormLayout(self.tagging_section)
+        self.tagging_section.setLayout(self.tagging_section.layout)
+        layout.addWidget(self.tagging_section)
+
+        self.tagging_section.overwrite_metadata = QCheckBox(
+            "Set custom metadata for all tracks (Leave property blank to keep the metadata from the audio file.)",
+            self.tagging_section
+        )
+        self.tagging_section.layout.addRow(self.tagging_section.overwrite_metadata)
+
+        self.tagging_section.artist = QLineEdit(self.tagging_section)
+        self.tagging_section.layout.addRow("    Artist: ", self.tagging_section.artist)
+        self.tagging_section.artist.setPlaceholderText("Keep track artist")
+
+        self.tagging_section.album = QLineEdit(self.tagging_section)
+        self.tagging_section.layout.addRow("    Album: ", self.tagging_section.album)
+        self.tagging_section.album.setPlaceholderText("Keep track album")
+
+        self.tagging_section.genre = QLineEdit(self.tagging_section)
+        self.tagging_section.layout.addRow("    Genre: ", self.tagging_section.genre)
+        self.tagging_section.genre.setPlaceholderText("Keep track genre")
+
+        self.file_section = GroupBox("File Structure", self)
+        self.file_section.layout = QFormLayout(self.file_section)
+        self.file_section.setLayout(self.file_section.layout)
+        layout.addWidget(self.file_section)
+
+        self.file_section.copy_type_description = QLabel("How should the tracks get put into the Wobuzz library?")
+        self.file_section.layout.addRow(self.file_section.copy_type_description)
+
+        self.file_section.copy_type = QButtonGroup(self.file_section)
+
+        self.file_section.copy_type_symlink = QRadioButton("Create symlinks", self.file_section)
+        self.file_section.copy_type_symlink.setChecked(True)
+        self.file_section.copy_type.addButton(self.file_section.copy_type_symlink)
+        self.file_section.layout.addWidget(self.file_section.copy_type_symlink)
+
+        self.file_section.copy_type_copy = QRadioButton("Copy tracks", self.file_section)
+        self.file_section.copy_type.addButton(self.file_section.copy_type_copy)
+        self.file_section.layout.addWidget(self.file_section.copy_type_copy)
+
+        self.file_section.copy_type_move = QRadioButton("Move tracks", self.file_section)
+        self.file_section.copy_type.addButton(self.file_section.copy_type_move)
+        self.file_section.layout.addWidget(self.file_section.copy_type_move)
+
+        # add expanding widget so the GroupBoxes aren't vertically centered
+        spacer_widget = QWidget(self)
+        spacer_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+        layout.addWidget(spacer_widget)
+
+        dialog_buttons = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
+
+        self.dialog_buttons = QDialogButtonBox(dialog_buttons)
+        layout.addWidget(self.dialog_buttons)
+
+        self.reset_inputs()
+
+        self.tagging_section.overwrite_metadata.stateChanged.connect(self.overwrite_state_changed)
+        self.dialog_buttons.accepted.connect(self.accept)
+        self.dialog_buttons.rejected.connect(self.reject)
+
+    def exec(self):
+        self.reset_inputs()
+
+        return super().exec()
+
+    def overwrite_state_changed(self, state: int):
+        overwrite = state == 2
+
+        self.tagging_section.artist.setEnabled(overwrite)
+        self.tagging_section.album.setEnabled(overwrite)
+        self.tagging_section.genre.setEnabled(overwrite)
+
+        self.tagging_section.layout.setRowVisible(self.tagging_section.artist, overwrite)
+        self.tagging_section.layout.setRowVisible(self.tagging_section.album, overwrite)
+        self.tagging_section.layout.setRowVisible(self.tagging_section.genre, overwrite)
+
+    def reset_inputs(self):
+        self.overwrite_state_changed(0)
+
+        self.tagging_section.artist.setText("")
+        self.tagging_section.album.setText("")
+        self.tagging_section.genre.setText("")
+
+        self.file_section.copy_type_symlink.setChecked(True)
diff --git a/wobuzz/ui/popups.py b/wobuzz/ui/popups.py
index 324e53f..7b7ec13 100644
--- a/wobuzz/ui/popups.py
+++ b/wobuzz/ui/popups.py
@@ -1,6 +1,9 @@
 #!/usr/bin/python3
 
-from PyQt6.QtWidgets import QFileDialog
+from PyQt6.QtWidgets import QDialog, QFileDialog
+
+from .library.import_dialog import ImportDialog
+from ..types import Types
 
 
 class Popups:
@@ -12,7 +15,7 @@ class Popups:
 
         self.audio_file_selector = QFileDialog(self.window, "Select Audio Files")
         self.audio_file_selector.setFileMode(QFileDialog.FileMode.ExistingFiles)
-        self.audio_file_selector.setNameFilters(["Audio Files (*.flac *.wav *.mp3 *.ogg *.opus)", "Any (*)"])
+        self.audio_file_selector.setNameFilters(["Audio Files (*.flac *.wav *.mp3 *.ogg *.opus *.m4a)", "Any (*)"])
         self.audio_file_selector.setViewMode(QFileDialog.ViewMode.List)
 
         self.playlist_file_selector = QFileDialog(self.window, "Select Playlist")
@@ -20,6 +23,8 @@ class Popups:
         self.playlist_file_selector.setNameFilters(["Playlists (*.wbz.m3u *.m3u)", "Any (*)"])
         self.playlist_file_selector.setViewMode(QFileDialog.ViewMode.List)
 
+        self.import_dialog = ImportDialog()
+
         self.window.open_track_action.triggered.connect(self.open_tracks)
         self.window.import_track_action.triggered.connect(self.import_tracks)
         self.window.open_playlist_action.triggered.connect(self.open_playlist)
@@ -39,11 +44,43 @@ class Popups:
         if files is not None and not files == []:
             self.app.library.open_tracks(files)
 
+    def get_import_options(self):
+        import_options = Types.ImportOptions()
+
+        if self.import_dialog.tagging_section.overwrite_metadata.isChecked():
+            artist = self.import_dialog.tagging_section.artist.text()
+            album = self.import_dialog.tagging_section.album.text()
+            genre = self.import_dialog.tagging_section.genre.text()
+
+            if not artist == "":
+                import_options.artist = artist
+
+            if not album == "":
+                import_options.album = album
+
+            if not genre == "":
+                import_options.genre = genre
+
+        if self.import_dialog.file_section.copy_type_copy.isChecked():
+            import_options.copy_type = Types.CopyType.copy
+
+        elif self.import_dialog.file_section.copy_type_move.isChecked():
+            import_options.copy_type = Types.CopyType.move
+
+        return import_options
+
     def import_tracks(self):
         files = self.select_audio_files()
 
-        if files is not None and not files == []:
-            self.app.library.import_tracks(files)
+        if files is None or files == []:
+            return
+
+        if self.import_dialog.exec() == QDialog.rejected:
+            return
+
+        import_options = self.get_import_options()
+
+        self.app.library.import_tracks(files, import_options)
 
     def open_playlist(self):
         playlist_path = self.select_playlist_file()
@@ -54,5 +91,12 @@ class Popups:
     def import_playlist(self):
         playlist_path = self.select_playlist_file()
 
-        if playlist_path is not None and not playlist_path == "":
-            self.app.library.import_playlist(playlist_path)
+        if playlist_path is None or playlist_path == "":
+            return
+
+        if self.import_dialog.exec() == QDialog.rejected:
+            return
+
+        import_options = self.get_import_options()
+
+        self.app.library.import_playlist(playlist_path, import_options)
diff --git a/wobuzz/ui/settings/sub_category.py b/wobuzz/ui/settings/sub_category.py
index 694b682..6453b78 100644
--- a/wobuzz/ui/settings/sub_category.py
+++ b/wobuzz/ui/settings/sub_category.py
@@ -2,21 +2,18 @@
 
 from PyQt6.QtCore import Qt
 from PyQt6.QtGui import QFont
-from PyQt6.QtWidgets import QGroupBox, QLabel, QSizePolicy, QFormLayout
+from PyQt6.QtWidgets import QLabel, QSizePolicy, QFormLayout
+
+from ..custom_widgets import GroupBox
 
 
-class SubCategory(QGroupBox):
+class SubCategory(GroupBox):
     description_font = QFont()
     description_font.setPointSize(8)
 
     def __init__(self, title: str, description: str=None, parent=None):
         super().__init__(title, parent)
 
-        self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
-        self.setAlignment(Qt.AlignmentFlag.AlignLeading | Qt.AlignmentFlag.AlignVCenter)
-
-        self.setStyleSheet("QGroupBox{font-weight: bold;}")
-
         self.layout = QFormLayout()
         self.setLayout(self.layout)