Remove FFmpeg Linux download which wouldnt work and add messageboxes, add a yes/no messagebox for yt-dlp, add acoustid music recognition, only support MP3, split online_metadata to multiple files, add missing metadata to files automatically, add synchronized lyrics pane

This commit is contained in:
csd4ni3l
2025-07-10 20:29:49 +02:00
parent b086a6b921
commit c7aad868db
16 changed files with 575 additions and 257 deletions

View File

@@ -11,10 +11,9 @@ Features:
- Custom playlists - Custom playlists
- Fast search using just text, and instant best result playback using enter - Fast search using just text, and instant best result playback using enter
- Discord RPC - Discord RPC
- MusicBrainz metadata
Features TBD: - MusicBrainz global search
- Improved UI looks - AcoustID automatic music recognition
- More keyboard shortcuts - Lyrics from lrclib
- Vim keybindings(focusing with up down arrow keys) - Synchronized Lyrics
- Replace paths with own file manager - Controller support
- And much more!

View File

@@ -49,6 +49,7 @@ class Downloader(arcade.gui.UIView):
self.anchor.detect_focusable_widgets() self.anchor.detect_focusable_widgets()
def on_update(self, delta_time: float) -> bool | None: def on_update(self, delta_time: float) -> bool | None:
if not self.yt_dl_buffer == "download_yt_dlp":
self.status_label.text = self.yt_dl_buffer self.status_label.text = self.yt_dl_buffer
if "WARNING" in self.yt_dl_buffer: if "WARNING" in self.yt_dl_buffer:
@@ -57,12 +58,18 @@ class Downloader(arcade.gui.UIView):
self.status_label.update_font(font_color=arcade.color.RED) self.status_label.update_font(font_color=arcade.color.RED)
else: else:
self.status_label.update_font(font_color=arcade.color.LIGHT_GREEN) self.status_label.update_font(font_color=arcade.color.LIGHT_GREEN)
else:
msgbox = self.ui.add(arcade.gui.UIMessageBox(width=self.window.width / 2, height=self.window.height / 2, title="This app needs to download third-party software.", message_text="This app needs to download yt-dlp (a third-party tool) to enable video/audio downloading.\n Do you want to continue?", buttons=("Yes", "No")))
msgbox.on_action = lambda event: self.install_and_run_yt_dlp() if event.action == "Yes" else None
self.yt_dl_buffer = ''
def run_yt_dlp(self, url): def run_yt_dlp(self, url):
yt_dlp_path = self.ensure_yt_dlp() if not self.check_for_yt_dlp():
self.yt_dl_buffer = "download_yt_dlp"
return None
command = [ command = [
yt_dlp_path, f"{url}", self.get_yt_dlp_path(), f"{url}",
"--write-info-json", "--write-info-json",
"-x", "--audio-format", "mp3", "-x", "--audio-format", "mp3",
"-o", "downloaded_music.mp3", "-o", "downloaded_music.mp3",
@@ -101,6 +108,8 @@ class Downloader(arcade.gui.UIView):
path = os.path.expanduser(self.tab_selector.value) path = os.path.expanduser(self.tab_selector.value)
info = self.run_yt_dlp(url) info = self.run_yt_dlp(url)
if not info:
return # download will get re-executed.
os.remove("downloaded_music.mp3.info.json") os.remove("downloaded_music.mp3.info.json")
os.remove("downloaded_music.info.json") os.remove("downloaded_music.info.json")
@@ -155,20 +164,28 @@ class Downloader(arcade.gui.UIView):
self.yt_dl_buffer = f"Successfully downloaded {title} to {path}" self.yt_dl_buffer = f"Successfully downloaded {title} to {path}"
def ensure_yt_dlp(self): def get_yt_dlp_path(self):
system = platform.system() system = platform.system()
if system == "Windows": if system == "Windows":
path = os.path.join("bin", "yt-dlp.exe") return os.path.join("bin", "yt-dlp.exe")
elif system == "Darwin": elif system == "Darwin":
path = os.path.join("bin", "yt-dlp_macos") return os.path.join("bin", "yt-dlp_macos")
elif system == "Linux": elif system == "Linux":
path = os.path.join("bin", "yt-dlp_linux") return os.path.join("bin", "yt-dlp_linux")
def check_for_yt_dlp(self):
path = self.get_yt_dlp_path()
if not os.path.exists("bin"): if not os.path.exists("bin"):
os.makedirs("bin") os.makedirs("bin")
if not os.path.exists(path): return os.path.exists(path)
def install_and_run_yt_dlp(self):
system = platform.system()
path = self.get_yt_dlp_path()
if system == "Windows": if system == "Windows":
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
elif system == "Darwin": elif system == "Darwin":
@@ -181,7 +198,7 @@ class Downloader(arcade.gui.UIView):
urllib.request.urlretrieve(url, path) urllib.request.urlretrieve(url, path)
os.chmod(path, 0o755) os.chmod(path, 0o755)
return path threading.Thread(target=self.download, daemon=True).start()
def main_exit(self): def main_exit(self):
from menus.main import Main from menus.main import Main

View File

@@ -1,6 +1,6 @@
import arcade, arcade.gui import arcade, arcade.gui
import os, sys, subprocess, platform, urllib.request, zipfile, logging import os, sys, subprocess, platform, logging
class FFmpegMissing(arcade.gui.UIView): class FFmpegMissing(arcade.gui.UIView):
def __init__(self): def __init__(self):
@@ -15,11 +15,11 @@ class FFmpegMissing(arcade.gui.UIView):
height=self.window.height / 2, height=self.window.height / 2,
title="FFmpeg Missing", title="FFmpeg Missing",
message_text="FFmpeg has not been found but is required for this application.", message_text="FFmpeg has not been found but is required for this application.",
buttons=("Exit", "Auto Install") buttons=("Exit", "Install")
) )
) )
msgbox.on_action = lambda event: self.install_ffmpeg() if event.action == "Auto Install" else sys.exit() msgbox.on_action = lambda event: self.install_ffmpeg() if event.action == "Install" else sys.exit()
def install_ffmpeg(self): def install_ffmpeg(self):
bin_dir = os.path.join(os.getcwd(), "bin") bin_dir = os.path.join(os.getcwd(), "bin")
@@ -28,22 +28,9 @@ class FFmpegMissing(arcade.gui.UIView):
system = platform.system() system = platform.system()
if system == "Linux" or system == "Darwin": if system == "Linux" or system == "Darwin":
url = "https://evermeet.cx/ffmpeg/ffmpeg-7.1.1.zip" msgbox = self.add_widget(arcade.gui.UIMessageBox(message_text="You are on a Linux or Darwin based OS. You need to install FFmpeg, and libavcodec shared libraries from your package manager so it is in PATH.", width=self.window.width / 2, height=self.window.height / 2))
filename = "ffmpeg.zip" msgbox.on_action = lambda: sys.exit()
return
logging.debug(f"Downloading FFmpeg from {url}...")
file_path = os.path.join(bin_dir, filename)
urllib.request.urlretrieve(url, file_path)
logging.debug("Extracting FFmpeg...")
with zipfile.ZipFile(file_path, 'r') as zip_ref:
zip_ref.extractall(bin_dir)
ffmpeg_path = os.path.join(bin_dir, "ffmpeg")
os.chmod(ffmpeg_path, 0o755)
os.remove(file_path)
logging.debug("FFmpeg installed in ./bin")
elif system == "Windows": elif system == "Windows":
try: try:
@@ -52,11 +39,12 @@ class FFmpegMissing(arcade.gui.UIView):
"--accept-source-agreements", "--accept-package-agreements" "--accept-source-agreements", "--accept-package-agreements"
], check=True) ], check=True)
logging.debug("FFmpeg installed via winget.") logging.debug("FFmpeg installed via winget.")
msgbox = self.add_widget(arcade.gui.UIMessageBox(message_text="You are on a Linux or Darwin based OS. You need to install FFmpeg, and libavcodec shared libraries from your package manager so it is in PATH.", width=self.window.width / 2, height=self.window.height / 2))
msgbox.on_action = lambda: sys.exit()
return
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logging.debug("Failed to install FFmpeg via winget:", e) logging.debug("Failed to install FFmpeg via winget:", e)
else: else:
logging.error(f"Unsupported OS: {system}") self.add_widget(arcade.gui.UIMessageBox(message_text="Your OS is unsupported by this script. You are probably on some kind of BSD system. Please install FFmpeg and libavcodec shared libraries from your package manager so it is in PATH.", width=self.window.width / 2, height=self.window.height / 2))
from menus.main import Main
self.window.show_view(Main())

View File

