commit a08c62a9766d12ac0a914329922d5a3dc32d215b Author: Alex Date: Mon Apr 15 13:21:43 2024 +0000 Upload diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a12e657 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__ +thumbnails +scan.json +config.ini \ No newline at end of file diff --git a/__main__.py b/__main__.py new file mode 100644 index 0000000..95bf67a --- /dev/null +++ b/__main__.py @@ -0,0 +1,33 @@ +import pip, sys, os + +# check if PyQt6 is installed +print('### Checking install of PyQt6 ###') +try: + from PyQt6 import QtCore, QtGui, QtWidgets + print("### Check passed ###") +except ImportError: + print("PyQt6 is not installed. Installing PyQt6...") + pip.main(['install', 'PyQt6']) + print("### PyQt6 installed ###") + +from PyQt6.QtWidgets import QApplication +import traceback, configparser + +from main_window import MainWindow + +def folderCheck(name): + if not os.path.exists(name): + os.makedirs(name) + +if __name__ == '__main__': + folderCheck("thumbnails") + + # configparser + try: + app = QApplication([]) + window = MainWindow() + window.show() + app.exec() + except Exception as e: + print("### Init Error ###") + traceback.print_exc() \ No newline at end of file diff --git a/action_runner.py b/action_runner.py new file mode 100644 index 0000000..bbdd04e --- /dev/null +++ b/action_runner.py @@ -0,0 +1,69 @@ +from PyQt6.QtWidgets import ( + QDialog, QLabel, QFrame, + QVBoxLayout +) +from PyQt6.QtCore import Qt +import subprocess, traceback, threading + +class RunningDialog(QDialog): + def __init__(self, parent): + super().__init__(parent) + self.hide() + self.setObjectName("runningdialog") + self.setWindowTitle("Running") + # always on top + self.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True) + + self.setFixedSize(600, 1) + # disable resizing + self.setFixedSize(self.size()) + # disable close button + self.setWindowFlag(Qt.WindowType.WindowCloseButtonHint, False) + self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False) + +class ActionRunner: + def __init__(self, command: str, args: list[str] = []): + self.command = command + self.args = args + + def run(self): + running_dialog = RunningDialog(None) + running_dialog.show() + running_dialog.setWindowTitle("Running: " + self.command + " " + " ".join(self.args) ) + try: + process=subprocess.Popen( + " ".join([self.command] + self.args), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=True + ) + output = process.stdout.readlines() + err = process.stderr.readlines() + + if output == []: + print("Output:", output) + print("err:") + for e in err: + print(e.decode("utf-8")) + raise Exception("Error while invoking command: " + self.command + " " + " ".join(self.args)) + + lines = [] + for line in output: + l = line.decode("utf-8") + lines.append(l) + + result = "".join(lines) + + # remove last newline + if result.endswith("\n"): + result = result[:-1] + + running_dialog.close() + return result + except Exception as e: + running_dialog.close() + print("### ActionRunner Error ###") + print(e) + traceback.print_exc() + return None \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..050a677 --- /dev/null +++ b/config.py @@ -0,0 +1,31 @@ +import json, os + +class configManager(): + def __init__(self): + print("Loading config...") + self.config = { + "config_version": 1, + "music_folder": "C:/Users/username/Music", + } + + def setConfigTemplate(self, template): + self.config = template + + def load_config(self): + # load config + if os.path.exists("config.json"): + with open("config.json", "r") as config_file: + self.config = json.load(config_file) + else: + self.save_config() + + def save_config(self): + # save config + with open("config.json", "w") as config_file: + json.dump(self.config, config_file) + + def get(self, key): + return self.config.get(key, None) + + def set(self, key, value): + self.config[key] = value \ No newline at end of file diff --git a/default.png b/default.png new file mode 100644 index 0000000..5f231f1 Binary files /dev/null and b/default.png differ diff --git a/dialogs.py b/dialogs.py new file mode 100644 index 0000000..e24ac6d --- /dev/null +++ b/dialogs.py @@ -0,0 +1,112 @@ +from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QLabel, QPushButton, + QWidget, QFrame, QSplitter, QStackedWidget, QListWidget, QListWidgetItem, + QGraphicsScene, QGraphicsView, QGraphicsPixmapItem, + + QLayout, QScrollArea, QVBoxLayout, QHBoxLayout, + + QDialog, QFileDialog +) +from PyQt6.QtCore import Qt + +class SimpleHeader(QFrame): + def __init__(self, parent) -> None: + super().__init__(parent) + + self.setObjectName("SimpleHeader") + self.setStyleSheet("#SimpleHeader { border: 1px solid #00000000; border-bottom-color: #444; }") + + self.parent = parent + self.setFixedHeight(24) + self.label = QLabel("Simple Header", self) + self.label.move(12, 0) + self.label.resize( self.width() - 24, 24) + + def resizeEvent(self, event): + self.label.resize( self.width() - 24, 24) + self.resize(self.parent.width(), 24 ) + + def setLabel(self, text): + self.label.setText(text) + +class ErrorPopup(QDialog): + def __init__(self, parent, message, trace, CanIgnore=True, buttonLayout=0) -> None: + super().__init__(parent) + self.parent = parent + self.message = message + self.trace = trace + self.canTerminate = CanIgnore + self.setWindowTitle("Error") + self.setGeometry(000, 150, 600, 300) + # reset styles + # self.setStyleSheet("") + # self.setWindowFlags(Qt.WindowType.WindowStaysOnTopHint) + self.setWindowModality(Qt.WindowModality.WindowModal) + + self.header = SimpleHeader(self) + self.header.setLabel(self.message) + self.header.move(0, 0) + + # self.label = QLabel(self.message, self) + # self.label.move(12, 0) + # self.label.resize( self.width() - 24, 24) + + self.trace = QLabel(self.trace, self) + self.trace.move(12, 24) + self.trace.resize( self.width() - 24, 24) + + match buttonLayout: + case 0: + pass + self.ignore = QPushButton(CanIgnore and "Ignore" or "Critical Error", self) + self.ignore.move(12, self.height() - 36) + self.ignore.resize( self.width() - 24, 24) + self.ignore.clicked.connect(self.close) + self.ignore.setDisabled(not CanIgnore) + + self.btn_terminate = QPushButton("Terminate", self) + self.btn_terminate.move(12, self.height() - 60) + self.btn_terminate.resize( self.width() - 24, 24) + self.btn_terminate.clicked.connect(self.terminate) + + def resizeEvent(self, event): + # self.move(int(self.parent.width()/2) - int(self.width()/2), int(self.parent.height()/2) - int(self.height()/2)) + self.header.resize( self.width(), 24) + self.trace.resize( self.width() - 24, self.height() - 60) + self.ignore.move(12, self.height() - 36) + self.ignore.resize( int(self.width()/2) - 24, 24) + self.btn_terminate.move(int(self.width()/2) + 12, self.height() - 36) + self.btn_terminate.resize( int(self.width()/2) - 24, 24) + + def close(self): + super().close() + + def terminate(self): + self.parent.close() + super().close() + exit(1) + + # on window close + def closeEvent(self, event): + if self.canTerminate: + self.close() + else: + self.terminate() + +class ListDialog(QDialog): + def __init__(self, parent = None, dialogHeader = None, header = None, data = None): + super(ListDialog, self).__init__(parent) + self.setWindowTitle(dialogHeader) + layout = QVBoxLayout() + layout.addWidget(QLabel(header)) + # scroll area + scroll = QScrollArea() + widget = QWidget() + widget.setLayout(QVBoxLayout()) + layout.addWidget(scroll) + self.setLayout(layout) + for item in data: + widget.layout().addWidget(QLabel(item)) + scroll.setWidget(widget) + self.exec() + diff --git a/events.py b/events.py new file mode 100644 index 0000000..de81b21 --- /dev/null +++ b/events.py @@ -0,0 +1,26 @@ +class Event(): + def __init__(self): + self.__listeners = [] + + @property + def on(self, event, callback): + self.__listeners.append({event: callback}) + + def emit(self, event, data): + for listener in self.__listeners: + if event in listener: + # is this a awaitable? + if hasattr(listener[event], "__await__"): + listener[event](data) + else: + listener[event](data) + + def addListener(self, event, callback): + if callback in self.__listeners: + return + self.__listeners.append({event: callback}) + + def removeListener(self, event, callback): + if callback not in self.__listeners: + return + self.__listeners.remove({event: callback}) \ No newline at end of file diff --git a/extractor.py b/extractor.py new file mode 100644 index 0000000..0023fce --- /dev/null +++ b/extractor.py @@ -0,0 +1,176 @@ +import os, json +import traceback + +def escape_file_name(fname): + """ + Helper function to safely convert the file name (a.k.a. escaping) with + spaces which can cause issues when passing over to the command line. + """ + result = fname.replace('\"', '\\"') # escapes double quotes first + result = ['"',result,'"'] + return "".join(result) + + +def BytesToReadable(bytes): + sizes = ["B", "KB", "MB", "GB", "TB"] + i = 0 + while bytes > 1024: + bytes = bytes / 1024 + i += 1 + return f"{bytes:.2f} {sizes[i]}" + +def get_file_size(file_path): + """ + Returns the size of the file in bytes. + """ + return os.path.getsize(file_path) + +import subprocess + +def CheckFFPROBE(): + try: + process=subprocess.Popen( + " ".join(["ffprobe", "-version"]), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=True + ) + + output = process.stdout.readlines() + err = process.stderr.readlines() + + if output == []: + err_msg = "".join(err) + raise Exception("Error while invoking ffprobe:\n" + err_msg) + + return True + except Exception as e: + return False + except: + pass + + return +def CheckFFMPEG(): + try: + process=subprocess.Popen( + " ".join(["ffmpeg", "-version"]), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=True + ) + + output = process.stdout.readlines() + err = process.stderr.readlines() + + if output == []: + err_msg = "".join(err) + raise Exception("Error while invoking ffmpeg:\n" + err_msg) + + return True + except Exception as e: + return False + except: + pass + + return + +class ExtractorException(Exception): + def __init__(self, errors): + self.errors = errors + + def __str__(self): + return repr(self.errors) + +def ExtractMetadata(file): + print("Extracting Metadata: ", file) + + AdvancedData = { + "file:" : file, + "data": "Unknown", + } + + test = CheckFFPROBE() + if not test: + print("### FFPROBE: Check failed ###") + e = ExtractorException("FFPROBE not found or not installed.") + raise e + + try: + escaped_fpath = escape_file_name(file) + process=subprocess.Popen( + " ".join(["ffprobe", + "-show_entries format_tags", + "-print_format json", + escaped_fpath],), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=True + ) + + output = process.stdout.readlines() + err = process.stderr.readlines() + + if output == []: + err_msg = "".join(err) + raise Exception("Error while invoking ffprobe: " + err_msg) + + ffprobe_json = [] + skip_line = True + # cleanse any additional information that is not valid JSON + for line in output: + # string out b' and \r\n + line = line.decode("utf-8") + if line.strip() == '{': + skip_line = False + if not skip_line: + ffprobe_json.append(line) + + result = json.loads("".join(ffprobe_json)) + return result + + except Exception as e: + print("### Extractor Error ###") + print(e) + except: + pass + + return AdvancedData + +def ExtractMediaData(file, output_file): + """ + Extracts media data from a file using ffmpeg. + """ + + try: + escaped_fpath = escape_file_name(file) + # get the absolute path to the output file + current_dir = os.getcwd() + absout = os.path.join(current_dir, output_file) + + if os.path.exists(absout): + return absout + + print("Extracting Media Data: ", file) + + subprocess.Popen( + " ".join(["ffmpeg", + "-i", + escaped_fpath, + "-an", + "-vcodec", + "copy", + f'"{absout}"'],), # output file should be in quotes to avoid spaces in the path 'E:\Downloads\4kvideodownloader\video.mp4 + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=True + ) + return absout + except Exception as e: + print("### Extractor Error ###") + traceback.print_exc() + except: + pass diff --git a/main_window.py b/main_window.py new file mode 100644 index 0000000..4d886f9 --- /dev/null +++ b/main_window.py @@ -0,0 +1,33 @@ +from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QLabel, QVBoxLayout, + QWidget, QFrame, QSplitter, QStackedWidget, QTabWidget +) + +from tab_musiclibrary import MusicLibraryTab +from tab_settings import SettingsTab + +class MusicLibrary(QFrame): + def __init__(self): + super().__init__() + + library = QTabWidget() + library.addTab(MusicLibraryTab(), "Songs") + library.addTab(SettingsTab(), "Settings") + + layout = QVBoxLayout() + layout.addWidget(library) + self.setLayout(layout) + + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("My App") + self.setFixedSize(800, 600) + layout = QVBoxLayout() + layout.addWidget( MusicLibrary() ) + container = QWidget() + container.setLayout(layout) + container.layout().setContentsMargins(0, 0, 0, 0) + self.setCentralWidget(container) + diff --git a/settings_widgets.py b/settings_widgets.py new file mode 100644 index 0000000..c01d345 --- /dev/null +++ b/settings_widgets.py @@ -0,0 +1,99 @@ +import traceback +from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QLabel, QPushButton, + QWidget, QFrame, QSplitter, QStackedWidget, QListWidget, QListWidgetItem, + QGraphicsScene, QGraphicsView, QGraphicsPixmapItem, + + QLayout, QScrollArea, QVBoxLayout, QHBoxLayout, + + QDialog, QFileDialog +) +from PyQt6.QtGui import QPixmap +from PyQt6.QtCore import Qt, QAbstractListModel + +from dialogs import ErrorPopup, ListDialog + +import os, sys, json + +# settings widgets + +class SettingBox(QFrame): + def __init__(self, parent, name, description = None): + super().__init__(parent) + self.setObjectName("settingbox") + self.name = name + + self.layout = QVBoxLayout() + self.layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(self.layout) + + self.label = QLabel(name, self) + self.label.setObjectName("settinglabel") + self.layout.addWidget(self.label) + + if description: + self.description = QLabel(description, self) + self.layout.addWidget(self.description) + + def AddSetting(self, setting): + self.layout.addWidget(setting) + +class SettingButton(QFrame): + def __init__(self, parent, text, description = None): + super().__init__(parent) + self.setObjectName("settingbutton") + self.description = None + + self.layout = QHBoxLayout() + self.layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(self.layout) + + self.button = QPushButton("Button", self) + self.layout.addWidget(self.button) + self.button.setFixedWidth(100) + + self.setting_info_base = QWidget() + self.setting_info = QVBoxLayout(self.setting_info_base) + self.setting_info.setContentsMargins(0, 0, 0, 0) + self.layout.addWidget(self.setting_info_base) + + self.label = QLabel(text, self) + self.label.setObjectName("settinglabel") + self.setting_info.addWidget(self.label) + + if description: + self.description = QLabel(description, self) + self.setting_info.addWidget(self.description) + + def SetText(self, text): + self.label.setText(text) + + def SetDescription(self, text): + # check the type of the description + if type(text) == bool: + text = str(text) + if type(text) == list: + text = "\n".join(text) + if type(text) == dict: + text = json.dumps(text, indent=4) + if type(text) == traceback.FrameSummary: + text = traceback.format_tb(text) + + if not self.description: + self.description = QLabel(text, self) + self.setting_info.addWidget(self.description) + + if self.description: + self.description.setText(text) + + def GetButton(self): + return self.button + + def SetButtonText(self, text): + self.button.setText(text) + + def SetButtonPressed(self, func): + self.button.clicked.connect(func) + + def SetButtonWidth(self, width): + self.button.setFixedWidth(width) diff --git a/tab_musiclibrary.py b/tab_musiclibrary.py new file mode 100644 index 0000000..cccd044 --- /dev/null +++ b/tab_musiclibrary.py @@ -0,0 +1,301 @@ +import traceback +from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QLabel, QPushButton, QLineEdit, + QWidget, QFrame, QSplitter, QStackedWidget, QListWidget, QListWidgetItem, + QGraphicsScene, QGraphicsView, QGraphicsPixmapItem, + + QLayout, QScrollArea, QVBoxLayout, QHBoxLayout, + + QDialog, QFileDialog +) +from PyQt6.QtGui import QPixmap +from PyQt6.QtCore import Qt, QAbstractListModel + +from dialogs import ErrorPopup, ListDialog + +import os, sys, json, time +from extractor import ExtractMetadata, ExtractMediaData +from action_runner import RunningDialog + +def calculateAspectRatio(pixmap, width, height, aspectRatioMode): + return pixmap.scaled(width, height, aspectRatioMode) +# E:\Downloads\4kvideodownloader + +class FolderScanner: + def __init__(self, path): + self.path = path + + def scan(self): + data = [] + + if not os.path.exists(self.path): + print("Path does not exist: ", self.path) + return data + + for root, dirs, files in os.walk(self.path): + for file in files: + if file.endswith(".mp3"): + p = os.path.join(root, file) + metadata = ExtractMetadata(p) + # debug.json + # with open(f"{file}.debug.json", "w") as f: + # json.dump(metadata, f, indent=4) + + data.append(metadata) + metadata.update({ + "file:": p + }) + if "data" not in data: + metadata.update({ + "thumbnail": self.ExtractImageData(p, f"thumbnails\\{file}.png"), + }) + + return data + def ExtractImageData(self, file, output): + try: + return ExtractMediaData(file, output) + except Exception as e: + print("### ExtractImageData Error ###") + traceback.print_exc() + + pass + +class MusicModel(QAbstractListModel): + def __init__(self, data): + super().__init__() + self._data = data + + def rowCount(self, parent): + return len(self._data) + + def data(self, index, role): + if role == Qt.ItemDataRole.DisplayRole: + return self._data[index.row()] + +class MusicModelWidget(QFrame): + def __init__(self, parent = None, name = None, path = None): + super(MusicModelWidget, self).__init__(parent) + self.setObjectName(name) + layout = QHBoxLayout() + self.image = image( "default.png" ) + self.image.setFixedSize(64, 64) + iteminfo_base = QWidget() + iteminfo = QVBoxLayout(iteminfo_base) + iteminfo.setSpacing(0) + self.itemname = QLabel(name) + self.itemname.setObjectName("itemname") + self.artist = QLabel("Artist") + iteminfo.addWidget(self.itemname) + iteminfo.addWidget(self.artist) + + layout.addWidget(self.image) + layout.addWidget(iteminfo_base) + + self.setStyleSheet("#itemname { font-size: 16px; }") + self.setLayout(layout) + + def openFile(self): + os.startfile(self.data["format"]["filename"]) + + +class image(QFrame): + def __init__(self, path = None): + super().__init__() + if path is None: + path = "default.png" + + self.setObjectName("Logo") + self.setFrameShadow(QFrame.Shadow.Raised) + self.setFrameShape(QFrame.Shape.StyledPanel) + + self.sceene = QGraphicsScene(self) + self.view = QGraphicsView(self.sceene, self) + self.view.setFixedSize(self.width(), self.height()) + self.view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.view.move(0, 0) + # remove border + self.view.setLineWidth(0) + self.view.setMidLineWidth(0) + self.view.setFrameShadow(QFrame.Shadow.Plain) + self.view.setFrameShape(QFrame.Shape.NoFrame) + self.view.setStyleSheet("background-color: transparent;") + + self.pixmap = None + self.pixmap = QGraphicsPixmapItem() + self.sceene.addItem(self.pixmap) + + try: + self.pixmap.setPixmap(QPixmap(path).scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio)) + except Exception as e: + print("### Init Error ###") + traceback.print_exc() + + def loadimage(self, path): + try: + print("Setting image: ", path) + self.pixmap.setPixmap(QPixmap(path).scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio)) + except Exception as e: + print("### Load Image Error ###") + traceback.print_exc() + + def resizeEvent(self, event): + self.view.setFixedSize(self.width(), self.height()) + calculateAspectRatio(self.pixmap.pixmap(), self.width(), self.height(), Qt.AspectRatioMode.KeepAspectRatio) + +class MusicItem: + def __init__(self): + self.data = None + self.image = None + + self.title = None + self.artist = None + self.imagepath = None + + def setupData(self, ffprobeJsonData): + self.data = ffprobeJsonData + data_format = self.data["format"] + # get title from the tags + if "tags" in data_format: + if "title" in data_format["tags"]: + self.title = data_format["tags"]["title"] + if "artist" in data_format["tags"]: + self.artist = data_format["tags"]["artist"] + + if "thumbnail" in self.data: + self.image = self.data["thumbnail"] + + def getImage(self): + return self.image + + def openFile(self): + try: + os.startfile(self.data['file:']) + except Exception as e: + print("Unable to open file: ", e) + traceback.print_exc() + + +class MusicLibraryTab(QFrame): + def __init__(self): + super().__init__() + + self.list = QListWidget() + self.scanpathInput = QLineEdit() + self.scanpathInput.setPlaceholderText("Path to scan") + self.scanbutton = QPushButton("Scan") + self.scanbutton.clicked.connect(self.scan) + + self.list.setObjectName("list") + self.list.setLineWidth(2) + self.list.setMidLineWidth(2) + self.list.setFrameShadow(QFrame.Shadow.Raised) + self.list.setFrameShape(QFrame.Shape.StyledPanel) + + self.list.doubleClicked.connect(lambda: self.list.currentItem().data(0).openFile()) + + scan_base = QWidget() + scan_layout = QHBoxLayout(scan_base) + scan_layout.setSpacing(0) + scan_layout.setContentsMargins(0, 0, 0, 0) + scan_layout.addWidget(self.scanbutton) + scan_layout.addWidget(self.scanpathInput) + + layout = QVBoxLayout() + layout.setSpacing(0) + layout.addWidget(scan_base) + layout.addWidget(self.list) + self.setLayout(layout) + + # check if scan.json exists + if os.path.exists("scan.json"): + self.scan("scan.json") + + def scan(self, file = None): + path = self.scanpathInput.text() + musicscanner = FolderScanner(path) + runningDialog = RunningDialog(None) + + try: + # clear list + self.list.clear() + runningDialog.show() + runningDialog.setWindowTitle("Scanning: " + path) + scandate = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + if not file: + # formated date time + scandate = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + scan = musicscanner.scan() + else: + loadeddata = json.load(open(file))["scan"] + scan = loadeddata + + unknownData = False + unknownFiles = [] + + # debug.scan.json + with open("scan.json", "w") as f: + settings={ + "path": self.scanpathInput.text(), + "scanned": scandate + } + finaldata ={ + "settings": settings, + "scan": scan + } + json.dump(finaldata, f, indent=4) + + + # check if any of the data is unknown + for data in scan: + # does data key exist + if "data" in data: + # is data key unknown + if data["data"] == "Unknown": + print("Unknown data: ", data["file:"]) + unknownData = True + unknownFiles.append(data["file:"]) + + # + music = MusicItem() + try: + music.setupData(data) + except Exception as e: + print("Skipping setup data: ", e) + item = QListWidgetItem(self.list) + item.setData(0, music) + item_frame = MusicModelWidget(self.list, music.title) + if music.image: + runningDialog.setWindowTitle("Loading Image: " + music.image) + item_frame.image.loadimage(music.image) + item.setSizeHint(item_frame.sizeHint()) + if music.artist: + item_frame.artist.setText(music.artist) + else: + item_frame.artist.setText("Unknown Artist") + + + # double click + # item_frame.itemname.mouseDoubleClickEvent = os.startfile(data["format"]["filename"]) + + self.list.addItem(item) + self.list.setItemWidget(item, item_frame) + + if unknownData: + ListDialog(None, "Unknown Files", "Some files metadata could not be extracted", unknownFiles) + + runningDialog.close() + except Exception as e: + runningDialog.close() + traceback.print_exc() + try: + ErrorPopup(self, str(e), str(traceback.format_exc( + limit=1, chain=True + ))).exec() + except Exception as e: + print("Unable to show error popup: ", e) + + pass + + def resizeEvent(self, event): + self.list.resize(self.width(), self.height()) diff --git a/tab_settings.py b/tab_settings.py new file mode 100644 index 0000000..c985205 --- /dev/null +++ b/tab_settings.py @@ -0,0 +1,231 @@ +import traceback, webbrowser +from PyQt6.QtWidgets import ( + QLabel, QPushButton, QWidget, QFrame, + QSplitter, QStackedWidget, QListWidget, QListWidgetItem, + QGraphicsScene, QGraphicsView, QGraphicsPixmapItem, + + QLayout, QScrollArea, QVBoxLayout, QHBoxLayout, +) +from PyQt6.QtGui import QPixmap +from PyQt6.QtCore import Qt, QAbstractListModel + +from dialogs import ErrorPopup, ListDialog +from settings_widgets import SettingBox, SettingButton +from extractor import CheckFFPROBE + +import os, sys, json +from action_runner import ActionRunner, RunningDialog + +class SidebarPage(QFrame): + def __init__(self, name): + super().__init__() + self.name = name + self.setObjectName("SidebarPage") + self.setStyleSheet("#SidebarPage { border: 1px solid #00000000; border-left-color: #444; }") + + self.layout = QVBoxLayout() + self.layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(self.layout) + + def AddWidget(self, widget) -> None: + self.layout.addWidget(widget) + + def AddLayout(self, layout) -> None: + self.layout.addLayout(layout) + + def AddSpacer(self) -> None: + self.layout.addStretch(1) + +class SidebarButton(QPushButton): + def __init__(self, text, parent): + super().__init__(text, parent) + self.defaultstyle = "border: 1px solid #00000000; padding: 4px 8px; color: #000; text-align: left;" + self.setObjectName("SidebarButton") + self.setStyleSheet("#SidebarButton { " + self.defaultstyle + " background-color: #00000000; }") + + def SetSelected(self, selected): + if selected: + self.setStyleSheet("#SidebarButton { " + self.defaultstyle + " background-color: #444; color: #fff; }") + else: + self.setStyleSheet("#SidebarButton { " + self.defaultstyle + " background-color: #00000000; }") + +class Sidebar(QFrame): + def __init__(self): + super().__init__() + self.pages = [] + + self.setObjectName("Sidebar") + + self.mainlayout = QHBoxLayout() + self.mainlayout.setContentsMargins(0, 0, 0, 0) + self.setLayout(self.mainlayout) + + self.left_base = QWidget() + self.left_base.setFixedWidth(200) + self.left = QVBoxLayout(self.left_base) + # gap between buttons : 0px + self.left.setSpacing(0) + + self.left.setAlignment(Qt.AlignmentFlag.AlignTop) + self.left.setContentsMargins(0, 0, 0, 0) + + self.right_base = QWidget() + self.right = QVBoxLayout(self.right_base) + self.right.setAlignment(Qt.AlignmentFlag.AlignTop) + self.right.setContentsMargins(0, 0, 0, 0) + + self.mainlayout.addWidget(self.left_base) + self.mainlayout.addWidget(self.right_base) + + def swisthPage(self, index) -> QFrame: + self.showPage(self.pages[index]) + return self.pages[index] + + def AddButton(self, text) -> QPushButton: + button = SidebarButton(text, self) + self.left.addWidget(button) + return button + + def showPage(self, page) -> None: + for p in self.pages: + p.hide() + + # get buttons + for b in self.left_base.children(): + if isinstance(b, QPushButton): + b.SetSelected(False) + if b.text() == page.name: + b.SetSelected(True) + page.show() + + def AddPage(self, name, page) -> None: + # hide all pages + button = self.AddButton(name) + button.clicked.connect(lambda: self.showPage(page)) + + for p in self.pages: + p.hide() + + self.right.addWidget(page) + self.pages.append(page) + + return page + +class subpage_general(QFrame): + def __init__(self): + super().__init__() + + self.layout = QVBoxLayout() + self.setLayout(self.layout) + + self.setStyleSheet( + "#subpage_general_title { font-size: 24px; font-weight: bold; }" + ) + + header = QLabel("General") + header.setObjectName("subpage_general_title") + + self.layout.addWidget(header) + self.layout.addWidget(QLabel("General settings for the application.")) + + # settings + self.layout.addWidget(SettingBox(self, "No general settings available.")) + +class subpage_requirements(QFrame): + def __init__(self): + super().__init__() + + self.layout = QVBoxLayout() + self.setLayout(self.layout) + + self.setStyleSheet( + "#subpage_requirements_title { font-size: 24px; font-weight: bold; }" + ) + + header = QLabel("Requirements") + header.setObjectName("subpage_requirements_title") + + self.layout.addWidget(header) + self.layout.addWidget(QLabel("Important applications and libraries required for the application to function.")) + + + setting_echo_test = SettingButton(self, "Echo Test", "Test the echo command.") + setting_update_winget = SettingButton(self, "Manual download and install", "Download and install winget manually.") + setting_install_ffprobe = SettingButton(self, "Install FFMPEG", "Install or update FFMPEG ( includes FFPROBE ) for media processing.") + self.layout.addWidget(setting_echo_test) + self.layout.addWidget(setting_update_winget) + self.layout.addWidget(setting_install_ffprobe) + + if not CheckFFPROBE(): + setting_install_ffprobe.SetText("Required: Install FFMPEG") + + setting_echo_test.SetButtonText("Run action") + echo_test = ActionRunner("echo", ["Hello World!"]) + + ffprobe_installer = ActionRunner("winget", ["install", "-e", "--id", "Gyan.FFmpeg"]) + def echo_test_button(): + run = echo_test.run() + setting_echo_test.SetText("Echo Test Complete") + setting_echo_test.SetDescription(run) + + + setting_echo_test.SetButtonPressed(echo_test_button) + + setting_update_winget.SetButtonText("Start download") + def update_winget(): + # https://aka.ms/getwinget + webbrowser.open("https://aka.ms/getwinget") + + setting_update_winget.SetButtonPressed(update_winget) + + setting_install_ffprobe.SetButtonText("Run action") + def install_ffprobe(): + installed = CheckFFPROBE() + print("Status:", installed) + print(installed and "FFPROBE Upadting." or "FFPROBE Installing.") + ffprobe_installer.run() + print(installed and "FFPROBE Upadted." or "FFPROBE Installed.") + setting_install_ffprobe.SetText( installed and "FFPROBE Upadted." or "FFPROBE Installed." ) + setting_install_ffprobe.SetButtonPressed(install_ffprobe) + + +class SettingsTab(QFrame): + def __init__(self): + super().__init__() + self.setStyleSheet( + "#settinglabel { font-size: 16px; }" + "#settingbutton, #settingbox { border: 1px solid #8c8c8c; padding: 4px 8px; border-radius: 4px;}" + ) + + try: + self.sidebar = Sidebar() + + # general = self.sidebar.AddButton("General") + # general.clicked.connect(lambda: print("General")) + # general = self.sidebar.AddButton("Requirements") + # general.clicked.connect(lambda: print("Requirements")) + + page_general = SidebarPage( "General" ) + page_general.AddWidget(subpage_general()) + page_general.AddSpacer() + + page_requirements = SidebarPage( "Requirements" ) + page_requirements.AddWidget(subpage_requirements()) + page_requirements.AddSpacer() + + self.sidebar.AddPage("General", page_general) + self.sidebar.AddPage("Requirements", page_requirements) + self.sidebar.swisthPage(0) + + + self.layout = QHBoxLayout() + self.layout.addWidget(self.sidebar) + self.setLayout(self.layout) + self.setObjectName("Settings") + except Exception as e: + print("### SettingsTab Error ###") + traceback.print_exc() + + self.layout = QVBoxLayout() + self.layout.addWidget(QLabel("SettingsTab Error")) + self.setLayout(self.layout) \ No newline at end of file