mirror of
https://github.com/csd4ni3l/music-player.git
synced 2026-01-01 12:13:42 +01:00
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:
13
README.md
13
README.md
@@ -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!
|
|
||||||
@@ -49,20 +49,27 @@ 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:
|
||||||
self.status_label.text = self.yt_dl_buffer
|
if not self.yt_dl_buffer == "download_yt_dlp":
|
||||||
|
self.status_label.text = self.yt_dl_buffer
|
||||||
|
|
||||||
if "WARNING" in self.yt_dl_buffer:
|
if "WARNING" in self.yt_dl_buffer:
|
||||||
self.status_label.update_font(font_color=arcade.color.YELLOW)
|
self.status_label.update_font(font_color=arcade.color.YELLOW)
|
||||||
elif "ERROR" in self.yt_dl_buffer:
|
elif "ERROR" in self.yt_dl_buffer:
|
||||||
self.status_label.update_font(font_color=arcade.color.RED)
|
self.status_label.update_font(font_color=arcade.color.RED)
|
||||||
|
else:
|
||||||
|
self.status_label.update_font(font_color=arcade.color.LIGHT_GREEN)
|
||||||
else:
|
else:
|
||||||
self.status_label.update_font(font_color=arcade.color.LIGHT_GREEN)
|
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,33 +164,41 @@ 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)
|
||||||
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)
|
def install_and_run_yt_dlp(self):
|
||||||
os.chmod(path, 0o755)
|
system = platform.system()
|
||||||
|
path = self.get_yt_dlp_path()
|
||||||
|
|
||||||
return 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)
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -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())
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
75
utils/acoustid_metadata.py
Normal file
75
utils/acoustid_metadata.py
Normal 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"]
|
||||||
@@ -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
52
utils/cover_art.py
Normal 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
58
utils/lyrics_metadata.py
Normal 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]
|
||||||
@@ -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)
|
id3 = ID3(file_path)
|
||||||
if hasattr(file_audio, 'info'):
|
for frame in id3.getall("WXXX"):
|
||||||
sound_length = round(file_audio.info.length, 2)
|
desc = frame.desc.lower()
|
||||||
bitrate = int((file_audio.info.bitrate or 0) / 1000)
|
if desc == "uploader":
|
||||||
sample_rate = int(file_audio.info.sample_rate / 1000)
|
uploader_url = frame.url
|
||||||
|
elif desc == "source":
|
||||||
|
source_url = frame.url
|
||||||
|
for frame in id3.getall("TXXX"):
|
||||||
|
desc = frame.desc.lower()
|
||||||
|
if desc == "last_played":
|
||||||
|
last_played = float(frame.text[0])
|
||||||
|
elif desc == "play_count":
|
||||||
|
play_count = int(frame.text[0])
|
||||||
|
except ID3NoHeaderError:
|
||||||
|
pass
|
||||||
|
|
||||||
thumb_image_data = None
|
if hasattr(easyid3, "info"):
|
||||||
if ext == 'mp3':
|
sound_length = round(easyid3.info.length, 2)
|
||||||
for tag in file_audio.values():
|
bitrate = int((easyid3.info.bitrate or 0) / 1000)
|
||||||
if tag.FrameID == "APIC":
|
sample_rate = int(easyid3.info.sample_rate / 1000)
|
||||||
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)
|
apic = id3.getall("APIC")
|
||||||
for frame in id3.getall("WXXX"):
|
thumb_image_data = apic[0].data if apic else None
|
||||||
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:
|
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()
|
||||||
@@ -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,19 +164,30 @@ 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:
|
||||||
detailed = music_api.get_recording_by_id(
|
if recording_id:
|
||||||
recording_id,
|
detailed = music_api.get_recording_by_id(
|
||||||
includes=["artists", "releases", "isrcs", "tags", "ratings"]
|
recording_id,
|
||||||
)["recording"]
|
includes=["artists", "releases", "isrcs", "tags", "ratings"]
|
||||||
metadata_cache["recording_by_id"][recording_id] = {
|
)["recording"]
|
||||||
"title": detailed["title"],
|
metadata_cache["recording_by_id"][recording_id] = {
|
||||||
"artist-credit": [{"artist": {"id": artist_data["artist"]["id"]}} for artist_data in detailed.get("artist-credit", {}) if isinstance(artist_data, dict)],
|
"title": detailed["title"],
|
||||||
"isrc-list": detailed["isrc-list"] if "isrc-list" in detailed else [],
|
"artist-credit": [{"artist": {"id": artist_data["artist"]["id"]}} for artist_data in detailed.get("artist-credit", {}) if isinstance(artist_data, dict)],
|
||||||
"rating": {"rating": detailed["rating"]["rating"]} if "rating" in detailed else {},
|
"isrc-list": detailed["isrc-list"] if "isrc-list" in detailed else [],
|
||||||
"tags": detailed.get("tag-list", []),
|
"rating": {"rating": detailed["rating"]["rating"]} if "rating" 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 [],
|
"tags": detailed.get("tag-list", []),
|
||||||
"release-event-count": detailed.get("release-event-count", 0)
|
"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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
@@ -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
111
uv.lock
generated
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user