@@ -3,7 +3,7 @@ import arcade, arcade.gui
from utils.preload import music_icon, person_icon, button_texture, button_hovered_texture from utils.preload import music_icon, person_icon, button_texture, button_hovered_texture
from utils.constants import button_style from utils.constants import button_style
from utils.utils import Card, MouseAwareScrollArea, get_wordwrapped_text from utils.utils import Card, MouseAwareScrollArea, get_wordwrapped_text
from utils.online_metadata import search_recordings, search_artists, search_albums, get_artists_metadata, get_album_metadata from utils.musicbrainz_metadata import search_recordings, search_artists, search_albums, get_artists_metadata, get_album_metadata
from arcade.gui.experimental.focus import UIFocusGroup from arcade.gui.experimental.focus import UIFocusGroup
from arcade.gui.experimental.scroll_area import UIScrollBar from arcade.gui.experimental.scroll_area import UIScrollBar

View File

@@ -6,6 +6,7 @@ from utils.constants import button_style, slider_style, audio_extensions, discor
from utils.utils import FakePyPresence, UIFocusTextureButton, Card, MouseAwareScrollArea, get_wordwrapped_text from utils.utils import FakePyPresence, UIFocusTextureButton, Card, MouseAwareScrollArea, get_wordwrapped_text
from utils.music_handling import update_last_play_statistics, extract_metadata_and_thumbnail, adjust_volume, truncate_end from utils.music_handling import update_last_play_statistics, extract_metadata_and_thumbnail, adjust_volume, truncate_end
from utils.file_watching import watch_directories, watch_files from utils.file_watching import watch_directories, watch_files
from utils.lyrics_metadata import get_lyrics, get_closest_time, parse_synchronized_lyrics
from thefuzz import process, fuzz from thefuzz import process, fuzz
@@ -15,7 +16,7 @@ from arcade.gui.experimental.focus import UIFocusGroup
class Main(arcade.gui.UIView): class Main(arcade.gui.UIView):
def __init__(self, pypresence_client: None | FakePyPresence | pypresence.Presence=None, current_tab: str | None=None, current_mode: str | None=None, current_music_artist: str | None=None, def __init__(self, pypresence_client: None | FakePyPresence | pypresence.Presence=None, current_tab: str | None=None, current_mode: str | None=None, current_music_artist: str | None=None,
current_music_title: str | None=None, current_music_path: str | None=None, current_length: int | None=None, current_music_title: str | None=None, current_music_path: str | None=None, current_length: int | None=None,
current_music_player: pyglet.media.Player | None=None, queue: list | None=None, loaded_sounds: dict | None=None, shuffle: bool=False): current_music_player: pyglet.media.Player | None=None, current_synchronized_lyrics: str | None=None, queue: list | None=None, loaded_sounds: dict | None=None, shuffle: bool=False):
super().__init__() super().__init__()
self.pypresence_client = pypresence_client self.pypresence_client = pypresence_client
@@ -61,16 +62,19 @@ class Main(arcade.gui.UIView):
self.file_metadata = {} self.file_metadata = {}
self.tab_buttons = {} self.tab_buttons = {}
self.music_buttons = {} self.music_buttons = {}
self.queue = [] self.queue = queue or []
self.current_music_artist = current_music_artist self.current_music_artist = current_music_artist
self.current_music_title = current_music_title self.current_music_title = current_music_title
self.current_music_player = current_music_player self.current_music_player = current_music_player
self.current_music_path = current_music_path self.current_music_path = current_music_path
self.current_length = current_length if current_length else 0 self.current_length = current_length if current_length else 0
self.current_synchronized_lyrics = current_synchronized_lyrics if current_synchronized_lyrics else None
self.shuffle = shuffle self.shuffle = shuffle
self.volume = self.settings_dict.get("default_volume", 100) self.volume = self.settings_dict.get("default_volume", 100)
self.lyrics_times, self.parsed_lyrics = parse_synchronized_lyrics(self.current_synchronized_lyrics) if self.current_synchronized_lyrics else (None, None)
self.current_mode = current_mode if current_mode else "files" self.current_mode = current_mode if current_mode else "files"
self.current_tab = current_tab if current_tab else self.tab_options[0] self.current_tab = current_tab if current_tab else self.tab_options[0]
self.search_term = "" self.search_term = ""
@@ -112,10 +116,10 @@ class Main(arcade.gui.UIView):
if self.current_mode == "playlist" and not self.current_tab: if self.current_mode == "playlist" and not self.current_tab:
self.current_tab = list(self.playlist_content.keys())[0] if self.playlist_content else None self.current_tab = list(self.playlist_content.keys())[0] if self.playlist_content else None
# Scrollable Sounds # Scrollable Sounds and Lyrics
self.scroll_box = self.content_box.add(arcade.gui.UIBoxLayout(size_hint=(1, 0.90), space_between=15, vertical=False)) self.scroll_box = self.content_box.add(arcade.gui.UIBoxLayout(size_hint=(1, 0.90), space_between=15, vertical=False))
self.scroll_area = MouseAwareScrollArea(size_hint=(1, 1)) # center on screen self.scroll_area = MouseAwareScrollArea(size_hint=(0.8, 1)) # center on screen
self.scroll_area.scroll_speed = -50 self.scroll_area.scroll_speed = -50
self.scroll_box.add(self.scroll_area) self.scroll_box.add(self.scroll_area)
@@ -123,9 +127,17 @@ class Main(arcade.gui.UIView):
self.scrollbar.size_hint = (0.02, 1) self.scrollbar.size_hint = (0.02, 1)
self.scroll_box.add(self.scrollbar) self.scroll_box.add(self.scrollbar)
self.music_grid = arcade.gui.UIGridLayout(horizontal_spacing=10, vertical_spacing=10, row_count=99, column_count=8) self.music_grid = arcade.gui.UIGridLayout(horizontal_spacing=10, vertical_spacing=10, row_count=99, column_count=6)
self.scroll_area.add(self.music_grid) self.scroll_area.add(self.music_grid)
self.lyrics_box = self.scroll_box.add(arcade.gui.UIBoxLayout(space_between=5, size_hint=(0.25, 1), align="left"))
self.current_lyrics_label = arcade.gui.UILabel(size_hint=(0.2, 0.05), width=self.window.width * 0.2, multiline=True, font_size=16, font_name="Roboto", text_color=arcade.color.WHITE, text=self.current_synchronized_lyrics if self.current_synchronized_lyrics else "Play a song to get lyrics.")
self.lyrics_box.add(self.current_lyrics_label)
self.next_lyrics_label = arcade.gui.UILabel(size_hint=(0.2, 0.95), width=self.window.width * 0.2, multiline=True, font_size=16, font_name="Roboto", text_color=arcade.color.GRAY, text=self.current_synchronized_lyrics if self.current_synchronized_lyrics else "Play a song to get lyrics.")
self.lyrics_box.add(self.next_lyrics_label)
# Controls # Controls
self.now_playing_box = self.content_box.add(arcade.gui.UIBoxLayout(size_hint=(0.99, 0.075), space_between=10, vertical=False)) self.now_playing_box = self.content_box.add(arcade.gui.UIBoxLayout(size_hint=(0.99, 0.075), space_between=10, vertical=False))
@@ -270,6 +282,9 @@ class Main(arcade.gui.UIView):
self.current_music_player = None self.current_music_player = None
self.current_music_path = None self.current_music_path = None
self.progressbar.value = 0 self.progressbar.value = 0
self.current_synchronized_lyrics = None
self.lyrics_times = None
self.parsed_lyrics = None
self.current_music_thumbnail_image.texture = music_icon self.current_music_thumbnail_image.texture = music_icon
self.current_music_title_label.text = "No songs playing" self.current_music_title_label.text = "No songs playing"
self.full_length_label.text = "00:00" self.full_length_label.text = "00:00"
@@ -294,7 +309,7 @@ class Main(arcade.gui.UIView):
def view_metadata(self, file_path): def view_metadata(self, file_path):
from menus.metadata_viewer import MetadataViewer from menus.metadata_viewer import MetadataViewer
self.window.show_view(MetadataViewer(self.pypresence_client, "file", self.file_metadata[file_path], file_path, self.current_tab, self.current_mode, self.current_music_artist, self.current_music_title, self.current_music_path, self.current_length, self.current_music_player, self.queue, self.loaded_sounds, self.shuffle)) self.window.show_view(MetadataViewer(self.pypresence_client, "file", self.file_metadata[file_path], file_path, self.current_tab, self.current_mode, self.current_music_artist, self.current_music_title, self.current_music_path, self.current_length, self.current_music_player, self.current_synchronized_lyrics, self.queue, self.loaded_sounds, self.shuffle))
def show_content(self, tab, content_type): def show_content(self, tab, content_type):
for music_button in self.music_buttons.values(): for music_button in self.music_buttons.values():
@@ -328,8 +343,8 @@ class Main(arcade.gui.UIView):
row, col = 0, 0 row, col = 0, 0
for n, music_filename in enumerate(content_to_show): for n, music_filename in enumerate(content_to_show):
row = n // 8 row = n // self.music_grid.column_count
col = n % 8 col = n % self.music_grid.column_count
if self.current_mode == "files": if self.current_mode == "files":
music_path = f"{tab}/{music_filename}" music_path = f"{tab}/{music_filename}"
@@ -338,17 +353,17 @@ class Main(arcade.gui.UIView):
metadata = self.file_metadata[music_path] metadata = self.file_metadata[music_path]
self.music_buttons[music_path] = self.music_grid.add(Card(metadata["thumbnail"], get_wordwrapped_text(metadata["title"]), get_wordwrapped_text(metadata["artist"]), width=self.window.width / 9, height=self.window.width / 9), row=row, column=col) self.music_buttons[music_path] = self.music_grid.add(Card(metadata["thumbnail"], get_wordwrapped_text(metadata["title"]), get_wordwrapped_text(metadata["artist"]), width=self.window.width / (self.music_grid.column_count + 1), height=self.window.width / (self.music_grid.column_count + 1)), row=row, column=col)
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].button.on_click = lambda event, music_path=music_path: self.music_button_click(event, music_path)
row = (n + 1) // 8 row = (n + 1) // self.music_grid.column_count
col = (n + 1) % 8 col = (n + 1) % self.music_grid.column_count
self.music_grid.row_count = row + 1 self.music_grid.row_count = row + 1
self.music_grid._update_size_hints() self.music_grid._update_size_hints()
if self.current_mode == "playlist": if self.current_mode == "playlist":
self.music_buttons["add_music"] = self.music_grid.add(Card(music_icon, "Add Music", None, width=self.window.width / 9, height=self.window.width / 9), row=row, column=col) self.music_buttons["add_music"] = self.music_grid.add(Card(music_icon, "Add Music", None, width=self.window.width / (self.music_grid.column_count + 1), height=self.window.width / (self.music_grid.column_count + 1)), row=row, column=col)
self.music_buttons["add_music"].button.on_click = lambda event: self.add_music() self.music_buttons["add_music"].button.on_click = lambda event: self.add_music()
self.anchor.detect_focusable_widgets() self.anchor.detect_focusable_widgets()
@@ -365,7 +380,7 @@ class Main(arcade.gui.UIView):
with open("settings.json", "w") as file: with open("settings.json", "w") as file:
file.write(json.dumps(self.settings_dict, indent=4)) file.write(json.dumps(self.settings_dict, indent=4))
self.window.show_view(Main(self.pypresence_client, self.current_tab, self.current_mode, self.current_music_artist, self.current_music_title, self.current_music_path, self.current_length, self.current_music_player, self.queue, self.loaded_sounds, self.shuffle)) self.window.show_view(Main(self.pypresence_client, self.current_tab, self.current_mode, self.current_music_artist, self.current_music_title, self.current_music_path, self.current_length, self.current_music_player, self.current_synchronized_lyrics, self.queue, self.loaded_sounds, self.shuffle))
def load_content(self): def load_content(self):
self.tab_content.clear() self.tab_content.clear()
@@ -436,6 +451,17 @@ class Main(arcade.gui.UIView):
self.current_music_player.volume = self.volume / 100 self.current_music_player.volume = self.volume / 100
def on_update(self, delta_time): def on_update(self, delta_time):
if self.current_synchronized_lyrics:
closest_lyrics_time = get_closest_time(self.current_music_player.time, self.lyrics_times)
self.current_lyrics_label.text = self.parsed_lyrics.get(closest_lyrics_time, '[Music]') or '[Music]'
self.current_lyrics_label.fit_content()
if closest_lyrics_time in self.lyrics_times:
next_lyrics_times = self.lyrics_times[self.lyrics_times.index(closest_lyrics_time) + 1:self.lyrics_times.index(closest_lyrics_time) + 11]
self.next_lyrics_label.text = '\n'.join([self.parsed_lyrics[next_lyrics_time] for next_lyrics_time in next_lyrics_times])
else:
self.next_lyrics_label.text = '\n'.join(list(self.parsed_lyrics.values())[0:10])
if self.should_reload: if self.should_reload:
self.should_reload = False self.should_reload = False
self.reload() self.reload()
@@ -476,6 +502,8 @@ class Main(arcade.gui.UIView):
self.full_length_label.text = "00:00" self.full_length_label.text = "00:00"
self.progressbar.max_value = self.current_length self.progressbar.max_value = self.current_length
self.progressbar.value = 0 self.progressbar.value = 0
self.current_synchronized_lyrics = get_lyrics(self.current_music_artist, self.current_music_title)[1]
self.lyrics_times, self.parsed_lyrics = parse_synchronized_lyrics(self.current_synchronized_lyrics) if self.current_synchronized_lyrics else (None, None)
else: else:
if self.current_music_player is not None: if self.current_music_player is not None:
@@ -541,31 +569,31 @@ class Main(arcade.gui.UIView):
from menus.global_search import GlobalSearch from menus.global_search import GlobalSearch
arcade.unschedule(self.update_presence) arcade.unschedule(self.update_presence)
self.ui.clear() self.ui.clear()
self.window.show_view(GlobalSearch(self.pypresence_client, self.current_tab, self.current_mode, self.current_music_artist, self.current_music_title, self.current_music_path, self.current_length, self.current_music_player, self.queue, self.loaded_sounds, self.shuffle)) self.window.show_view(GlobalSearch(self.pypresence_client, self.current_tab, self.current_mode, self.current_music_artist, self.current_music_title, self.current_music_path, self.current_length, self.current_music_player, self.current_synchronized_lyrics, self.queue, self.loaded_sounds, self.shuffle))
def settings(self): def settings(self):
from menus.settings import Settings from menus.settings import Settings
arcade.unschedule(self.update_presence) arcade.unschedule(self.update_presence)
self.ui.clear() self.ui.clear()
self.window.show_view(Settings(self.pypresence_client, self.current_tab, self.current_mode, self.current_music_artist, self.current_music_title, self.current_music_path, self.current_length, self.current_music_player, self.queue, self.loaded_sounds, self.shuffle)) self.window.show_view(Settings(self.pypresence_client, self.current_tab, self.current_mode, self.current_music_artist, self.current_music_title, self.current_music_path, self.current_length, self.current_music_player, self.current_synchronized_lyrics, self.queue, self.loaded_sounds, self.shuffle))
def new_tab(self): def new_tab(self):
from menus.new_tab import NewTab from menus.new_tab import NewTab
arcade.unschedule(self.update_presence) arcade.unschedule(self.update_presence)
self.ui.clear() self.ui.clear()
self.window.show_view(NewTab(self.pypresence_client, self.current_tab, self.current_mode, self.current_music_artist, self.current_music_title, self.current_music_path, self.current_length, self.current_music_player, self.queue, self.loaded_sounds, self.shuffle)) self.window.show_view(NewTab(self.pypresence_client, self.current_tab, self.current_mode, self.current_music_artist, self.current_music_title, self.current_music_path, self.current_length, self.current_music_player, self.current_synchronized_lyrics, self.queue, self.loaded_sounds, self.shuffle))
def add_music(self): def add_music(self):
from menus.add_music import AddMusic from menus.add_music import AddMusic
arcade.unschedule(self.update_presence) arcade.unschedule(self.update_presence)
self.ui.clear() self.ui.clear()
self.window.show_view(AddMusic(self.pypresence_client, self.current_tab, self.current_mode, self.current_music_artist, self.current_music_title, self.current_music_path, self.current_length, self.current_music_player, self.queue, self.loaded_sounds, self.shuffle)) self.window.show_view(AddMusic(self.pypresence_client, self.current_tab, self.current_mode, self.current_music_artist, self.current_music_title, self.current_music_path, self.current_length, self.current_music_player, self.current_synchronized_lyrics, self.queue, self.loaded_sounds, self.shuffle))
def downloader(self): def downloader(self):
from menus.downloader import Downloader from menus.downloader import Downloader
arcade.unschedule(self.update_presence) arcade.unschedule(self.update_presence)
self.ui.clear() self.ui.clear()
self.window.show_view(Downloader(self.pypresence_client, self.current_tab, self.current_mode, self.current_music_artist, self.current_music_title, self.current_music_path, self.current_length, self.current_music_player, self.queue, self.loaded_sounds, self.shuffle)) self.window.show_view(Downloader(self.pypresence_client, self.current_tab, self.current_mode, self.current_music_artist, self.current_music_title, self.current_music_path, self.current_length, self.current_music_player, self.current_synchronized_lyrics, self.queue, self.loaded_sounds, self.shuffle))
def reload(self): def reload(self):
self.load_content() self.load_content()

