From 2461d6fc9dc275fd3fbcfff907aff4693976c7a5 Mon Sep 17 00:00:00 2001 From: csd4ni3l Date: Fri, 27 Jun 2025 19:44:22 +0200 Subject: [PATCH] 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 --- assets/graphics/reload.png | Bin 1083 -> 0 bytes menus/downloader.py | 34 ++++++- menus/main.py | 130 ++++++++++++++----------- pyproject.toml | 1 + requirements.txt | 37 ++++++-- run.py | 2 +- utils/constants.py | 2 + utils/file_watching.py | 46 +++++++++ utils/music_handling.py | 189 +++++++++++++++++++++++++++++++++++++ utils/preload.py | 2 +- utils/utils.py | 175 +--------------------------------- uv.lock | 29 ++++++ 12 files changed, 408 insertions(+), 239 deletions(-) delete mode 100644 assets/graphics/reload.png create mode 100644 utils/file_watching.py create mode 100644 utils/music_handling.py diff --git a/assets/graphics/reload.png b/assets/graphics/reload.png deleted file mode 100644 index 83b07e0e56a04714de7cd6b82227993e3e1ff2d1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1083 zcmV-B1jPG^P)5Zfr~o)mo)46>%fBJ{RIjOBHJs*F_Og+nR`q zii;vhG5)%%vASW|S^er=>JD`%ecn{@QT1R0rHeSIj+Dq7P$%ZK|1~wDuHxsanOAyI0USTUUb!r0G|V!fVY6nWsSRu zLe~Nx0_Rn?eRLf~VduTne9-DX&wshILV)#ucU zD#n^$R;qi{zZ$sM0v9S1GHCF}GTv4%t)Y0fT;{^)Qrm#CHSjFKAB)`OLEt`Mx^kL} znF8(v9tO@d8lm9Ltg$#$&Z?Uu-g;4-Kh)+7JP#}@sCyLXGYb3x-Xt&9I5OUY%6u zYKG5~>Tb19rB9a*^*v?DfL^m?K(9#+=mTB^P6E~}lY}E@+YAD`3A_I^@CqXkna`6~N;KbqBH!Vhwl?xR-E7Zi|=rFDabm)$D^913JLnz&{aVWZ9OCna2mD zU!e17YQU$!J+%Ag?*PUN>dpX9=2bK`U;`no(iYo*cPLIIi~(Qg)+F(CXT$hEpsMQy*g9g#c_elbZvl=IcJWm;`mF-?0LPLsikKm|*6E@K-Ci+CaiVoOupM{=_>8dF z4il2Q(}c6@YC>{%18`gNTjVY&zb9?KE!V8tg*xg+_4~U16~Rx5%t|+`a-lR-9tLhC zCybp1jsVvsGAl_9_#>yT!@^K`JQ;YLris{cW(n)!dctjHcO+9WWVkp>nJ{E0il%Nv zJ 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 diff --git a/pyproject.toml b/pyproject.toml index 6ea4653..9ccf1f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,4 +10,5 @@ dependencies = [ "pydub>=0.25.1", "pypresence>=4.3.0", "thefuzz>=0.22.1", + "watchdog>=6.0.0", ] diff --git a/requirements.txt b/requirements.txt index 34b8731..5ebfefc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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) diff --git a/run.py b/run.py index b2b0aca..d8bff07 100644 --- a/run.py +++ b/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 diff --git a/utils/constants.py b/utils/constants.py index 2be02a7..a8417e4 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -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) diff --git a/utils/file_watching.py b/utils/file_watching.py new file mode 100644 index 0000000..93c49f5 --- /dev/null +++ b/utils/file_watching.py @@ -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) + ) \ No newline at end of file diff --git a/utils/music_handling.py b/utils/music_handling.py new file mode 100644 index 0000000..7fe16d4 --- /dev/null +++ b/utils/music_handling.py @@ -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" \ No newline at end of file diff --git a/utils/preload.py b/utils/preload.py index f3ad475..a4512e1 100644 --- a/utils/preload.py +++ b/utils/preload.py @@ -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") diff --git a/utils/utils.py b/utils/utils.py index 29c3dc7..cc1ba1b 100644 --- a/utils/utils.py +++ b/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) diff --git a/uv.lock b/uv.lock index 62c94d0..5f9c3cf 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, +]