Add file watching/automatic reload, remove manual reload, split utils into multiple files, add much more metadata such as last play time and play times

This commit is contained in:
csd4ni3l
2025-06-27 19:44:22 +02:00
parent 716ebfa021
commit 2461d6fc9d
12 changed files with 408 additions and 239 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -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))

View File

@@ -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 {}
@@ -118,10 +122,10 @@ 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}"]
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, 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)
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()
@@ -353,7 +356,6 @@ class Main(arcade.gui.UIView):
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

View File

@@ -10,4 +10,5 @@ dependencies = [
"pydub>=0.25.1",
"pypresence>=4.3.0",
"thefuzz>=0.22.1",
"watchdog>=6.0.0",
]

View File

@@ -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)

2
run.py
View File

@@ -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

View File

@@ -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)

46
utils/file_watching.py Normal file
View File

@@ -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)
)

189
utils/music_handling.py Normal file
View File

@@ -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"

View File

@@ -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")

View File

@@ -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)

29
uv.lock generated
View File

@@ -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" },
]