View File

@@ -1,26 +1,54 @@
import arcade, arcade.gui, webbrowser import arcade, arcade.gui, webbrowser, os
from arcade.gui.experimental.focus import UIFocusGroup from arcade.gui.experimental.focus import UIFocusGroup
from arcade.gui.experimental.scroll_area import UIScrollArea, UIScrollBar from arcade.gui.experimental.scroll_area import UIScrollArea, UIScrollBar
from utils.online_metadata import get_music_metadata, download_albums_cover_art from utils.musicbrainz_metadata import get_music_metadata
from utils.cover_art import download_albums_cover_art
from utils.constants import button_style from utils.constants import button_style
from utils.preload import button_texture, button_hovered_texture from utils.preload import button_texture, button_hovered_texture
from utils.utils import convert_seconds_to_date from utils.utils import convert_seconds_to_date
from utils.music_handling import convert_timestamp_to_time_ago, truncate_end from utils.music_handling import convert_timestamp_to_time_ago, truncate_end, add_metadata_to_file
from utils.acoustid_metadata import get_recording_id_from_acoustic, get_fpcalc_path, download_fpcalc
class MetadataViewer(arcade.gui.UIView): class MetadataViewer(arcade.gui.UIView):
def __init__(self, pypresence_client, metadata_type="file", metadata=None, file_path=None, *args): def __init__(self, pypresence_client, metadata_type="file", metadata=None, file_path=None, *args):
super().__init__() super().__init__()
self.metadata_type = metadata_type self.metadata_type = metadata_type
self.pypresence_client = pypresence_client
self.args = args
self.more_metadata_buttons = []
self.metadata_labels = []
self.msgbox = None
if metadata_type == "file": if metadata_type == "file":
self.file_metadata = metadata self.file_metadata = metadata
self.file_path = file_path self.file_path = file_path
self.artist = self.file_metadata["artist"] if not self.file_metadata["artist"] == "Unknown" else None self.artist = self.file_metadata["artist"] if not self.file_metadata["artist"] == "Unknown" else None
self.title = self.file_metadata["title"] self.title = self.file_metadata["title"]
if metadata.get("confirm_download") is None:
if not os.path.exists(get_fpcalc_path()):
self.msgbox = self.add_widget(arcade.gui.UIMessageBox(width=self.window.width / 2, height=self.window.height / 2, title="Third-party fpcalc download", message_text="We need to download fpcalc from AcoustID to recognize the song for you for better results.\nIf you say no, we will use a searching algorithm instead which might give wrong results.\nEven if fpcalc is downloaded, it might not find the music since its a community-based project.\nIf so, we will fallback to the searching algorithm.\nDo you want to continue?", buttons=("Yes", "No")))
self.msgbox.on_action = lambda event: self.window.show_view(MetadataViewer(pypresence_client, metadata_type, metadata | {"confirm_download": event.action == "Yes"}, file_path, *args))
return
else:
self.acoustid_id, musicbrainz_id = get_recording_id_from_acoustic(self.file_path)
else:
if metadata["confirm_download"]:
download_fpcalc()
self.acoustid_id, musicbrainz_id = get_recording_id_from_acoustic(self.file_path)
else:
self.acoustid_id = None
if self.acoustid_id and musicbrainz_id:
self.music_metadata, self.artist_metadata, self.album_metadata, self.lyrics_metadata = get_music_metadata(musicbrainz_id=musicbrainz_id)
return
self.music_metadata, self.artist_metadata, self.album_metadata, self.lyrics_metadata = get_music_metadata(artist=self.artist, title=self.title)
self.music_metadata, self.artist_metadata, self.album_metadata, self.lyrics_metadata = get_music_metadata(self.artist, self.title)
elif metadata_type == "music": elif metadata_type == "music":
self.artist = metadata["artist"] self.artist = metadata["artist"]
self.title = metadata["title"] self.title = metadata["title"]
@@ -31,14 +59,15 @@ class MetadataViewer(arcade.gui.UIView):
elif metadata_type == "album": elif metadata_type == "album":
self.album_metadata = metadata self.album_metadata = metadata
self.pypresence_client = pypresence_client
self.args = args
self.more_metadata_buttons = []
self.metadata_labels = []
def on_show_view(self): def on_show_view(self):
super().on_show_view() super().on_show_view()
if self.msgbox:
return
if self.metadata_type == "file":
add_metadata_to_file(self.file_path, [artist['musicbrainz_id'] for artist in self.artist_metadata.values()], self.artist, self.title, self.lyrics_metadata[1], self.music_metadata["isrc-list"], self.acoustid_id)
self.anchor = self.add_widget(UIFocusGroup(size_hint=(1, 1))) self.anchor = self.add_widget(UIFocusGroup(size_hint=(1, 1)))
self.back_button = self.anchor.add(arcade.gui.UITextureButton(texture=button_texture, texture_hovered=button_hovered_texture, text='<--', style=button_style, width=100, height=50), anchor_x="left", anchor_y="top", align_x=5, align_y=-5) self.back_button = self.anchor.add(arcade.gui.UITextureButton(texture=button_texture, texture_hovered=button_hovered_texture, text='<--', style=button_style, width=100, height=50), anchor_x="left", anchor_y="top", align_x=5, align_y=-5)
@@ -98,7 +127,7 @@ Sample rate: {self.file_metadata['sample_rate']}KHz'''
else: else:
metadata_text = musicbrainz_metadata_text metadata_text = musicbrainz_metadata_text
metadata_text += f"\n\nLyrics:\n{self.lyrics_metadata}" metadata_text += f"\n\nLyrics:\n{self.lyrics_metadata[0]}"
self.more_metadata_buttons.append(self.more_metadata_box.add(arcade.gui.UITextureButton(text="Artist Metadata", style=button_style, texture=button_texture, texture_hovered=button_hovered_texture, width=self.window.width / 4.5 if self.metadata_type == "file" else self.window.width / 2.5, height=self.window.height / 15))) self.more_metadata_buttons.append(self.more_metadata_box.add(arcade.gui.UITextureButton(text="Artist Metadata", style=button_style, texture=button_texture, texture_hovered=button_hovered_texture, width=self.window.width / 4.5 if self.metadata_type == "file" else self.window.width / 2.5, height=self.window.height / 15)))
self.more_metadata_buttons[-1].on_click = lambda event: self.show_artist_metadata() self.more_metadata_buttons[-1].on_click = lambda event: self.show_artist_metadata()

View File

@@ -9,6 +9,7 @@ dependencies = [
"iso3166>=2.1.1", "iso3166>=2.1.1",
"musicbrainzngs>=0.7.1", "musicbrainzngs>=0.7.1",
"mutagen>=1.47.0", "mutagen>=1.47.0",
"pyacoustid>=1.3.0",
"pydub>=0.25.1", "pydub>=0.25.1",
"pypresence>=4.3.0", "pypresence>=4.3.0",
"thefuzz>=0.22.1", "thefuzz>=0.22.1",

View File

@@ -4,8 +4,16 @@ arcade==3.2.0
# via musicplayer (pyproject.toml) # via musicplayer (pyproject.toml)
attrs==25.3.0 attrs==25.3.0
# via pytiled-parser # via pytiled-parser
audioread==3.0.1
# via pyacoustid
certifi==2025.7.9
# via requests
cffi==1.17.1 cffi==1.17.1
# via pymunk # via pymunk
charset-normalizer==3.4.2
# via requests
idna==3.10
# via requests
iso3166==2.1.1 iso3166==2.1.1
# via musicplayer (pyproject.toml) # via musicplayer (pyproject.toml)
musicbrainzngs==0.7.1 musicbrainzngs==0.7.1
@@ -14,6 +22,8 @@ mutagen==1.47.0
# via musicplayer (pyproject.toml) # via musicplayer (pyproject.toml)
pillow==11.0.0 pillow==11.0.0
# via arcade # via arcade
pyacoustid==1.3.0
# via musicplayer (pyproject.toml)
pycparser==2.22 pycparser==2.22
# via cffi # via cffi
pydub==0.25.1 pydub==0.25.1
@@ -28,9 +38,13 @@ pytiled-parser==2.2.9
# via arcade # via arcade
rapidfuzz==3.13.0 rapidfuzz==3.13.0
# via thefuzz # via thefuzz
requests==2.32.4
# via pyacoustid
thefuzz==0.22.1 thefuzz==0.22.1
# via musicplayer (pyproject.toml) # via musicplayer (pyproject.toml)
typing-extensions==4.14.1 typing-extensions==4.14.1
# via pytiled-parser # via pytiled-parser
urllib3==2.5.0
# via requests
watchdog==6.0.0 watchdog==6.0.0
# via musicplayer (pyproject.toml) # via musicplayer (pyproject.toml)

View File

@@ -0,0 +1,75 @@
import os, platform, tarfile, acoustid, urllib.request, shutil, gzip, glob, logging, sys, io
from utils.constants import ACOUSTID_API_KEY
from zipfile import ZipFile
def get_fpcalc_name():
system = platform.system()
if system == "Linux" or system == "Darwin":
return "fpcalc"
elif system == "Windows":
return "fpcalc.exe"
def get_fpcalc_path():
return os.path.join(os.getcwd(), "bin", get_fpcalc_name())
def download_fpcalc():
system = platform.system()
architecture = platform.machine()
if system == "Linux":
url = "https://github.com/acoustid/chromaprint/releases/download/v1.5.1/chromaprint-fpcalc-1.5.1-linux-x86_64.tar.gz"
elif system == "Darwin":
if architecture.lower() == "x86_64" or architecture.lower() == "amd64":
url = "https://github.com/acoustid/chromaprint/releases/download/v1.5.1/chromaprint-fpcalc-1.5.1-macos-x86_64.tar.gz"
else:
url = "https://github.com/acoustid/chromaprint/releases/download/v1.5.1/chromaprint-fpcalc-1.5.1-macos-arm64.tar.gz"
elif system == "Windows":
url = "https://github.com/acoustid/chromaprint/releases/download/v1.5.1/chromaprint-fpcalc-1.5.1-windows-x86_64.zip"
if url.endswith(".zip"):
zip_path = os.path.join(os.getcwd(), "bin", "chromaprint.zip")
urllib.request.urlretrieve(url, zip_path)
with ZipFile(zip_path) as file:
file.extractall(os.path.join(os.getcwd(), "bin"))
os.remove(zip_path)
else:
tar_gz_path = os.path.join(os.getcwd(), "bin", "chromaprint.tar.gz")
urllib.request.urlretrieve(url, tar_gz_path)
with gzip.open(tar_gz_path, "rb") as f: # For some reason, tarfile by itself didnt work for tar.gz so i have to uncompress with gzip first and then with tarfile
with tarfile.open(fileobj=io.BytesIO(f.read())) as tar:
tar.extractall(os.path.join(os.getcwd(), "bin"))
os.remove(tar_gz_path)
chromaprint_matches = glob.glob(os.path.join("bin", "chromaprint*"))
if chromaprint_matches:
shutil.move(os.path.join(chromaprint_matches[0], get_fpcalc_name()), os.path.join("bin", get_fpcalc_name()))
shutil.rmtree(chromaprint_matches[0])
os.chmod(get_fpcalc_path(), 0o755)
def get_recording_id_from_acoustic(filename):
os.environ["FPCALC"] = get_fpcalc_path()
try:
results = acoustid.match(ACOUSTID_API_KEY, filename, meta=['recordings'], force_fpcalc=True, parse=False)["results"]
except acoustid.NoBackendError:
logging.debug("ChromaPrint library/tool not found")
return None, None
except acoustid.FingerprintGenerationError:
logging.debug("Fingerprint could not be calculated")
return None, None
except acoustid.WebServiceError as exc:
logging.debug(f"Web service request failed: {exc}")
return None, None
if not results:
return None, None
result = results[0]
return result["id"], result["recordings"][0]["id"]

View File

@@ -6,10 +6,13 @@ from arcade.gui.widgets.slider import UISliderStyle
menu_background_color = (17, 17, 17) menu_background_color = (17, 17, 17)
log_dir = 'logs' log_dir = 'logs'
discord_presence_id = 1368277020332523530 discord_presence_id = 1368277020332523530
audio_extensions = ["mp3", "m4a", "aac", "flac", "ogg", "opus", "wav"] audio_extensions = ["mp3"]
view_modes = ["files", "playlist"] view_modes = ["files", "playlist"]
MUSIC_TITLE_WORD_BLACKLIST = ["compilation", "remix", "vs", "cover", "version", "instrumental", "restrung", "interlude"]
COVER_CACHE_DIR = "cover_cache" COVER_CACHE_DIR = "cover_cache"
ACOUSTID_API_KEY = 'PuUkMEnUXf'
LRCLIB_BASE_URL = "https://lrclib.net/api/search"
MUSICBRAINZ_PROJECT_NAME = "csd4ni3l/music-player" MUSICBRAINZ_PROJECT_NAME = "csd4ni3l/music-player"
MUSCIBRAINZ_VERSION = "git" MUSCIBRAINZ_VERSION = "git"

52
utils/cover_art.py Normal file
View File

@@ -0,0 +1,52 @@
from io import BytesIO
from PIL import Image
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.request import urlopen, Request
from urllib.error import URLError, HTTPError
from utils.constants import COVER_CACHE_DIR, MUSCIBRAINZ_VERSION, MUSICBRAINZ_CONTACT, MUSICBRAINZ_PROJECT_NAME
import musicbrainzngs as music_api
import os, logging, arcade
def fetch_image_bytes(url):
try:
req = Request(url, headers={"User-Agent": "csd4ni3l/music-player/git python-musicbrainzngs/0.7.1 ( csd4ni3l@proton.me )"})
with urlopen(req, timeout=10) as resp:
return resp.read()
except (HTTPError, URLError) as e:
logging.debug(f"Error fetching {url}: {e}")
return None
def download_cover_art(mb_album_id, size=250):
path = os.path.join(COVER_CACHE_DIR, f"{mb_album_id}_{size}.png")
if os.path.exists(path):
return mb_album_id, Image.open(path)
url = f"https://coverartarchive.org/release/{mb_album_id}/front-{size}"
img_bytes = fetch_image_bytes(url)
if not img_bytes:
return mb_album_id, None
try:
img = Image.open(BytesIO(img_bytes)).convert("RGBA")
img.save(path)
return mb_album_id, img
except Exception as e:
logging.debug(f"Failed to decode/save image for {mb_album_id}: {e}")
return mb_album_id, None
def download_albums_cover_art(album_ids, size=250, max_workers=5):
music_api.set_useragent(MUSICBRAINZ_PROJECT_NAME, MUSCIBRAINZ_VERSION, MUSICBRAINZ_CONTACT)
os.makedirs(COVER_CACHE_DIR, exist_ok=True)
images = {}
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [executor.submit(download_cover_art, album_id, size) for album_id in album_ids]
for future in as_completed(futures):
album_id, img = future.result()
images[album_id] = arcade.Texture(img) if img else None
return images

58
utils/lyrics_metadata.py Normal file
View File

@@ -0,0 +1,58 @@
import urllib.parse, urllib.request, json
from utils.utils import ensure_metadata_file
from utils.constants import LRCLIB_BASE_URL
def convert_syncronized_time_to_seconds(synchronized_time):
minutes_str, seconds_str = synchronized_time.split(":")
return float(minutes_str) * 60 + float(seconds_str)
def parse_synchronized_lyrics(synchronized_lyrics: str):
lyrics_list = {}
for lyrics_line in synchronized_lyrics.splitlines():
uncleaned_date, text = lyrics_line.split("] ")
cleaned_date = uncleaned_date.replace("[", "")
lyrics_list[convert_syncronized_time_to_seconds(cleaned_date)] = text
return list(lyrics_list.keys()), lyrics_list
def get_closest_time(current_time, lyrics_times):
closest_time = 0
for lyrics_time in lyrics_times:
if lyrics_time <= current_time and lyrics_time > closest_time:
closest_time = lyrics_time
return closest_time
def get_lyrics(artist, title):
metadata_cache = ensure_metadata_file()
if (artist, title) in metadata_cache["lyrics_by_artist_title"]:
return metadata_cache["lyrics_by_artist_title"][(artist, title)]
else:
if artist:
query = f"{artist} - {title}"
else:
query = title
query_string = urllib.parse.urlencode({"q": query})
full_url = f"{LRCLIB_BASE_URL}?{query_string}"
with urllib.request.urlopen(full_url) as request:
data = json.loads(request.read().decode("utf-8"))
for result in data:
if result.get("plainLyrics") and result.get("syncedLyrics"):
metadata_cache["lyrics_by_artist_title"][(artist, title)] = (result["plainLyrics"], result["syncedLyrics"])
return (result["plainLyrics"], result["syncedLyrics"])
with open("metadata_cache.json", "w") as file:
file.write(json.dumps(metadata_cache))
if artist: # if there was an artist, it might have been misleading. For example, on Youtube, the uploader might not be the artist. We retry with only title.
return get_lyrics(None, title)
return [None, None]

View File

@@ -1,12 +1,12 @@
import io, base64, tempfile, struct, re, os, logging, arcade, time import io, tempfile, re, os, logging, arcade, time
from mutagen.easyid3 import EasyID3 from mutagen.easyid3 import EasyID3
from mutagen.id3 import ID3, TXXX, ID3NoHeaderError from mutagen.id3 import ID3, TXXX, SYLT, ID3NoHeaderError
from mutagen import File
from pydub import AudioSegment from pydub import AudioSegment
from PIL import Image from PIL import Image
from utils.lyrics_metadata import parse_synchronized_lyrics
from utils.utils import convert_seconds_to_date from utils.utils import convert_seconds_to_date
def truncate_end(text: str, max_length: int) -> str: def truncate_end(text: str, max_length: int) -> str:
@@ -16,7 +16,7 @@ def truncate_end(text: str, max_length: int) -> str:
return text return text
return text[:max_length - 3] + '...' return text[:max_length - 3] + '...'
def extract_metadata_and_thumbnail(file_path: str, thumb_resolution: tuple) -> tuple: def extract_metadata_and_thumbnail(file_path: str, thumb_resolution: tuple):
artist = "Unknown" artist = "Unknown"
title = "" title = ""
source_url = "Unknown" source_url = "Unknown"
@@ -31,55 +31,40 @@ def extract_metadata_and_thumbnail(file_path: str, thumb_resolution: tuple) -> t
basename = os.path.basename(file_path) basename = os.path.basename(file_path)
name_only = os.path.splitext(basename)[0] name_only = os.path.splitext(basename)[0]
ext = os.path.splitext(file_path)[1].lower().lstrip('.')
try: try:
thumb_audio = EasyID3(file_path)
try: try:
artist = str(thumb_audio["artist"][0]) easyid3 = EasyID3(file_path)
title = str(thumb_audio["title"][0]) if "artist" in easyid3:
upload_year = int(thumb_audio["date"][0]) artist = easyid3["artist"][0]
except KeyError: if "title" in easyid3:
artist_title_match = re.search(r'^.+\s*-\s*.+$', title) title = easyid3["title"][0]
if artist_title_match: if "date" in easyid3:
title = title.split("- ")[1] upload_year = int(re.match(r"\d{4}", easyid3["date"][0]).group())
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) id3 = ID3(file_path)
for frame in id3.getall("WXXX"): for frame in id3.getall("WXXX"):
if frame.desc.lower() == "uploader": desc = frame.desc.lower()
if desc == "uploader":
uploader_url = frame.url uploader_url = frame.url
elif frame.desc.lower() == "source": elif desc == "source":
source_url = frame.url source_url = frame.url
for frame in id3.getall("TXXX"): for frame in id3.getall("TXXX"):
if frame.desc.lower() == "last_played": desc = frame.desc.lower()
if desc == "last_played":
last_played = float(frame.text[0]) last_played = float(frame.text[0])
elif frame.desc.lower() == "play_count": elif desc == "play_count":
play_count = int(frame.text[0]) play_count = int(frame.text[0])
except ID3NoHeaderError:
pass
if hasattr(easyid3, "info"):
sound_length = round(easyid3.info.length, 2)
bitrate = int((easyid3.info.bitrate or 0) / 1000)
sample_rate = int(easyid3.info.sample_rate / 1000)
apic = id3.getall("APIC")
thumb_image_data = apic[0].data if apic else None
if thumb_image_data: if thumb_image_data:
pil_image = Image.open(io.BytesIO(thumb_image_data)).convert("RGBA") pil_image = Image.open(io.BytesIO(thumb_image_data)).convert("RGBA")
@@ -90,13 +75,10 @@ def extract_metadata_and_thumbnail(file_path: str, thumb_resolution: tuple) -> t
logging.debug(f"[Metadata/Thumbnail Error] {file_path}: {e}") logging.debug(f"[Metadata/Thumbnail Error] {file_path}: {e}")
if artist == "Unknown" or not title: if artist == "Unknown" or not title:
match = re.search(r'^(.*?)\s+-\s+(.*?)$', name_only) m = re.match(r"^(.*?)\s+-\s+(.*?)$", name_only) # check for artist - title titles in the title
if match: if m:
file_path_artist, file_path_title = match.groups() artist = m.group(1)
if artist == "Unknown": title = m.group(2)
artist = file_path_artist
if not title:
title = file_path_title
if not title: if not title:
title = name_only title = name_only
@@ -105,7 +87,7 @@ def extract_metadata_and_thumbnail(file_path: str, thumb_resolution: tuple) -> t
from utils.preload import music_icon from utils.preload import music_icon
thumb_texture = music_icon thumb_texture = music_icon
file_size = round(os.path.getsize(file_path) / (1024 ** 2), 2) # MiB file_size = round(os.path.getsize(file_path) / (1024 ** 2), 2)
return { return {
"sound_length": sound_length, "sound_length": sound_length,
@@ -119,9 +101,10 @@ def extract_metadata_and_thumbnail(file_path: str, thumb_resolution: tuple) -> t
"source_url": source_url, "source_url": source_url,
"artist": artist, "artist": artist,
"title": title, "title": title,
"thumbnail": thumb_texture "thumbnail": thumb_texture,
} }
def adjust_volume(input_path, volume): def adjust_volume(input_path, volume):
audio = AudioSegment.from_file(input_path) audio = AudioSegment.from_file(input_path)
change = volume - audio.dBFS change = volume - audio.dBFS
@@ -191,3 +174,25 @@ def convert_timestamp_to_time_ago(timestamp):
return convert_seconds_to_date(elapsed_time) + ' ago' return convert_seconds_to_date(elapsed_time) + ' ago'
else: else:
return "Never" return "Never"
def add_metadata_to_file(file_path, musicbrainz_artist_ids, artist, title, synchronized_lyrics, isrc, acoustid_id=None):
easyid3 = EasyID3(file_path)
easyid3["musicbrainz_artistid"] = musicbrainz_artist_ids
easyid3["artist"] = artist
easyid3["title"] = title
easyid3["isrc"] = isrc
if acoustid_id:
easyid3["acoustid_id"] = acoustid_id
easyid3.save()
id3 = ID3(file_path)
id3.delall("SYLT")
lyrics_dict = parse_synchronized_lyrics(synchronized_lyrics)[1]
synchronized_lyrics_tuples = [(text, int(lyrics_time * 1000)) for lyrics_time, text in lyrics_dict.items()] # * 1000 because format 2 means milliseconds
id3.add(SYLT(encoding=3, lang="eng", format=2, type=1, desc="From lrclib", text=synchronized_lyrics_tuples))
id3.save()

View File

@@ -1,20 +1,10 @@
import musicbrainzngs as music_api import musicbrainzngs as music_api
from io import BytesIO from utils.constants import MUSICBRAINZ_PROJECT_NAME, MUSICBRAINZ_CONTACT, MUSCIBRAINZ_VERSION, MUSIC_TITLE_WORD_BLACKLIST
from utils.lyrics_metadata import get_lyrics
from utils.utils import ensure_metadata_file
from PIL import Image import json, iso3166
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.request import urlopen, Request
from urllib.error import URLError, HTTPError
from utils.constants import MUSICBRAINZ_PROJECT_NAME, MUSICBRAINZ_CONTACT, MUSCIBRAINZ_VERSION, COVER_CACHE_DIR
import urllib.request, json, os, arcade, logging, iso3166
WORD_BLACKLIST = ["compilation", "remix", "vs", "cover", "version", "instrumental", "restrung", "interlude"]
LRCLIB_BASE_URL = "https://lrclib.net/api/search"
def get_country(code): def get_country(code):
country = iso3166.countries.get(code, None) country = iso3166.countries.get(code, None)
@@ -24,9 +14,9 @@ def check_blacklist(text, blacklist):
return any(word in text for word in blacklist) return any(word in text for word in blacklist)
def finalize_blacklist(title): def finalize_blacklist(title):
blacklist = WORD_BLACKLIST[:] blacklist = MUSIC_TITLE_WORD_BLACKLIST[:]
for word in WORD_BLACKLIST: for word in MUSIC_TITLE_WORD_BLACKLIST:
if word in title: if word in title:
blacklist.remove(word) blacklist.remove(word)
@@ -35,21 +25,6 @@ def finalize_blacklist(title):
def is_release_valid(release): def is_release_valid(release):
return release.get("release-event-count", 0) == 0 # only include albums return release.get("release-event-count", 0) == 0 # only include albums
def ensure_metadata_file():
if os.path.exists("metadata_cache.json") and os.path.isfile("metadata_cache.json"):
with open("metadata_cache.json", "r") as file:
metadata_cache = json.load(file)
else:
metadata_cache = {
"query_results": {},
"recording_by_id": {},
"artist_by_id": {},
"lyrics_by_artist_title": {},
"album_by_id": {}
}
return metadata_cache
def get_artists_metadata(artist_ids): def get_artists_metadata(artist_ids):
metadata_cache = ensure_metadata_file() metadata_cache = ensure_metadata_file()
@@ -121,6 +96,7 @@ def extract_release_metadata(release_list):
"album_name": release.get("title") if release else "Unknown", "album_name": release.get("title") if release else "Unknown",
"album_date": release.get("date") if release else "Unknown", "album_date": release.get("date") if release else "Unknown",
"album_country": (get_country(release.get("country", "WZ")) or "Worldwide") if release else "Unknown", "album_country": (get_country(release.get("country", "WZ")) or "Worldwide") if release else "Unknown",
"album_tracks": [track['recording']['title'] for track in release.get('medium-list', [{}])[0].get('track-list', [])[:3]]
} }
metadata_cache["album_by_id"][release_id] = album_metadata[release_id] metadata_cache["album_by_id"][release_id] = album_metadata[release_id]
@@ -142,7 +118,7 @@ def get_album_metadata(album_id):
"album_name": release.get("title") if release else "Unknown", "album_name": release.get("title") if release else "Unknown",
"album_date": release.get("date") if release else "Unknown", "album_date": release.get("date") if release else "Unknown",
"album_country": (get_country(release.get("country", "WZ")) or "Worldwide") if release else "Unknown", "album_country": (get_country(release.get("country", "WZ")) or "Worldwide") if release else "Unknown",
"album_tracks": [track['recording']['title'] for track in release.get('medium-list', [])[0].get('track-list', {})[:3]] "album_tracks": [track['recording']['title'] for track in release.get('medium-list', [{}])[0].get('track-list', [])[:3]]
} }
metadata_cache["album_by_id"][release["id"]] = album_metadata metadata_cache["album_by_id"][release["id"]] = album_metadata
@@ -162,6 +138,8 @@ def get_music_metadata(artist=None, title=None, musicbrainz_id=None):
else: else:
query = title query = title
recording_id = None
if query in metadata_cache["query_results"]: if query in metadata_cache["query_results"]:
recording_id = metadata_cache["query_results"][query] recording_id = metadata_cache["query_results"][query]
else: else:
@@ -186,6 +164,7 @@ def get_music_metadata(artist=None, title=None, musicbrainz_id=None):
if recording_id in metadata_cache["recording_by_id"]: if recording_id in metadata_cache["recording_by_id"]:
detailed = metadata_cache["recording_by_id"][recording_id] detailed = metadata_cache["recording_by_id"][recording_id]
else: else:
if recording_id:
detailed = music_api.get_recording_by_id( detailed = music_api.get_recording_by_id(
recording_id, recording_id,
includes=["artists", "releases", "isrcs", "tags", "ratings"] includes=["artists", "releases", "isrcs", "tags", "ratings"]
@@ -199,6 +178,16 @@ def get_music_metadata(artist=None, title=None, musicbrainz_id=None):
"release-list": [{"id": release["id"], "title": release["title"], "status": release.get("status"), "date": release.get("date"), "country": release.get("country", "WZ")} for release in detailed["release-list"]] if "release-list" in detailed else [], "release-list": [{"id": release["id"], "title": release["title"], "status": release.get("status"), "date": release.get("date"), "country": release.get("country", "WZ")} for release in detailed["release-list"]] if "release-list" in detailed else [],
"release-event-count": detailed.get("release-event-count", 0) "release-event-count": detailed.get("release-event-count", 0)
} }
else:
detailed = metadata_cache["recording_by_id"][recording_id] = {
"title": title,
"artist-credit": [],
"isrc-list": [],
"rating": {},
"tags": [],
"release-list": [],
"release-event-count": 0
}
with open("metadata_cache.json", "w") as file: with open("metadata_cache.json", "w") as file:
file.write(json.dumps(metadata_cache)) file.write(json.dumps(metadata_cache))
@@ -213,73 +202,7 @@ def get_music_metadata(artist=None, title=None, musicbrainz_id=None):
"musicbrainz_rating": detailed["rating"]["rating"] if "rating" in detailed.get("rating", {}) else "Unknown", "musicbrainz_rating": detailed["rating"]["rating"] if "rating" in detailed.get("rating", {}) else "Unknown",
"tags": [tag["name"] for tag in detailed.get("tag-list", [])] "tags": [tag["name"] for tag in detailed.get("tag-list", [])]
} }
return music_metadata, artist_metadata, album_metadata, get_lyrics(', '.join([artist for artist in artist_metadata]), detailed["title"])[0] return music_metadata, artist_metadata, album_metadata, get_lyrics(', '.join([artist for artist in artist_metadata]), detailed["title"])
def get_lyrics(artist, title):
metadata_cache = ensure_metadata_file()
if (artist, title) in metadata_cache["lyrics_by_artist_title"]:
return metadata_cache["lyrics_by_artist_title"][(artist, title)]
else:
if artist:
query = f"{artist} - {title}"
else:
query = title
query_string = urllib.parse.urlencode({"q": query})
full_url = f"{LRCLIB_BASE_URL}?{query_string}"
with urllib.request.urlopen(full_url) as request:
data = json.loads(request.read().decode("utf-8"))
for result in data:
if result.get("plainLyrics") and result.get("syncedLyrics"):
metadata_cache["lyrics_by_artist_title"][(artist, title)] = (result["plainLyrics"], result["syncedLyrics"])
return (result["plainLyrics"], result["syncedLyrics"])
with open("metadata_cache.json", "w") as file:
file.write(json.dumps(metadata_cache))
if artist: # if there was an artist, it might have been misleading. For example, on Youtube, the uploader might not be the artist. We retry with only title.
return get_lyrics(None, title)
def fetch_image_bytes(url):
try:
req = Request(url, headers={"User-Agent": "csd4ni3l/music-player/git python-musicbrainzngs/0.7.1 ( csd4ni3l@proton.me )"})
with urlopen(req, timeout=10) as resp:
return resp.read()
except (HTTPError, URLError) as e:
logging.debug(f"Error fetching {url}: {e}")
return None
def download_cover_art(mb_album_id, size=250):
path = os.path.join(COVER_CACHE_DIR, f"{mb_album_id}_{size}.png")
if os.path.exists(path):
return mb_album_id, Image.open(path)
url = f"https://coverartarchive.org/release/{mb_album_id}/front-{size}"
img_bytes = fetch_image_bytes(url)
if not img_bytes:
return mb_album_id, None
try:
img = Image.open(BytesIO(img_bytes)).convert("RGBA")
img.save(path)
return mb_album_id, img
except Exception as e:
logging.debug(f"Failed to decode/save image for {mb_album_id}: {e}")
return mb_album_id, None
def download_albums_cover_art(album_ids, size=250, max_workers=5):
music_api.set_useragent(MUSICBRAINZ_PROJECT_NAME, MUSCIBRAINZ_VERSION, MUSICBRAINZ_CONTACT)
os.makedirs(COVER_CACHE_DIR, exist_ok=True)
images = {}
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [executor.submit(download_cover_art, album_id, size) for album_id in album_ids]
for future in as_completed(futures):
album_id, img = future.result()
images[album_id] = arcade.Texture(img) if img else None
return images
def search_recordings(search_term): def search_recordings(search_term):
music_api.set_useragent(MUSICBRAINZ_PROJECT_NAME, MUSCIBRAINZ_VERSION, MUSICBRAINZ_CONTACT) music_api.set_useragent(MUSICBRAINZ_PROJECT_NAME, MUSCIBRAINZ_VERSION, MUSICBRAINZ_CONTACT)

View File

@@ -1,4 +1,4 @@
import logging, sys, traceback, pyglet, arcade, arcade.gui, textwrap import logging, sys, traceback, pyglet, arcade, arcade.gui, textwrap, os, json
from utils.constants import menu_background_color from utils.constants import menu_background_color
@@ -197,3 +197,18 @@ def get_wordwrapped_text(text, width=18):
output_text = '\n'.join(textwrap.wrap(text, width=width)) output_text = '\n'.join(textwrap.wrap(text, width=width))
return output_text return output_text
def ensure_metadata_file():
if os.path.exists("metadata_cache.json") and os.path.isfile("metadata_cache.json"):
with open("metadata_cache.json", "r") as file:
metadata_cache = json.load(file)
else:
metadata_cache = {
"query_results": {},
"recording_by_id": {},
"artist_by_id": {},
"lyrics_by_artist_title": {},
"album_by_id": {}
}
return metadata_cache

111
uv.lock generated
View File

@@ -26,6 +26,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload_time = "2025-03-13T11:10:21.14Z" }, { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload_time = "2025-03-13T11:10:21.14Z" },
] ]
[[package]]
name = "audioread"
version = "3.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/db/d2/87016ca9f083acadffb2d8da59bfa3253e4da7eeb9f71fb8e7708dc97ecd/audioread-3.0.1.tar.gz", hash = "sha256:ac5460a5498c48bdf2e8e767402583a4dcd13f4414d286f42ce4379e8b35066d", size = 116513, upload_time = "2023-09-27T19:27:53.084Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/8d/30aa32745af16af0a9a650115fbe81bde7c610ed5c21b381fca0196f3a7f/audioread-3.0.1-py3-none-any.whl", hash = "sha256:4cdce70b8adc0da0a3c9e0d85fb10b3ace30fbdf8d1670fd443929b61d117c33", size = 23492, upload_time = "2023-09-27T19:27:51.334Z" },
]
[[package]]
name = "certifi"
version = "2025.7.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/de/8a/c729b6b60c66a38f590c4e774decc4b2ec7b0576be8f1aa984a53ffa812a/certifi-2025.7.9.tar.gz", hash = "sha256:c1d2ec05395148ee10cf672ffc28cd37ea0ab0d99f9cc74c43e588cbd111b079", size = 160386, upload_time = "2025-07-09T02:13:58.874Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/66/f3/80a3f974c8b535d394ff960a11ac20368e06b736da395b551a49ce950cce/certifi-2025.7.9-py3-none-any.whl", hash = "sha256:d842783a14f8fdd646895ac26f719a061408834473cfc10203f6a575beb15d39", size = 159230, upload_time = "2025-07-09T02:13:57.007Z" },
]
[[package]] [[package]]
name = "cffi" name = "cffi"
version = "1.17.1" version = "1.17.1"
@@ -71,6 +89,63 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload_time = "2024-09-04T20:44:45.309Z" }, { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload_time = "2024-09-04T20:44:45.309Z" },
] ]
[[package]]
name = "charset-normalizer"
version = "3.4.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload_time = "2025-05-02T08:34:42.01Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload_time = "2025-05-02T08:32:11.945Z" },
{ url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload_time = "2025-05-02T08:32:13.946Z" },
{ url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload_time = "2025-05-02T08:32:15.873Z" },
{ url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload_time = "2025-05-02T08:32:17.283Z" },
{ url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload_time = "2025-05-02T08:32:18.807Z" },
{ url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload_time = "2025-05-02T08:32:20.333Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload_time = "2025-05-02T08:32:21.86Z" },
{ url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload_time = "2025-05-02T08:32:23.434Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload_time = "2025-05-02T08:32:24.993Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload_time = "2025-05-02T08:32:26.435Z" },
{ url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload_time = "2025-05-02T08:32:28.376Z" },
{ url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload_time = "2025-05-02T08:32:30.281Z" },
{ url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload_time = "2025-05-02T08:32:32.191Z" },
{ url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload_time = "2025-05-02T08:32:33.712Z" },
{ url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload_time = "2025-05-02T08:32:35.768Z" },
{ url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload_time = "2025-05-02T08:32:37.284Z" },
{ url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload_time = "2025-05-02T08:32:38.803Z" },
{ url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload_time = "2025-05-02T08:32:40.251Z" },
{ url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload_time = "2025-05-02T08:32:41.705Z" },
{ url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload_time = "2025-05-02T08:32:43.709Z" },
{ url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload_time = "2025-05-02T08:32:46.197Z" },
{ url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload_time = "2025-05-02T08:32:48.105Z" },
{ url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload_time = "2025-05-02T08:32:49.719Z" },
{ url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload_time = "2025-05-02T08:32:51.404Z" },
{ url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload_time = "2025-05-02T08:32:53.079Z" },
{ url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload_time = "2025-05-02T08:32:54.573Z" },
{ url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload_time = "2025-05-02T08:32:56.363Z" },
{ url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload_time = "2025-05-02T08:32:58.551Z" },
{ url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload_time = "2025-05-02T08:33:00.342Z" },
{ url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload_time = "2025-05-02T08:33:02.081Z" },
{ url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload_time = "2025-05-02T08:33:04.063Z" },
{ url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload_time = "2025-05-02T08:33:06.418Z" },
{ url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload_time = "2025-05-02T08:33:08.183Z" },
{ url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload_time = "2025-05-02T08:33:09.986Z" },
{ url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload_time = "2025-05-02T08:33:11.814Z" },
{ url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload_time = "2025-05-02T08:33:13.707Z" },
{ url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload_time = "2025-05-02T08:33:15.458Z" },
{ url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload_time = "2025-05-02T08:33:17.06Z" },
{ url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload_time = "2025-05-02T08:33:18.753Z" },
{ url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload_time = "2025-05-02T08:34:40.053Z" },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload_time = "2024-09-15T18:07:39.745Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload_time = "2024-09-15T18:07:37.964Z" },
]
[[package]] [[package]]
name = "iso3166" name = "iso3166"
version = "2.1.1" version = "2.1.1"
@@ -98,6 +173,7 @@ dependencies = [
{ name = "iso3166" }, { name = "iso3166" },
{ name = "musicbrainzngs" }, { name = "musicbrainzngs" },
{ name = "mutagen" }, { name = "mutagen" },
{ name = "pyacoustid" },
{ name = "pydub" }, { name = "pydub" },
{ name = "pypresence" }, { name = "pypresence" },
{ name = "thefuzz" }, { name = "thefuzz" },
@@ -110,6 +186,7 @@ requires-dist = [
{ name = "iso3166", specifier = ">=2.1.1" }, { name = "iso3166", specifier = ">=2.1.1" },
{ name = "musicbrainzngs", specifier = ">=0.7.1" }, { name = "musicbrainzngs", specifier = ">=0.7.1" },
{ name = "mutagen", specifier = ">=1.47.0" }, { name = "mutagen", specifier = ">=1.47.0" },
{ name = "pyacoustid", specifier = ">=1.3.0" },
{ name = "pydub", specifier = ">=0.25.1" }, { name = "pydub", specifier = ">=0.25.1" },
{ name = "pypresence", specifier = ">=4.3.0" }, { name = "pypresence", specifier = ">=4.3.0" },
{ name = "thefuzz", specifier = ">=0.22.1" }, { name = "thefuzz", specifier = ">=0.22.1" },
@@ -174,6 +251,16 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/51/85/9c33f2517add612e17f3381aee7c4072779130c634921a756c97bc29fb49/pillow-11.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3", size = 2256828, upload_time = "2024-10-15T14:23:39.826Z" }, { url = "https://files.pythonhosted.org/packages/51/85/9c33f2517add612e17f3381aee7c4072779130c634921a756c97bc29fb49/pillow-11.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3", size = 2256828, upload_time = "2024-10-15T14:23:39.826Z" },
] ]
[[package]]
name = "pyacoustid"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "audioread" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1e/c7/48a17b6a75888cf760a95f677cec5fe68fd00edf9072df14071008d9b2c0/pyacoustid-1.3.0.tar.gz", hash = "sha256:5f4f487191c19ebb908270b1b7b5297f132da332b1568b96a914574c079ed177", size = 17369, upload_time = "2023-09-12T19:25:21.258Z" }
[[package]] [[package]]
name = "pycparser" name = "pycparser"
version = "2.22" version = "2.22"
@@ -317,6 +404,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/c5/c243b05a15a27b946180db0d1e4c999bef3f4221505dff9748f1f6c917be/rapidfuzz-3.13.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f219f1e3c3194d7a7de222f54450ce12bc907862ff9a8962d83061c1f923c86", size = 1553782, upload_time = "2025-04-03T20:38:30.778Z" }, { url = "https://files.pythonhosted.org/packages/c1/c5/c243b05a15a27b946180db0d1e4c999bef3f4221505dff9748f1f6c917be/rapidfuzz-3.13.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f219f1e3c3194d7a7de222f54450ce12bc907862ff9a8962d83061c1f923c86", size = 1553782, upload_time = "2025-04-03T20:38:30.778Z" },
] ]
[[package]]
name = "requests"
version = "2.32.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload_time = "2025-06-09T16:43:07.34Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload_time = "2025-06-09T16:43:05.728Z" },
]
[[package]] [[package]]
name = "thefuzz" name = "thefuzz"
version = "0.22.1" version = "0.22.1"
@@ -338,6 +440,15 @@ 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" }, { 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 = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload_time = "2025-06-18T14:07:41.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload_time = "2025-06-18T14:07:40.39Z" },
]
[[package]] [[package]]
name = "watchdog" name = "watchdog"
version = "6.0.0" version = "6.0.0"