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