From ad51f1236f39b6d4e521ee54b12fd67d7635eab6 Mon Sep 17 00:00:00 2001 From: csd4ni3l Date: Thu, 26 Jun 2025 22:00:29 +0200 Subject: [PATCH] Add a view metadata button, add more metadata to file after download and extract more from files. --- menus/downloader.py | 22 +++++-- menus/main.py | 79 +++++++++++++++--------- utils/constants.py | 4 +- utils/utils.py | 143 +++++++++++++++++++++++--------------------- uv.lock | 6 +- 5 files changed, 148 insertions(+), 106 deletions(-) diff --git a/menus/downloader.py b/menus/downloader.py index bcb466f..81a852a 100644 --- a/menus/downloader.py +++ b/menus/downloader.py @@ -1,6 +1,7 @@ -from mutagen.easyid3 import EasyID3 +from mutagen.id3 import ID3, TIT2, TPE1, WXXX +from mutagen.mp3 import MP3 -import arcade, arcade.gui, os, json, threading, subprocess +import arcade, arcade.gui, os, json, threading, subprocess, traceback from arcade.gui.experimental.focus import UIFocusGroup @@ -106,7 +107,7 @@ class Downloader(arcade.gui.UIView): path = os.path.expanduser(self.tab_selector.value) info = self.run_yt_dlp(url) - + os.remove("downloaded_music.mp3.info.json") os.remove("downloaded_music.info.json") @@ -122,9 +123,18 @@ class Downloader(arcade.gui.UIView): title = f"{artist} - {track_title}" try: - audio = EasyID3("downloaded_music.mp3") - audio["artist"] = artist - audio["title"] = track_title + audio = MP3("downloaded_music.mp3", ID3=ID3) + if audio.tags is None: + audio.add_tags() + else: + for frame_id in ("TIT2", "TPE1", "WXXX"): + audio.tags.delall(frame_id) + audio.tags.add(TIT2(encoding=3, text=track_title)) + audio.tags.add(TPE1(encoding=3, text=artist)) + if info.get("creator_url"): + audio.tags.add(WXXX(desc="Uploader", url=info["uploader_url"])) + audio.tags.add(WXXX(desc="Source", url=info["webpage_url"])) + audio.save() except Exception as meta_err: self.yt_dl_buffer = f"ERROR: Tried to override metadata based on title, but failed: {meta_err}" diff --git a/menus/main.py b/menus/main.py index e634810..aff6a21 100644 --- a/menus/main.py +++ b/menus/main.py @@ -1,11 +1,10 @@ -import random, asyncio, pypresence, time, copy, json, os, logging +import random, asyncio, pypresence, time, copy, json, os, logging, webbrowser import arcade, pyglet from utils.preload import * from utils.constants import button_style, slider_style, audio_extensions, discord_presence_id -from utils.utils import FakePyPresence, UIFocusTextureButton, ListItem, extract_metadata, get_audio_thumbnail_texture, truncate_end, adjust_volume +from utils.utils import FakePyPresence, UIFocusTextureButton, MusicItem, extract_metadata_and_thumbnail, truncate_end, adjust_volume -from math import ceil from thefuzz import process, fuzz from arcade.gui.experimental.scroll_area import UIScrollArea, UIScrollBar @@ -57,6 +56,7 @@ class Main(arcade.gui.UIView): self.tab_options = self.settings_dict.get("tab_options", [os.path.join("~", "Music"), os.path.join("~", "Downloads")]) self.tab_content = {} self.playlist_content = {} + self.file_metadata = {} self.thumbnails = {} self.tab_buttons = {} self.music_buttons = {} @@ -257,10 +257,22 @@ class Main(arcade.gui.UIView): self.shuffle = not self.shuffle self.update_buttons() + def metadata_button_action(self, action, metadata): + if action != "Close": + webbrowser.open(metadata["uploader_url"] if action == "Uploader" else metadata["source_url"]) + + def open_metadata(self, file_path): + metadata = self.file_metadata[file_path] + + metadata_text = f"File path: {file_path}\nArtist: {metadata['artist']}\nTitle: {metadata['title']}\nSound length: {int(metadata['sound_length'])}\nBitrate: {metadata['bit_rate']}Kbps" + + msgbox = arcade.gui.UIMessageBox(title=f"{metadata['artist']} - {metadata['title']} Metadata", buttons=("Uploader", "Source", "Close"), message_text=metadata_text, width=self.window.width / 2, height=self.window.height / 2) + msgbox.on_action = lambda event, metadata=metadata: self.metadata_button_action(event.action, metadata) + self.anchor.add(msgbox, anchor_x="center", anchor_y="center") + def show_content(self, tab): for music_button in self.music_buttons.values(): - music_button.remove(music_button.button) - music_button.remove(music_button.image) + music_button.clear() self.music_box.remove(music_button) del music_button @@ -272,21 +284,25 @@ class Main(arcade.gui.UIView): if not self.search_term == "": matches = process.extract(self.search_term, self.tab_content[self.current_tab], limit=5, processor=lambda text: text.lower(), scorer=fuzz.partial_token_sort_ratio) self.highest_score_file = f"{self.current_tab}/{matches[0][0]}" + for match in matches: music_filename = match[0] - self.music_buttons[f"{tab}/{music_filename}"] = self.music_box.add(ListItem(texture=self.thumbnails[f"{tab}/{music_filename}"], font_name="Roboto", font_size=13, text=music_filename, width=self.window.width / 1.2, height=self.window.height / 11)) - self.music_buttons[f"{tab}/{music_filename}"].button.on_click = lambda event, tab=tab, music_filename=music_filename: self.music_button_click(event, f"{tab}/{music_filename}") - + metadata = self.file_metadata[f"{tab}/{music_filename}"] + self.music_buttons[f"{tab}/{music_filename}"] = self.music_box.add(MusicItem(metadata=metadata, texture=self.thumbnails[f"{tab}/{music_filename}"], width=self.window.width / 1.2, height=self.window.height / 11)) + self.music_buttons[f"{tab}/{music_filename}"].button.on_click = lambda event, music_path=f"{tab}/{music_filename}": self.music_button_click(event, music_path) + self.music_buttons[f"{tab}/{music_filename}"].view_metadata_button.on_click = lambda event, music_path=f"{tab}/{music_filename}": self.open_metadata(music_path) + else: self.highest_score_file = "" self.no_music_label.visible = not self.tab_content[tab] for music_filename in self.tab_content[tab]: - self.music_buttons[f"{tab}/{music_filename}"] = self.music_box.add(ListItem(texture=self.thumbnails[f"{tab}/{music_filename}"], font_name="Roboto", font_size=13, text=music_filename, width=self.window.width / 1.2, height=self.window.height / 11)) - self.music_buttons[f"{tab}/{music_filename}"].button.on_click = lambda event, tab=tab, music_filename=music_filename: self.music_button_click(event, f"{tab}/{music_filename}") - - self.music_box._update_size_hints() + metadata = self.file_metadata[f"{tab}/{music_filename}"] + + self.music_buttons[f"{tab}/{music_filename}"] = self.music_box.add(MusicItem(metadata=metadata, texture=self.thumbnails[f"{tab}/{music_filename}"], width=self.window.width / 1.2, height=self.window.height / 11)) + self.music_buttons[f"{tab}/{music_filename}"].button.on_click = lambda event, music_path=f"{tab}/{music_filename}": self.music_button_click(event, music_path) + self.music_buttons[f"{tab}/{music_filename}"].view_metadata_button.on_click = lambda event, music_path=f"{tab}/{music_filename}": self.open_metadata(music_path) elif self.current_mode == "playlist": self.current_playlist = tab @@ -296,22 +312,24 @@ class Main(arcade.gui.UIView): matches = process.extract(self.search_term, self.playlist_content[tab], limit=5, processor=lambda text: text.lower(), scorer=fuzz.partial_token_sort_ratio) self.highest_score_file = matches[0][0] for match in matches: - music_filename = match[0] - self.music_buttons[music_filename] = self.music_box.add(ListItem(texture=self.thumbnails[music_filename], font_name="Roboto", font_size=13, text=music_filename, width=self.window.width / 1.2, height=self.window.height / 11)) - self.music_buttons[music_filename].button.on_click = lambda event, music_filename=music_filename: self.music_button_click(event, music_filename) + music_path = match[0] + metadata = self.file_metadata[music_path] + self.music_buttons[music_path] = self.music_box.add(MusicItem(metadata=metadata, texture=self.thumbnails[music_path], width=self.window.width / 1.2, height=self.window.height / 11)) + self.music_buttons[music_path].button.on_click = lambda event, music_path=music_path: self.music_button_click(event, music_path) + self.music_buttons[music_path].view_metadata_button.on_click = lambda event, music_path=music_path: self.open_metadata(music_path) else: self.highest_score_file = "" self.no_music_label.visible = not self.playlist_content[tab] - for music_filename in self.playlist_content[tab]: - self.music_buttons[music_filename] = self.music_box.add(ListItem(texture=self.thumbnails[music_filename], font_name="Roboto", font_size=13, text=music_filename, width=self.window.width / 1.2, height=self.window.height / 11)) - self.music_buttons[music_filename].button.on_click = lambda event, music_filename=music_filename: self.music_button_click(event, music_filename) + for music_path in self.playlist_content[tab]: + metadata = self.file_metadata[music_path] + self.music_buttons[music_path] = self.music_box.add(MusicItem(metadata=metadata, texture=self.thumbnails[music_path], width=self.window.width / 1.2, height=self.window.height / 11)) + self.music_buttons[music_path].button.on_click = lambda event, music_path=music_path: self.music_button_click(event, music_path) + self.music_buttons[music_path].view_metadata_button.on_click = lambda event, music_path=music_path: self.open_metadata(music_filename) - self.music_box._update_size_hints() - - self.music_buttons["add_music"] = self.music_box.add(ListItem(texture=plus_icon, font_name="Roboto", font_size=13, text="Add Music", width=self.window.width / 1.2, height=self.window.height / 11)) + self.music_buttons["add_music"] = self.music_box.add(MusicItem(metadata=None, texture=music_icon, width=self.window.width / 1.2, height=self.window.height / 11)) self.music_buttons["add_music"].button.on_click = lambda event: self.add_music() self.anchor.detect_focusable_widgets() @@ -334,6 +352,8 @@ class Main(arcade.gui.UIView): def load_content(self): self.tab_content.clear() self.playlist_content.clear() + self.file_metadata.clear() + self.thumbnails.clear() for tab in self.tab_options: expanded_tab = os.path.expanduser(tab) @@ -341,13 +361,15 @@ class Main(arcade.gui.UIView): if not os.path.exists(expanded_tab) or not os.path.isdir(expanded_tab): self.tab_options.remove(tab) continue - + self.tab_content[expanded_tab] = [] for filename in os.listdir(expanded_tab): if filename.split(".")[-1] in audio_extensions: - if f"{expanded_tab}/{filename}" not in self.thumbnails: - self.thumbnails[f"{expanded_tab}/{filename}"] = get_audio_thumbnail_texture(f"{expanded_tab}/{filename}", self.window.size) + if f"{expanded_tab}/{filename}" not in self.file_metadata: + sound_length, bit_rate, uploader_url, source_url, artist, title, thumbnail = extract_metadata_and_thumbnail(f"{expanded_tab}/{filename}", (int(self.window.width / 16), int(self.window.height / 9))) + self.file_metadata[f"{expanded_tab}/{filename}"] = {"sound_length": sound_length, "bit_rate": bit_rate, "uploader_url": uploader_url, "source_url": source_url, "artist": artist, "title": title} + self.thumbnails[f"{expanded_tab}/{filename}"] = thumbnail self.tab_content[expanded_tab].append(filename) for playlist, content in self.settings_dict.get("playlists", {}).items(): @@ -356,9 +378,10 @@ class Main(arcade.gui.UIView): content.remove(file) # also removes reference from self.settings_dict["playlists"] continue - if file not in self.thumbnails: - self.thumbnails[file] = get_audio_thumbnail_texture(file, self.window.size) - + if file not in self.file_metadata: + sound_length, bit_rate, uploader_url, source_url, artist, title, thumbnail = extract_metadata_and_thumbnail(file, (int(self.window.width / 16), int(self.window.height / 9))) + self.file_metadata[file] = {"sound_length": sound_length, "bit_rate": bit_rate, "uploader_url": uploader_url, "source_url": source_url, "artist": artist, "title": title} + self.thumbnails[file] = thumbnail self.playlist_content[playlist] = content def load_tabs(self): @@ -391,7 +414,7 @@ class Main(arcade.gui.UIView): if len(self.queue) > 0: music_path = self.queue.pop(0) - artist, title = extract_metadata(music_path) + artist, title = self.file_metadata[music_path]["artist"], self.file_metadata[music_path]["title"] music_name = f"{artist} - {title}" diff --git a/utils/constants.py b/utils/constants.py index 7380246..2be02a7 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -1,13 +1,13 @@ import arcade.color from arcade.types import Color -from arcade.gui.widgets.buttons import UITextureButtonStyle, UIFlatButtonStyle +from arcade.gui.widgets.buttons import UIFlatButtonStyle from arcade.gui.widgets.slider import UISliderStyle menu_background_color = (17, 17, 17) log_dir = 'logs' discord_presence_id = 1368277020332523530 -audio_extensions = ["mp3", "m4a", "mp4", "aac", "flac", "ogg", "opus", "wav"] +audio_extensions = ["mp3", "m4a", "aac", "flac", "ogg", "opus", "wav"] DARK_GRAY = Color(45, 45, 45) GRAY = Color(70, 70, 70) diff --git a/utils/utils.py b/utils/utils.py index cfe353c..912f79a 100644 --- a/utils/utils.py +++ b/utils/utils.py @@ -1,4 +1,4 @@ -import logging, sys, traceback, os, re, platform, urllib.request, io, base64, tempfile +import logging, sys, traceback, os, re, platform, urllib.request, io, base64, tempfile, struct from mutagen.easyid3 import EasyID3 from mutagen.id3 import ID3 @@ -87,28 +87,41 @@ class UIFocusTextureButton(arcade.gui.UITextureButton): else: self.resize(width=self.width / 1.1, height=self.height / 1.1) -class ListItem(arcade.gui.UIBoxLayout): - def __init__(self, width: int, height: int, font_name: str, font_size: int, text: str, texture: arcade.Texture, padding=10): +class MusicItem(arcade.gui.UIBoxLayout): + def __init__(self, metadata: dict, width: int, height: int, texture: arcade.Texture, padding=10): super().__init__(width=width, height=height, space_between=padding, align="top", vertical=False) - self.image = self.add(arcade.gui.UIImage( - texture=texture, - width=width * 0.1, - height=height - )) + if metadata: + self.image = self.add(arcade.gui.UIImage( + texture=texture, + width=height * 1.5, + height=height, + )) self.button = self.add(arcade.gui.UITextureButton( - text=text, + text=f"{metadata['artist']} - {metadata['title']}" if metadata else "Add Music", texture=button_texture, texture_hovered=button_hovered_texture, texture_pressed=button_texture, texture_disabled=button_texture, style=button_style, - width=width * 0.9, + width=width * 0.85, height=height, interaction_buttons=[arcade.MOUSE_BUTTON_LEFT, arcade.MOUSE_BUTTON_RIGHT] )) + if metadata: + self.view_metadata_button = self.add(arcade.gui.UITextureButton( + text="View Metadata", + texture=button_texture, + texture_hovered=button_hovered_texture, + texture_pressed=button_texture, + texture_disabled=button_texture, + style=button_style, + width=width * 0.1, + height=height, + )) + def on_exception(*exc_info): logging.error(f"Unhandled exception:\n{''.join(traceback.format_exception(exc_info[1], limit=None))}") @@ -171,88 +184,84 @@ def truncate_end(text: str, max_length: int) -> str: return text return text[:max_length - 3] + '...' -def extract_metadata(filename): +def extract_metadata_and_thumbnail(filename: str, thumb_resolution: tuple) -> tuple: artist = "Unknown" title = "" + source_url = "Unknown" + creator_url = "Unknown" + thumb_texture = None + sound_length = 0 + bit_rate = 0 basename = os.path.basename(filename) - name_only = os.path.splitext(basename)[0] - - name_only = re.sub(r'\s*\[[a-zA-Z0-9\-_]{5,}\]$', '', name_only) + name_only = re.sub(r'\s*\[[a-zA-Z0-9\-_]{5,}\]$', '', os.path.splitext(basename)[0]) + ext = os.path.splitext(filename)[1].lower().lstrip('.') try: thumb_audio = EasyID3(filename) + try: + artist = str(thumb_audio["artist"][0]) + title = str(thumb_audio["title"][0]) + except KeyError: + artist_title_match = re.search(r'^.+\s*-\s*.+$', title) + if artist_title_match: + title = title.split("- ")[1] - artist = str(thumb_audio["artist"][0]) - title = str(thumb_audio["title"][0]) + file_audio = File(filename) + if hasattr(file_audio, 'info'): + sound_length = round(file_audio.info.length, 2) + bit_rate = int((file_audio.info.bitrate or 0) / 1000) - artist_title_match = re.search(r'^.+\s*-\s*.+$', title) # check for Artist - Title titles, so Artist doesnt appear twice + thumb_image_data = None + if ext == 'mp3': + for tag in file_audio.values(): + if tag.FrameID == "APIC": + thumb_image_data = tag.data + break + elif ext in ('m4a', 'aac'): + if 'covr' in file_audio: + thumb_image_data = file_audio['covr'][0] + elif ext == 'flac': + if file_audio.pictures: + thumb_image_data = file_audio.pictures[0].data + elif ext in ('ogg', 'opus'): + if "metadata_block_picture" in file_audio: + pic_data = base64.b64decode(file_audio["metadata_block_picture"][0]) + header_len = struct.unpack(">I", pic_data[0:4])[0] + thumb_image_data = pic_data[4 + header_len:] - if artist_title_match: - title = title.split("- ")[1] + id3 = ID3(filename) + for frame in id3.getall("WXXX"): + if frame.desc.lower() == "creator": + creator_url = frame.url + elif frame.desc.lower() == "source": + source_url = frame.url - if artist != "Unknown" and title: - return artist, title - except: - pass + if thumb_image_data: + pil_image = Image.open(io.BytesIO(thumb_image_data)).convert("RGBA") + pil_image = pil_image.resize(thumb_resolution) + thumb_texture = arcade.Texture(pil_image) + + except Exception as e: + logging.debug(f"[Metadata/Thumbnail Error] {filename}: {e}") if artist == "Unknown" or not title: match = re.search(r'^(.*?)\s+-\s+(.*?)$', name_only) if match: filename_artist, filename_title = match.groups() - if artist == "Unknown": artist = filename_artist - if not title: title = filename_title - return artist, title - if not title: title = name_only - return artist, title + if thumb_texture is None: + from utils.preload import music_icon + thumb_texture = music_icon -def get_audio_thumbnail_texture(audio_path: str, window_resolution: tuple) -> arcade.Texture: - ext = os.path.splitext(audio_path)[1].lower().lstrip('.') - thumb_audio = File(audio_path) - - thumb_image_data = None - - try: - if ext == 'mp3': - for tag in thumb_audio.values(): - if tag.FrameID == "APIC": - thumb_image_data = tag.data - break - - elif ext in ('m4a', 'mp4', 'aac'): - if 'covr' in thumb_audio: - thumb_image_data = thumb_audio['covr'][0] - - elif ext == 'flac': - if thumb_audio.pictures: - thumb_image_data = thumb_audio.pictures[0].data - - elif ext in ('ogg', 'opus'): - if "metadata_block_picture" in thumb_audio: - pic_data = base64.b64decode(thumb_audio["metadata_block_picture"][0]) - import struct - header_len = struct.unpack(">I", pic_data[0:4])[0] - thumb_image_data = pic_data[4 + header_len:] - - if thumb_image_data: - pil_image = Image.open(io.BytesIO(thumb_image_data)).convert("RGBA") - pil_image = pil_image.resize((int(window_resolution[0] / 5), int(window_resolution[1] / 8))) - thumb_texture = arcade.Texture(pil_image) - return thumb_texture - - except Exception as e: - logging.debug(f"[Thumbnail Error] {audio_path}: {e}") - - from utils.preload import music_icon - return music_icon + return sound_length, bit_rate, creator_url, source_url, artist, title, thumb_texture def adjust_volume(input_path, volume): try: diff --git a/uv.lock b/uv.lock index d1ef07d..62c94d0 100644 --- a/uv.lock +++ b/uv.lock @@ -307,9 +307,9 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.13.2" +version = "4.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload_time = "2025-04-10T14:19:05.416Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload_time = "2025-06-02T14:52:11.399Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload_time = "2025-04-10T14:19:03.967Z" }, + { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload_time = "2025-06-02T14:52:10.026Z" }, ]