mirror of
https://github.com/csd4ni3l/music-player.git
synced 2025-11-05 02:58:15 +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
|
||||
- Fast search using just text, and instant best result playback using enter
|
||||
- Discord RPC
|
||||
|
||||
Features TBD:
|
||||
- Improved UI looks
|
||||
- More keyboard shortcuts
|
||||
- Vim keybindings(focusing with up down arrow keys)
|
||||
- Replace paths with own file manager
|
||||
- And much more!
|
||||
- MusicBrainz metadata
|
||||
- MusicBrainz global search
|
||||
- AcoustID automatic music recognition
|
||||
- Lyrics from lrclib
|
||||
- Synchronized Lyrics
|
||||
- Controller support
|
||||
@@ -49,20 +49,27 @@ class Downloader(arcade.gui.UIView):
|
||||
self.anchor.detect_focusable_widgets()
|
||||
|
||||
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:
|
||||
self.status_label.update_font(font_color=arcade.color.YELLOW)
|
||||
elif "ERROR" in self.yt_dl_buffer:
|
||||
self.status_label.update_font(font_color=arcade.color.RED)
|
||||
if "WARNING" in self.yt_dl_buffer:
|
||||
self.status_label.update_font(font_color=arcade.color.YELLOW)
|
||||
elif "ERROR" in self.yt_dl_buffer:
|
||||
self.status_label.update_font(font_color=arcade.color.RED)
|
||||
else:
|
||||
self.status_label.update_font(font_color=arcade.color.LIGHT_GREEN)
|
||||
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):
|
||||
yt_dlp_path = self.ensure_yt_dlp()
|
||||
|
||||
if not self.check_for_yt_dlp():
|
||||
self.yt_dl_buffer = "download_yt_dlp"
|
||||
return None
|
||||
|
||||
command = [
|
||||
yt_dlp_path, f"{url}",
|
||||
self.get_yt_dlp_path(), f"{url}",
|
||||
"--write-info-json",
|
||||
"-x", "--audio-format", "mp3",
|
||||
"-o", "downloaded_music.mp3",
|
||||
@@ -101,6 +108,8 @@ class Downloader(arcade.gui.UIView):
|
||||
path = os.path.expanduser(self.tab_selector.value)
|
||||
|
||||
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.info.json")
|
||||
@@ -155,33 +164,41 @@ class Downloader(arcade.gui.UIView):
|
||||
|
||||
self.yt_dl_buffer = f"Successfully downloaded {title} to {path}"
|
||||
|
||||
def ensure_yt_dlp(self):
|
||||
def get_yt_dlp_path(self):
|
||||
system = platform.system()
|
||||
|
||||
if system == "Windows":
|
||||
path = os.path.join("bin", "yt-dlp.exe")
|
||||
return os.path.join("bin", "yt-dlp.exe")
|
||||
elif system == "Darwin":
|
||||
path = os.path.join("bin", "yt-dlp_macos")
|
||||
return os.path.join("bin", "yt-dlp_macos")
|
||||
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"):
|
||||
os.makedirs("bin")
|
||||
|
||||
if not os.path.exists(path):
|
||||
if system == "Windows":
|
||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
|
||||
elif system == "Darwin":
|
||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
|
||||
elif system == "Linux":
|
||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux"
|
||||
else:
|
||||
raise RuntimeError("Unsupported OS")
|
||||
return os.path.exists(path)
|
||||
|
||||
urllib.request.urlretrieve(url, path)
|
||||
os.chmod(path, 0o755)
|
||||
def install_and_run_yt_dlp(self):
|
||||
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):
|
||||
from menus.main import Main
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import arcade, arcade.gui
|
||||
|
||||
import os, sys, subprocess, platform, urllib.request, zipfile, logging
|
||||
import os, sys, subprocess, platform, logging
|
||||
|
||||
class FFmpegMissing(arcade.gui.UIView):
|
||||
def __init__(self):
|
||||
@@ -15,11 +15,11 @@ class FFmpegMissing(arcade.gui.UIView):
|
||||
height=self.window.height / 2,
|
||||
title="FFmpeg Missing",
|
||||
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):
|
||||
bin_dir = os.path.join(os.getcwd(), "bin")
|
||||
@@ -28,22 +28,9 @@ class FFmpegMissing(arcade.gui.UIView):
|
||||
system = platform.system()
|
||||
|
||||
if system == "Linux" or system == "Darwin":
|
||||
url = "https://evermeet.cx/ffmpeg/ffmpeg-7.1.1.zip"
|
||||
filename = "ffmpeg.zip"
|
||||
|
||||
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")
|
||||
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
|
||||
|
||||
elif system == "Windows":
|
||||
try:
|
||||
@@ -52,11 +39,12 @@ class FFmpegMissing(arcade.gui.UIView):
|
||||
"--accept-source-agreements", "--accept-package-agreements"
|
||||
], check=True)
|
||||
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:
|
||||
logging.debug("Failed to install FFmpeg via winget:", e)
|
||||
|
||||
else:
|
||||
logging.error(f"Unsupported OS: {system}")
|
||||
|
||||
from menus.main import Main
|
||||
self.window.show_view(Main())
|
||||
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))
|
||||
|
||||
@@ -3,7 +3,7 @@ import arcade, arcade.gui
|
||||
from utils.preload import music_icon, person_icon, button_texture, button_hovered_texture
|
||||
from utils.constants import button_style
|
||||
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.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.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.lyrics_metadata import get_lyrics, get_closest_time, parse_synchronized_lyrics
|
||||
|
||||
from thefuzz import process, fuzz
|
||||
|
||||
@@ -15,7 +16,7 @@ from arcade.gui.experimental.focus import UIFocusGroup
|
||||
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,
|
||||
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__()
|
||||
self.pypresence_client = pypresence_client
|
||||
@@ -61,16 +62,19 @@ class Main(arcade.gui.UIView):
|
||||
self.file_metadata = {}
|
||||
self.tab_buttons = {}
|
||||
self.music_buttons = {}
|
||||
self.queue = []
|
||||
self.queue = queue or []
|
||||
|
||||
self.current_music_artist = current_music_artist
|
||||
self.current_music_title = current_music_title
|
||||
self.current_music_player = current_music_player
|
||||
self.current_music_path = current_music_path
|
||||
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.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_tab = current_tab if current_tab else self.tab_options[0]
|
||||
self.search_term = ""
|
||||
@@ -112,10 +116,10 @@ class Main(arcade.gui.UIView):
|
||||
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
|
||||
|
||||
# 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_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_box.add(self.scroll_area)
|
||||
|
||||
@@ -123,9 +127,17 @@ class Main(arcade.gui.UIView):
|
||||
self.scrollbar.size_hint = (0.02, 1)
|
||||
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.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
|
||||
|
||||
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_path = None
|
||||
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_title_label.text = "No songs playing"
|
||||
self.full_length_label.text = "00:00"
|
||||
@@ -294,7 +309,7 @@ class Main(arcade.gui.UIView):
|
||||
|
||||
def view_metadata(self, file_path):
|
||||
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):
|
||||
for music_button in self.music_buttons.values():
|
||||
@@ -328,8 +343,8 @@ class Main(arcade.gui.UIView):
|
||||
row, col = 0, 0
|
||||
|
||||
for n, music_filename in enumerate(content_to_show):
|
||||
row = n // 8
|
||||
col = n % 8
|
||||
row = n // self.music_grid.column_count
|
||||
col = n % self.music_grid.column_count
|
||||
|
||||
if self.current_mode == "files":
|
||||
music_path = f"{tab}/{music_filename}"
|
||||
@@ -338,17 +353,17 @@ class Main(arcade.gui.UIView):
|
||||
|
||||
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)
|
||||
|
||||
row = (n + 1) // 8
|
||||
col = (n + 1) % 8
|
||||
row = (n + 1) // self.music_grid.column_count
|
||||
col = (n + 1) % self.music_grid.column_count
|
||||
|
||||
self.music_grid.row_count = row + 1
|
||||
self.music_grid._update_size_hints()
|
||||
|
||||
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.anchor.detect_focusable_widgets()
|
||||
@@ -365,7 +380,7 @@ class Main(arcade.gui.UIView):
|
||||
with open("settings.json", "w") as file:
|
||||
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):
|
||||
self.tab_content.clear()
|
||||
@@ -436,6 +451,17 @@ class Main(arcade.gui.UIView):
|
||||
self.current_music_player.volume = self.volume / 100
|
||||
|
||||
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:
|
||||
self.should_reload = False
|
||||
self.reload()
|
||||
@@ -476,6 +502,8 @@ class Main(arcade.gui.UIView):
|
||||
self.full_length_label.text = "00:00"
|
||||
self.progressbar.max_value = self.current_length
|
||||
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:
|
||||
if self.current_music_player is not None:
|
||||
@@ -541,31 +569,31 @@ class Main(arcade.gui.UIView):
|
||||
from menus.global_search import GlobalSearch
|
||||
arcade.unschedule(self.update_presence)
|
||||
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):
|
||||
from menus.settings import Settings
|
||||
arcade.unschedule(self.update_presence)
|
||||
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):
|
||||
from menus.new_tab import NewTab
|
||||
arcade.unschedule(self.update_presence)
|
||||
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):
|
||||
from menus.add_music import AddMusic
|
||||
arcade.unschedule(self.update_presence)
|
||||
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):
|
||||
from menus.downloader import Downloader
|
||||
arcade.unschedule(self.update_presence)
|
||||
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):
|
||||
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.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.preload import button_texture, button_hovered_texture
|
||||
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):
|
||||
def __init__(self, pypresence_client, metadata_type="file", metadata=None, file_path=None, *args):
|
||||
super().__init__()
|
||||
|
||||
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":
|
||||
self.file_metadata = metadata
|
||||
self.file_path = file_path
|
||||
|
||||
self.artist = self.file_metadata["artist"] if not self.file_metadata["artist"] == "Unknown" else None
|
||||
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)
|
||||
|
||||
self.music_metadata, self.artist_metadata, self.album_metadata, self.lyrics_metadata = get_music_metadata(self.artist, self.title)
|
||||
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)
|
||||
|
||||
elif metadata_type == "music":
|
||||
self.artist = metadata["artist"]
|
||||
self.title = metadata["title"]
|
||||
@@ -31,14 +59,15 @@ class MetadataViewer(arcade.gui.UIView):
|
||||
elif metadata_type == "album":
|
||||
self.album_metadata = metadata
|
||||
|
||||
self.pypresence_client = pypresence_client
|
||||
self.args = args
|
||||
self.more_metadata_buttons = []
|
||||
self.metadata_labels = []
|
||||
|
||||
def on_show_view(self):
|
||||
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.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:
|
||||
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[-1].on_click = lambda event: self.show_artist_metadata()
|
||||
|
||||
@@ -9,6 +9,7 @@ dependencies = [
|
||||
"iso3166>=2.1.1",
|
||||
"musicbrainzngs>=0.7.1",
|
||||
"mutagen>=1.47.0",
|
||||
"pyacoustid>=1.3.0",
|
||||
"pydub>=0.25.1",
|
||||
"pypresence>=4.3.0",
|
||||
"thefuzz>=0.22.1",
|
||||
|
||||
@@ -4,8 +4,16 @@ arcade==3.2.0
|
||||
# via musicplayer (pyproject.toml)
|
||||
attrs==25.3.0
|
||||
# via pytiled-parser
|
||||
audioread==3.0.1
|
||||
# via pyacoustid
|
||||
certifi==2025.7.9
|
||||
# via requests
|
||||
cffi==1.17.1
|
||||
# via pymunk
|
||||
charset-normalizer==3.4.2
|
||||
# via requests
|
||||
idna==3.10
|
||||
# via requests
|
||||
iso3166==2.1.1
|
||||
# via musicplayer (pyproject.toml)
|
||||
musicbrainzngs==0.7.1
|
||||
@@ -14,6 +22,8 @@ mutagen==1.47.0
|
||||
# via musicplayer (pyproject.toml)
|
||||
pillow==11.0.0
|
||||
# via arcade
|
||||
pyacoustid==1.3.0
|
||||
# via musicplayer (pyproject.toml)
|
||||
pycparser==2.22
|
||||
# via cffi
|
||||
pydub==0.25.1
|
||||
@@ -28,9 +38,13 @@ pytiled-parser==2.2.9
|
||||
# via arcade
|
||||
rapidfuzz==3.13.0
|
||||
# via thefuzz
|
||||
requests==2.32.4
|
||||
# via pyacoustid
|
||||
thefuzz==0.22.1
|
||||
# via musicplayer (pyproject.toml)
|
||||
typing-extensions==4.14.1
|
||||
# via pytiled-parser
|
||||
urllib3==2.5.0
|
||||
# via requests
|
||||
watchdog==6.0.0
|
||||
# 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)
|
||||
log_dir = 'logs'
|
||||
discord_presence_id = 1368277020332523530
|
||||
audio_extensions = ["mp3", "m4a", "aac", "flac", "ogg", "opus", "wav"]
|
||||
audio_extensions = ["mp3"]
|
||||
view_modes = ["files", "playlist"]
|
||||
|
||||
MUSIC_TITLE_WORD_BLACKLIST = ["compilation", "remix", "vs", "cover", "version", "instrumental", "restrung", "interlude"]
|
||||
COVER_CACHE_DIR = "cover_cache"
|
||||
ACOUSTID_API_KEY = 'PuUkMEnUXf'
|
||||
LRCLIB_BASE_URL = "https://lrclib.net/api/search"
|
||||
|
||||
MUSICBRAINZ_PROJECT_NAME = "csd4ni3l/music-player"
|
||||
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.id3 import ID3, TXXX, ID3NoHeaderError
|
||||
from mutagen import File
|
||||
from mutagen.id3 import ID3, TXXX, SYLT, ID3NoHeaderError
|
||||
|
||||
from pydub import AudioSegment
|
||||
from PIL import Image
|
||||
|
||||
from utils.lyrics_metadata import parse_synchronized_lyrics
|
||||
from utils.utils import convert_seconds_to_date
|
||||
|
||||
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[: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"
|
||||
title = ""
|
||||
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)
|
||||
name_only = os.path.splitext(basename)[0]
|
||||
ext = os.path.splitext(file_path)[1].lower().lstrip('.')
|
||||
|
||||
try:
|
||||
thumb_audio = EasyID3(file_path)
|
||||
try:
|
||||
artist = str(thumb_audio["artist"][0])
|
||||
title = str(thumb_audio["title"][0])
|
||||
upload_year = int(thumb_audio["date"][0])
|
||||
except KeyError:
|
||||
artist_title_match = re.search(r'^.+\s*-\s*.+$', title)
|
||||
if artist_title_match:
|
||||
title = title.split("- ")[1]
|
||||
easyid3 = EasyID3(file_path)
|
||||
if "artist" in easyid3:
|
||||
artist = easyid3["artist"][0]
|
||||
if "title" in easyid3:
|
||||
title = easyid3["title"][0]
|
||||
if "date" in easyid3:
|
||||
upload_year = int(re.match(r"\d{4}", easyid3["date"][0]).group())
|
||||
|
||||
file_audio = File(file_path)
|
||||
if hasattr(file_audio, 'info'):
|
||||
sound_length = round(file_audio.info.length, 2)
|
||||
bitrate = int((file_audio.info.bitrate or 0) / 1000)
|
||||
sample_rate = int(file_audio.info.sample_rate / 1000)
|
||||
id3 = ID3(file_path)
|
||||
for frame in id3.getall("WXXX"):
|
||||
desc = frame.desc.lower()
|
||||
if desc == "uploader":
|
||||
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 ext == 'mp3':
|
||||
for tag in file_audio.values():
|
||||
if tag.FrameID == "APIC":
|
||||
thumb_image_data = tag.data
|
||||
break
|
||||
elif ext in ('m4a', 'aac'):
|
||||
if 'covr' in file_audio:
|
||||
thumb_image_data = file_audio['covr'][0]
|
||||
elif ext == 'flac':
|
||||
if file_audio.pictures:
|
||||
thumb_image_data = file_audio.pictures[0].data
|
||||
elif ext in ('ogg', 'opus'):
|
||||
if "metadata_block_picture" in file_audio:
|
||||
pic_data = base64.b64decode(file_audio["metadata_block_picture"][0])
|
||||
header_len = struct.unpack(">I", pic_data[0:4])[0]
|
||||
thumb_image_data = pic_data[4 + header_len:]
|
||||
if hasattr(easyid3, "info"):
|
||||
sound_length = round(easyid3.info.length, 2)
|
||||
bitrate = int((easyid3.info.bitrate or 0) / 1000)
|
||||
sample_rate = int(easyid3.info.sample_rate / 1000)
|
||||
|
||||
id3 = ID3(file_path)
|
||||
for frame in id3.getall("WXXX"):
|
||||
if frame.desc.lower() == "uploader":
|
||||
uploader_url = frame.url
|
||||
elif frame.desc.lower() == "source":
|
||||
source_url = frame.url
|
||||
|
||||
for frame in id3.getall("TXXX"):
|
||||
if frame.desc.lower() == "last_played":
|
||||
last_played = float(frame.text[0])
|
||||
elif frame.desc.lower() == "play_count":
|
||||
play_count = int(frame.text[0])
|
||||
apic = id3.getall("APIC")
|
||||
thumb_image_data = apic[0].data if apic else None
|
||||
|
||||
if thumb_image_data:
|
||||
pil_image = Image.open(io.BytesIO(thumb_image_data)).convert("RGBA")
|
||||
@@ -90,22 +75,19 @@ def extract_metadata_and_thumbnail(file_path: str, thumb_resolution: tuple) -> t
|
||||
logging.debug(f"[Metadata/Thumbnail Error] {file_path}: {e}")
|
||||
|
||||
if artist == "Unknown" or not title:
|
||||
match = re.search(r'^(.*?)\s+-\s+(.*?)$', name_only)
|
||||
if match:
|
||||
file_path_artist, file_path_title = match.groups()
|
||||
if artist == "Unknown":
|
||||
artist = file_path_artist
|
||||
if not title:
|
||||
title = file_path_title
|
||||
m = re.match(r"^(.*?)\s+-\s+(.*?)$", name_only) # check for artist - title titles in the title
|
||||
if m:
|
||||
artist = m.group(1)
|
||||
title = m.group(2)
|
||||
|
||||
if not title:
|
||||
if not title:
|
||||
title = name_only
|
||||
|
||||
|
||||
if thumb_texture is None:
|
||||
from utils.preload import music_icon
|
||||
thumb_texture = music_icon
|
||||
|
||||
file_size = round(os.path.getsize(file_path) / (1024 ** 2), 2) # MiB
|
||||
file_size = round(os.path.getsize(file_path) / (1024 ** 2), 2)
|
||||
|
||||
return {
|
||||
"sound_length": sound_length,
|
||||
@@ -119,9 +101,10 @@ def extract_metadata_and_thumbnail(file_path: str, thumb_resolution: tuple) -> t
|
||||
"source_url": source_url,
|
||||
"artist": artist,
|
||||
"title": title,
|
||||
"thumbnail": thumb_texture
|
||||
"thumbnail": thumb_texture,
|
||||
}
|
||||
|
||||
|
||||
def adjust_volume(input_path, volume):
|
||||
audio = AudioSegment.from_file(input_path)
|
||||
change = volume - audio.dBFS
|
||||
@@ -191,3 +174,25 @@ def convert_timestamp_to_time_ago(timestamp):
|
||||
return convert_seconds_to_date(elapsed_time) + ' ago'
|
||||
else:
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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"
|
||||
import json, iso3166
|
||||
|
||||
def get_country(code):
|
||||
country = iso3166.countries.get(code, None)
|
||||
@@ -24,9 +14,9 @@ def check_blacklist(text, blacklist):
|
||||
return any(word in text for word in blacklist)
|
||||
|
||||
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:
|
||||
blacklist.remove(word)
|
||||
|
||||
@@ -35,21 +25,6 @@ def finalize_blacklist(title):
|
||||
def is_release_valid(release):
|
||||
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):
|
||||
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_date": release.get("date") 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]
|
||||
|
||||
@@ -142,7 +118,7 @@ def get_album_metadata(album_id):
|
||||
"album_name": release.get("title") 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_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
|
||||
|
||||
@@ -162,6 +138,8 @@ def get_music_metadata(artist=None, title=None, musicbrainz_id=None):
|
||||
else:
|
||||
query = title
|
||||
|
||||
recording_id = None
|
||||
|
||||
if query in metadata_cache["query_results"]:
|
||||
recording_id = metadata_cache["query_results"][query]
|
||||
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"]:
|
||||
detailed = metadata_cache["recording_by_id"][recording_id]
|
||||
else:
|
||||
detailed = music_api.get_recording_by_id(
|
||||
recording_id,
|
||||
includes=["artists", "releases", "isrcs", "tags", "ratings"]
|
||||
)["recording"]
|
||||
metadata_cache["recording_by_id"][recording_id] = {
|
||||
"title": detailed["title"],
|
||||
"artist-credit": [{"artist": {"id": artist_data["artist"]["id"]}} for artist_data in detailed.get("artist-credit", {}) if isinstance(artist_data, dict)],
|
||||
"isrc-list": detailed["isrc-list"] if "isrc-list" in detailed else [],
|
||||
"rating": {"rating": detailed["rating"]["rating"]} if "rating" in detailed else {},
|
||||
"tags": detailed.get("tag-list", []),
|
||||
"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)
|
||||
}
|
||||
if recording_id:
|
||||
detailed = music_api.get_recording_by_id(
|
||||
recording_id,
|
||||
includes=["artists", "releases", "isrcs", "tags", "ratings"]
|
||||
)["recording"]
|
||||
metadata_cache["recording_by_id"][recording_id] = {
|
||||
"title": detailed["title"],
|
||||
"artist-credit": [{"artist": {"id": artist_data["artist"]["id"]}} for artist_data in detailed.get("artist-credit", {}) if isinstance(artist_data, dict)],
|
||||
"isrc-list": detailed["isrc-list"] if "isrc-list" in detailed else [],
|
||||
"rating": {"rating": detailed["rating"]["rating"]} if "rating" in detailed else {},
|
||||
"tags": detailed.get("tag-list", []),
|
||||
"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:
|
||||
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",
|
||||
"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]
|
||||
|
||||
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
|
||||
return music_metadata, artist_metadata, album_metadata, get_lyrics(', '.join([artist for artist in artist_metadata]), detailed["title"])
|
||||
|
||||
def search_recordings(search_term):
|
||||
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
|
||||
|
||||
@@ -196,4 +196,19 @@ def get_wordwrapped_text(text, width=18):
|
||||
else:
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "cffi"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "iso3166"
|
||||
version = "2.1.1"
|
||||
@@ -98,6 +173,7 @@ dependencies = [
|
||||
{ name = "iso3166" },
|
||||
{ name = "musicbrainzngs" },
|
||||
{ name = "mutagen" },
|
||||
{ name = "pyacoustid" },
|
||||
{ name = "pydub" },
|
||||
{ name = "pypresence" },
|
||||
{ name = "thefuzz" },
|
||||
@@ -110,6 +186,7 @@ requires-dist = [
|
||||
{ name = "iso3166", specifier = ">=2.1.1" },
|
||||
{ name = "musicbrainzngs", specifier = ">=0.7.1" },
|
||||
{ name = "mutagen", specifier = ">=1.47.0" },
|
||||
{ name = "pyacoustid", specifier = ">=1.3.0" },
|
||||
{ name = "pydub", specifier = ">=0.25.1" },
|
||||
{ name = "pypresence", specifier = ">=4.3.0" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pycparser"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "thefuzz"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "watchdog"
|
||||
version = "6.0.0"
|
||||
|
||||
Reference in New Issue
Block a user