diff --git a/assets/graphics/reload.png b/assets/graphics/reload.png deleted file mode 100644 index 83b07e0..0000000 Binary files a/assets/graphics/reload.png and /dev/null differ diff --git a/menus/downloader.py b/menus/downloader.py index 81a852a..53d050a 100644 --- a/menus/downloader.py +++ b/menus/downloader.py @@ -1,11 +1,11 @@ from mutagen.id3 import ID3, TIT2, TPE1, WXXX from mutagen.mp3 import MP3 -import arcade, arcade.gui, os, json, threading, subprocess, traceback +import arcade, arcade.gui, os, json, threading, subprocess, urllib.request, platform from arcade.gui.experimental.focus import UIFocusGroup -from utils.utils import ensure_yt_dlp, adjust_volume +from utils.music_handling import adjust_volume from utils.constants import button_style from utils.preload import button_texture, button_hovered_texture @@ -65,7 +65,7 @@ class Downloader(arcade.gui.UIView): self.status_label.update_font(font_color=arcade.color.LIGHT_GREEN) def run_yt_dlp(self, url): - yt_dlp_path = ensure_yt_dlp() + yt_dlp_path = self.ensure_yt_dlp() command = [ yt_dlp_path, f"{url}", @@ -161,6 +161,34 @@ class Downloader(arcade.gui.UIView): self.yt_dl_buffer = f"Successfully downloaded {title} to {path}" + def ensure_yt_dlp(): + system = platform.system() + + if system == "Windows": + path = os.path.join("bin", "yt-dlp.exe") + elif system == "Darwin": + path = os.path.join("bin", "yt-dlp_macos") + elif system == "Linux": + path = os.path.join("bin", "yt-dlp_linux") + + if not os.path.exists("bin"): + os.makedirs("bin") + + if not os.path.exists(path): + if system == "Windows": + url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" + elif system == "Darwin": + url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos" + elif system == "Linux": + url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux" + else: + raise RuntimeError("Unsupported OS") + + urllib.request.urlretrieve(url, path) + os.chmod(path, 0o755) + + return path + def main_exit(self): from menus.main import Main self.window.show_view(Main(self.pypresence_client, self.current_mode, self.current_music_name, self.current_length, self.current_music_player, self.queue, self.loaded_sounds, self.shuffle)) diff --git a/menus/main.py b/menus/main.py index e7081e8..adb5a23 100644 --- a/menus/main.py +++ b/menus/main.py @@ -2,8 +2,10 @@ 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, MusicItem, extract_metadata_and_thumbnail, truncate_end, adjust_volume, convert_seconds_to_date +from utils.constants import button_style, slider_style, audio_extensions, discord_presence_id, view_modes +from utils.utils import FakePyPresence, UIFocusTextureButton, MusicItem, convert_seconds_to_date +from utils.music_handling import update_last_play_statistics, extract_metadata_and_thumbnail, adjust_volume, truncate_end, convert_timestamp_to_time_ago +from utils.file_watching import watch_directories, watch_files from thefuzz import process, fuzz @@ -57,7 +59,6 @@ class Main(arcade.gui.UIView): self.tab_content = {} self.playlist_content = {} self.file_metadata = {} - self.thumbnails = {} self.tab_buttons = {} self.music_buttons = {} @@ -67,6 +68,9 @@ class Main(arcade.gui.UIView): self.current_mode = current_mode or "files" self.current_playlist = None self.time_to_seek = None + self.tab_observer = None + self.playlist_observer = None + self.should_reload = False self.current_tab = self.tab_options[0] self.queue = queue if queue else [] self.loaded_sounds = loaded_sounds if loaded_sounds else {} @@ -117,11 +121,11 @@ class Main(arcade.gui.UIView): self.downloader_button = self.settings_box.add(UIFocusTextureButton(texture=download_icon, texture_hovered=download_icon, texture_pressed=download_icon, style=button_style)) self.downloader_button.on_click = lambda event: self.downloader() - - self.reload_button = self.settings_box.add(UIFocusTextureButton(texture=reload_icon, texture_hovered=reload_icon, texture_pressed=reload_icon, style=button_style)) - self.reload_button.on_click = lambda event: self.reload() - - mode_icon = playlist_icon if self.current_mode == "files" else files_icon + + if self.current_mode == "files": + mode_icon = files_icon + elif self.current_mode == "playlist": + mode_icon = playlist_icon self.mode_button = self.settings_box.add(UIFocusTextureButton(texture=mode_icon, texture_hovered=mode_icon, texture_pressed=mode_icon, style=button_style)) self.mode_button.on_click = lambda event: self.change_mode() @@ -178,14 +182,13 @@ class Main(arcade.gui.UIView): def update_buttons(self): if self.current_mode == "files": - self.mode_button.texture = playlist_icon - self.mode_button.texture_hovered = playlist_icon - self.mode_button.texture_pressed = playlist_icon - + mode_icon = files_icon elif self.current_mode == "playlist": - self.mode_button.texture = files_icon - self.mode_button.texture_hovered = files_icon - self.mode_button.texture_pressed = files_icon + mode_icon = playlist_icon + + self.mode_button.texture = mode_icon + self.mode_button.texture_hovered = mode_icon + self.mode_button.texture_pressed = mode_icon self.shuffle_button.texture = no_shuffle_icon if self.shuffle else shuffle_icon self.shuffle_button.texture_hovered = no_shuffle_icon if self.shuffle else shuffle_icon @@ -211,7 +214,10 @@ class Main(arcade.gui.UIView): self.anchor.detect_focusable_widgets() def change_mode(self): - self.current_mode = "playlist" if self.current_mode == "files" else "files" + if view_modes.index(self.current_mode) == len(view_modes) - 1: + self.current_mode = view_modes[0] + else: + self.current_mode = view_modes[view_modes.index(self.current_mode) + 1] self.current_playlist = list(self.playlist_content.keys())[0] if self.playlist_content else None @@ -219,8 +225,8 @@ class Main(arcade.gui.UIView): self.search_term = "" self.reload() - self.load_tabs() + self.update_buttons() def skip_sound(self): if not self.current_music_player is None: @@ -264,7 +270,15 @@ class Main(arcade.gui.UIView): 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: {convert_seconds_to_date(int(metadata['sound_length']))}\nBitrate: {metadata['bit_rate']}Kbps" + metadata_text = f'''File path: {file_path} +File size: {metadata['file_size']}MiB +Artist: {metadata['artist']} +Title: {metadata['title']} +Amount of times played: {metadata['play_count']} +Last Played: {convert_timestamp_to_time_ago(int(metadata['last_played']))} +Sound length: {convert_seconds_to_date(int(metadata['sound_length']))} +Bitrate: {metadata['bitrate']}Kbps +Sample rate: {metadata['sample_rate']}KHz''' 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) @@ -284,25 +298,19 @@ 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] - 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) + content_to_show = [match[0] for match in matches] else: self.highest_score_file = "" - self.no_music_label.visible = not self.tab_content[tab] + content_to_show = self.tab_content[tab] - for music_filename in self.tab_content[tab]: - 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) + for music_filename in content_to_show: + metadata = self.file_metadata[f"{tab}/{music_filename}"] + + self.music_buttons[f"{tab}/{music_filename}"] = self.music_box.add(MusicItem(metadata=metadata, 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 @@ -311,25 +319,20 @@ class Main(arcade.gui.UIView): if not self.search_term == "": 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_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) + content_to_show = [match[0] for match in matches] else: self.highest_score_file = "" - self.no_music_label.visible = not self.playlist_content[tab] + content_to_show = self.playlist_content[tab] - 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) + for music_path in content_to_show: + metadata = self.file_metadata[music_path] + self.music_buttons[music_path] = self.music_box.add(MusicItem(metadata=metadata, 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) - 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"] = self.music_box.add(MusicItem(metadata=None, 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() @@ -348,12 +351,11 @@ class Main(arcade.gui.UIView): self.window.show_view(Main(self.pypresence_client, self.current_mode, self.current_music_name, # temporarily fixes the issue of bad resolution after deletion with less than 2 rows self.current_length, self.current_music_player, self.queue, self.loaded_sounds, self.shuffle)) - + 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) @@ -367,23 +369,33 @@ class Main(arcade.gui.UIView): for filename in os.listdir(expanded_tab): if filename.split(".")[-1] in audio_extensions: 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.file_metadata[f"{expanded_tab}/{filename}"] = extract_metadata_and_thumbnail(f"{expanded_tab}/{filename}", (int(self.window.width / 16), int(self.window.height / 9))) self.tab_content[expanded_tab].append(filename) - + + if self.tab_observer: + self.tab_observer.stop() + self.tab_observer = watch_directories(self.tab_content.keys(), self.on_file_change) + + playlist_files = [] for playlist, content in self.settings_dict.get("playlists", {}).items(): for file in content: + playlist_files.append(file) + if not os.path.exists(file) or not os.path.isfile(file): content.remove(file) # also removes reference from self.settings_dict["playlists"] continue 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.file_metadata[file] = extract_metadata_and_thumbnail(file, (int(self.window.width / 16), int(self.window.height / 9))) self.playlist_content[playlist] = content + if self.playlist_observer: + self.playlist_observer.stop() + self.playlist_observer = watch_files(playlist_files, self.on_file_change) + + def on_file_change(self, event_type, path): + self.should_reload = True # needed because the observer runs in another thread and OpenGL is single-threaded. + def load_tabs(self): self.tab_box.clear() @@ -410,6 +422,10 @@ class Main(arcade.gui.UIView): self.current_music_player.volume = self.volume / 100 def on_update(self, delta_time): + if self.should_reload: + self.should_reload = False + self.reload() + if self.current_music_player is None or self.current_music_player.time == 0: if len(self.queue) > 0: music_path = self.queue.pop(0) @@ -420,12 +436,14 @@ class Main(arcade.gui.UIView): if self.settings_dict.get("normalize_audio", True): self.current_music_label.text = "Normalizing audio..." - self.window.draw(delta_time) + self.window.draw(delta_time) # draw before blocking try: adjust_volume(music_path, self.settings_dict.get("normalized_volume", -8)) except Exception as e: logging.error(f"Couldn't normalize volume for {music_path}: {e}") + update_last_play_statistics(music_path) + if not music_name in self.loaded_sounds: self.loaded_sounds[music_name] = arcade.Sound(music_path, streaming=self.settings_dict.get("music_mode", "Stream") == "Stream") @@ -438,7 +456,7 @@ class Main(arcade.gui.UIView): self.current_length = self.loaded_sounds[music_name].get_length() self.current_music_name = music_name - self.current_music_thumbnail_image.texture = self.thumbnails[music_path] + self.current_music_thumbnail_image.texture = self.file_metadata[music_path]["thumbnail"] self.current_music_label.text = truncate_end(music_name, int(self.window.width / 30)) self.time_label.text = "00:00" self.progressbar.max_value = self.current_length diff --git a/pyproject.toml b/pyproject.toml index 6ea4653..9ccf1f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,4 +10,5 @@ dependencies = [ "pydub>=0.25.1", "pypresence>=4.3.0", "thefuzz>=0.22.1", + "watchdog>=6.0.0", ] diff --git a/requirements.txt b/requirements.txt index 34b8731..5ebfefc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,32 @@ -Pillow +# This file was autogenerated by uv via the following command: +# uv pip compile pyproject.toml -o requirements.txt arcade==3.2.0 -pypresence -mutagen -yt-dlp -thefuzz -pydub + # via musicplayer (pyproject.toml) +attrs==25.3.0 + # via pytiled-parser +cffi==1.17.1 + # via pymunk +mutagen==1.47.0 + # via musicplayer (pyproject.toml) +pillow==11.0.0 + # via arcade +pycparser==2.22 + # via cffi +pydub==0.25.1 + # via musicplayer (pyproject.toml) +pyglet==2.1.6 + # via arcade +pymunk==6.9.0 + # via arcade +pypresence==4.3.0 + # via musicplayer (pyproject.toml) +pytiled-parser==2.2.9 + # via arcade +rapidfuzz==3.13.0 + # via thefuzz +thefuzz==0.22.1 + # via musicplayer (pyproject.toml) +typing-extensions==4.14.0 + # via pytiled-parser +watchdog==6.0.0 + # via musicplayer (pyproject.toml) diff --git a/run.py b/run.py index b2b0aca..d8bff07 100644 --- a/run.py +++ b/run.py @@ -33,7 +33,7 @@ timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") log_filename = f"debug_{timestamp}.log" logging.basicConfig(filename=f'{os.path.join(log_dir, log_filename)}', format='%(asctime)s %(name)s %(levelname)s: %(message)s', level=logging.DEBUG) -for logger_name_to_disable in ['arcade', "numba"]: +for logger_name_to_disable in ['arcade', "watchdog", "PIL"]: logging.getLogger(logger_name_to_disable).propagate = False logging.getLogger(logger_name_to_disable).disabled = True diff --git a/utils/constants.py b/utils/constants.py index 2be02a7..a8417e4 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -9,6 +9,8 @@ discord_presence_id = 1368277020332523530 audio_extensions = ["mp3", "m4a", "aac", "flac", "ogg", "opus", "wav"] +view_modes = ["files", "playlist"] + DARK_GRAY = Color(45, 45, 45) GRAY = Color(70, 70, 70) LIGHT_GRAY = Color(150, 150, 150) diff --git a/utils/file_watching.py b/utils/file_watching.py new file mode 100644 index 0000000..93c49f5 --- /dev/null +++ b/utils/file_watching.py @@ -0,0 +1,46 @@ +from watchdog.events import PatternMatchingEventHandler +from watchdog.observers import Observer + +from typing import Callable + +from utils.constants import audio_extensions + +import os + +class DirectoryWatcher(PatternMatchingEventHandler): + def __init__(self, trigger_function: Callable[[str, str], None]): + patterns = [f"*.{ext}" for ext in audio_extensions] + super().__init__(patterns=patterns, ignore_directories=True, case_sensitive=False) + self.trigger_function = trigger_function + + def on_created(self, event): + self.trigger_function("create", event.src_path) + + def on_deleted(self, event): + self.trigger_function("delete", event.src_path) + +def watch_directories(directory_paths: list[str], func: Callable[[str, str], None]): + event_handler = DirectoryWatcher(func) + observer = Observer() + + for directory_path in directory_paths: + observer.schedule(event_handler, path=directory_path) + + observer.start() + return observer + +def file_hit(event_type: str, file_path: str, directories: dict[str, list[str]], func: Callable[[str, str], None]): + directory = os.path.dirname(file_path) + if directory in directories and file_path in directories[directory]: + func(event_type, file_path) + +def watch_files(file_paths: list[str], func: Callable[[str, str], None]): + directories: dict[str, list[str]] = {} + for file_path in file_paths: + directory = os.path.dirname(file_path) + directories.setdefault(directory, []).append(file_path) + + return watch_directories( + list(directories.keys()), + lambda event_type, file_path: file_hit(event_type, file_path, directories, func) + ) \ No newline at end of file diff --git a/utils/music_handling.py b/utils/music_handling.py new file mode 100644 index 0000000..7fe16d4 --- /dev/null +++ b/utils/music_handling.py @@ -0,0 +1,189 @@ +import io, base64, tempfile, struct, re, os, logging, arcade, time + +from mutagen.easyid3 import EasyID3 +from mutagen.id3 import ID3, TXXX, ID3NoHeaderError +from mutagen import File + +from pydub import AudioSegment +from PIL import Image + +from utils.utils import convert_seconds_to_date + +def truncate_end(text: str, max_length: int) -> str: + if len(text) <= max_length: + return text + if max_length <= 3: + return text + return text[:max_length - 3] + '...' + +def extract_metadata_and_thumbnail(file_path: str, thumb_resolution: tuple) -> tuple: + artist = "Unknown" + title = "" + source_url = "Unknown" + uploader_url = "Unknown" + thumb_texture = None + sound_length = 0 + bitrate = 0 + sample_rate = 0 + last_played = 0 + play_count = 0 + + basename = os.path.basename(file_path) + name_only = os.path.splitext(basename)[0] + ext = os.path.splitext(file_path)[1].lower().lstrip('.') + + try: + thumb_audio = EasyID3(file_path) + 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] + + file_audio = File(file_path) + if hasattr(file_audio, 'info'): + sound_length = round(file_audio.info.length, 2) + bitrate = int((file_audio.info.bitrate or 0) / 1000) + sample_rate = int(file_audio.info.sample_rate / 1000) + + 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:] + + id3 = ID3(file_path) + for frame in id3.getall("WXXX"): + if frame.desc.lower() == "uploader": + uploader_url = frame.url + elif frame.desc.lower() == "source": + source_url = frame.url + + for frame in id3.getall("TXXX"): + if frame.desc.lower() == "last_played": + last_played = float(frame.text[0]) + elif frame.desc.lower() == "play_count": + play_count = int(frame.text[0]) + + 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] {file_path}: {e}") + + if artist == "Unknown" or not title: + match = re.search(r'^(.*?)\s+-\s+(.*?)$', name_only) + if match: + file_path_artist, file_path_title = match.groups() + if artist == "Unknown": + artist = file_path_artist + if not title: + title = file_path_title + + if not title: + title = name_only + + if thumb_texture is None: + from utils.preload import music_icon + thumb_texture = music_icon + + file_size = round(os.path.getsize(file_path) / (1024 ** 2), 2) # MiB + + return { + "sound_length": sound_length, + "bitrate": bitrate, + "file_size": file_size, + "last_played": last_played, + "play_count": play_count, + "sample_rate": sample_rate, + "uploader_url": uploader_url, + "source_url": source_url, + "artist": artist, + "title": title, + "thumbnail": thumb_texture + } + +def adjust_volume(input_path, volume): + try: + easy_tags = EasyID3(input_path) + tags = dict(easy_tags) + tags = {k: v[0] if isinstance(v, list) else v for k, v in tags.items()} + except Exception as e: + tags = {} + + try: + id3 = ID3(input_path) + apic_frames = [f for f in id3.values() if f.FrameID == "APIC"] + cover_path = None + if apic_frames: + apic = apic_frames[0] + ext = ".jpg" if apic.mime == "image/jpeg" else ".png" + temp_cover = tempfile.NamedTemporaryFile(delete=False, suffix=ext) + temp_cover.write(apic.data) + temp_cover.close() + cover_path = temp_cover.name + else: + cover_path = None + except Exception as e: + cover_path = None + + audio = AudioSegment.from_file(input_path) + + if int(audio.dBFS) == volume: + return + + export_args = { + "format": "mp3", + "tags": tags + } + if cover_path: + export_args["cover"] = cover_path + + change = volume - audio.dBFS + audio.apply_gain(change) + audio.export(input_path, **export_args) + +def update_last_play_statistics(filepath): + try: + audio = ID3(filepath) + except ID3NoHeaderError: + audio = ID3() + + audio.setall("TXXX:last_played", [TXXX(desc="last_played", text=str(time.time()))]) + + play_count_frames = audio.getall("TXXX:play_count") + if play_count_frames: + try: + count = int(play_count_frames[0].text[0]) + except (ValueError, IndexError): + count = 0 + else: + count = 0 + + audio.setall("TXXX:play_count", [TXXX(desc="play_count", text=str(count + 1))]) + + audio.save(filepath) + +def convert_timestamp_to_time_ago(timestamp): + current_timestamp = time.time() + elapsed_time = current_timestamp - timestamp + if not timestamp == 0: + return convert_seconds_to_date(elapsed_time) + ' ago' + else: + return "Never" \ No newline at end of file diff --git a/utils/preload.py b/utils/preload.py index f3ad475..a4512e1 100644 --- a/utils/preload.py +++ b/utils/preload.py @@ -14,9 +14,9 @@ shuffle_icon = arcade.load_texture("assets/graphics/shuffle.png") no_shuffle_icon = arcade.load_texture("assets/graphics/no_shuffle.png") settings_icon = arcade.load_texture("assets/graphics/settings.png") -reload_icon = arcade.load_texture("assets/graphics/reload.png") download_icon = arcade.load_texture("assets/graphics/download.png") plus_icon = arcade.load_texture("assets/graphics/plus.png") + playlist_icon = arcade.load_texture("assets/graphics/playlist.png") files_icon = arcade.load_texture("assets/graphics/files.png") diff --git a/utils/utils.py b/utils/utils.py index 29c3dc7..cc1ba1b 100644 --- a/utils/utils.py +++ b/utils/utils.py @@ -1,12 +1,4 @@ -import logging, sys, traceback, os, re, platform, urllib.request, io, base64, tempfile, struct - -from mutagen.easyid3 import EasyID3 -from mutagen.id3 import ID3 -from mutagen import File - -from pydub import AudioSegment - -from PIL import Image +import logging, sys, traceback from utils.constants import menu_background_color, button_style from utils.preload import button_texture, button_hovered_texture @@ -88,12 +80,12 @@ class UIFocusTextureButton(arcade.gui.UITextureButton): self.resize(width=self.width / 1.1, height=self.height / 1.1) class MusicItem(arcade.gui.UIBoxLayout): - def __init__(self, metadata: dict, width: int, height: int, texture: arcade.Texture, padding=10): + def __init__(self, metadata: dict, width: int, height: int, padding=10): super().__init__(width=width, height=height, space_between=padding, align="top", vertical=False) if metadata: self.image = self.add(arcade.gui.UIImage( - texture=texture, + texture=metadata["thumbnail"], width=height * 1.5, height=height, )) @@ -142,167 +134,6 @@ def get_closest_resolution(): ) return closest_resolution -def get_yt_dlp_binary_path(): - system = platform.system() - - if system == "Windows": - binary = "yt-dlp.exe" - elif system == "Darwin": - binary = "yt-dlp_macos" - elif system == "Linux": - binary = "yt-dlp_linux" - - return os.path.join("bin", binary) - -def ensure_yt_dlp(): - path = get_yt_dlp_binary_path() - - if not os.path.exists("bin"): - os.makedirs("bin") - - if not os.path.exists(path): - system = platform.system() - - if system == "Windows": - url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" - elif system == "Darwin": - url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos" - elif system == "Linux": - url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux" - else: - raise RuntimeError("Unsupported OS") - - urllib.request.urlretrieve(url, path) - os.chmod(path, 0o755) - - return path - -def truncate_end(text: str, max_length: int) -> str: - if len(text) <= max_length: - return text - if max_length <= 3: - return text - return text[:max_length - 3] + '...' - -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 = 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] - - 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) - - 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:] - - 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 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 - - if not title: - title = name_only - - if thumb_texture is None: - from utils.preload import music_icon - thumb_texture = music_icon - - return sound_length, bit_rate, creator_url, source_url, artist, title, thumb_texture - -def adjust_volume(input_path, volume): - try: - easy_tags = EasyID3(input_path) - tags = dict(easy_tags) - tags = {k: v[0] if isinstance(v, list) else v for k, v in tags.items()} - except Exception as e: - tags = {} - - try: - id3 = ID3(input_path) - apic_frames = [f for f in id3.values() if f.FrameID == "APIC"] - cover_path = None - if apic_frames: - apic = apic_frames[0] - ext = ".jpg" if apic.mime == "image/jpeg" else ".png" - temp_cover = tempfile.NamedTemporaryFile(delete=False, suffix=ext) - temp_cover.write(apic.data) - temp_cover.close() - cover_path = temp_cover.name - else: - cover_path = None - except Exception as e: - cover_path = None - - audio = AudioSegment.from_file(input_path) - - if int(audio.dBFS) == volume: - return - - export_args = { - "format": "mp3", - "tags": tags - } - if cover_path: - export_args["cover"] = cover_path - - change = volume - audio.dBFS - audio.apply_gain(change) - audio.export(input_path, **export_args) - def convert_seconds_to_date(seconds): days, remainder = divmod(seconds, 86400) hours, remainder = divmod(remainder, 3600) diff --git a/uv.lock b/uv.lock index 62c94d0..5f9c3cf 100644 --- a/uv.lock +++ b/uv.lock @@ -81,6 +81,7 @@ dependencies = [ { name = "pydub" }, { name = "pypresence" }, { name = "thefuzz" }, + { name = "watchdog" }, ] [package.metadata] @@ -90,6 +91,7 @@ requires-dist = [ { name = "pydub", specifier = ">=0.25.1" }, { name = "pypresence", specifier = ">=4.3.0" }, { name = "thefuzz", specifier = ">=0.22.1" }, + { name = "watchdog", specifier = ">=6.0.0" }, ] [[package]] @@ -313,3 +315,30 @@ sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d0 wheels = [ { 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" }, ] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload_time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload_time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload_time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload_time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload_time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload_time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload_time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload_time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload_time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload_time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload_time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload_time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload_time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload_time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload_time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload_time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload_time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload_time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload_time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload_time = "2024-11-01T14:07:11.845Z" }, +]