mirror of
https://github.com/csd4ni3l/music-player.git
synced 2026-01-01 04:03:42 +01:00
Add a view metadata button, add more metadata to file after download and extract more from files.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from mutagen.easyid3 import EasyID3
|
||||
from mutagen.id3 import ID3, TIT2, TPE1, WXXX
|
||||
from mutagen.mp3 import MP3
|
||||
|
||||
import arcade, arcade.gui, os, json, threading, subprocess
|
||||
import arcade, arcade.gui, os, json, threading, subprocess, traceback
|
||||
|
||||
from arcade.gui.experimental.focus import UIFocusGroup
|
||||
|
||||
@@ -122,9 +123,18 @@ class Downloader(arcade.gui.UIView):
|
||||
title = f"{artist} - {track_title}"
|
||||
|
||||
try:
|
||||
audio = EasyID3("downloaded_music.mp3")
|
||||
audio["artist"] = artist
|
||||
audio["title"] = track_title
|
||||
audio = MP3("downloaded_music.mp3", ID3=ID3)
|
||||
if audio.tags is None:
|
||||
audio.add_tags()
|
||||
else:
|
||||
for frame_id in ("TIT2", "TPE1", "WXXX"):
|
||||
audio.tags.delall(frame_id)
|
||||
audio.tags.add(TIT2(encoding=3, text=track_title))
|
||||
audio.tags.add(TPE1(encoding=3, text=artist))
|
||||
if info.get("creator_url"):
|
||||
audio.tags.add(WXXX(desc="Uploader", url=info["uploader_url"]))
|
||||
audio.tags.add(WXXX(desc="Source", url=info["webpage_url"]))
|
||||
|
||||
audio.save()
|
||||
except Exception as meta_err:
|
||||
self.yt_dl_buffer = f"ERROR: Tried to override metadata based on title, but failed: {meta_err}"
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import random, asyncio, pypresence, time, copy, json, os, logging
|
||||
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, ListItem, extract_metadata, get_audio_thumbnail_texture, truncate_end, adjust_volume
|
||||
from utils.utils import FakePyPresence, UIFocusTextureButton, MusicItem, extract_metadata_and_thumbnail, truncate_end, adjust_volume
|
||||
|
||||
from math import ceil
|
||||
from thefuzz import process, fuzz
|
||||
|
||||
from arcade.gui.experimental.scroll_area import UIScrollArea, UIScrollBar
|
||||
@@ -57,6 +56,7 @@ class Main(arcade.gui.UIView):
|
||||
self.tab_options = self.settings_dict.get("tab_options", [os.path.join("~", "Music"), os.path.join("~", "Downloads")])
|
||||
self.tab_content = {}
|
||||
self.playlist_content = {}
|
||||
self.file_metadata = {}
|
||||
self.thumbnails = {}
|
||||
self.tab_buttons = {}
|
||||
self.music_buttons = {}
|
||||
@@ -257,10 +257,22 @@ class Main(arcade.gui.UIView):
|
||||
self.shuffle = not self.shuffle
|
||||
self.update_buttons()
|
||||
|
||||
def metadata_button_action(self, action, metadata):
|
||||
if action != "Close":
|
||||
webbrowser.open(metadata["uploader_url"] if action == "Uploader" else metadata["source_url"])
|
||||
|
||||
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: {int(metadata['sound_length'])}\nBitrate: {metadata['bit_rate']}Kbps"
|
||||
|
||||
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)
|
||||
self.anchor.add(msgbox, anchor_x="center", anchor_y="center")
|
||||
|
||||
def show_content(self, tab):
|
||||
for music_button in self.music_buttons.values():
|
||||
music_button.remove(music_button.button)
|
||||
music_button.remove(music_button.image)
|
||||
music_button.clear()
|
||||
self.music_box.remove(music_button)
|
||||
del music_button
|
||||
|
||||
@@ -272,10 +284,13 @@ 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]
|
||||
self.music_buttons[f"{tab}/{music_filename}"] = self.music_box.add(ListItem(texture=self.thumbnails[f"{tab}/{music_filename}"], font_name="Roboto", font_size=13, text=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, tab=tab, music_filename=music_filename: self.music_button_click(event, f"{tab}/{music_filename}")
|
||||
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)
|
||||
|
||||
else:
|
||||
self.highest_score_file = ""
|
||||
@@ -283,10 +298,11 @@ class Main(arcade.gui.UIView):
|
||||
self.no_music_label.visible = not self.tab_content[tab]
|
||||
|
||||
for music_filename in self.tab_content[tab]:
|
||||
self.music_buttons[f"{tab}/{music_filename}"] = self.music_box.add(ListItem(texture=self.thumbnails[f"{tab}/{music_filename}"], font_name="Roboto", font_size=13, text=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, tab=tab, music_filename=music_filename: self.music_button_click(event, f"{tab}/{music_filename}")
|
||||
metadata = self.file_metadata[f"{tab}/{music_filename}"]
|
||||
|
||||
self.music_box._update_size_hints()
|
||||
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)
|
||||
|
||||
elif self.current_mode == "playlist":
|
||||
self.current_playlist = tab
|
||||
@@ -296,22 +312,24 @@ class Main(arcade.gui.UIView):
|
||||
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_filename = match[0]
|
||||
self.music_buttons[music_filename] = self.music_box.add(ListItem(texture=self.thumbnails[music_filename], font_name="Roboto", font_size=13, text=music_filename, width=self.window.width / 1.2, height=self.window.height / 11))
|
||||
self.music_buttons[music_filename].button.on_click = lambda event, music_filename=music_filename: self.music_button_click(event, music_filename)
|
||||
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)
|
||||
|
||||
else:
|
||||
self.highest_score_file = ""
|
||||
|
||||
self.no_music_label.visible = not self.playlist_content[tab]
|
||||
|
||||
for music_filename in self.playlist_content[tab]:
|
||||
self.music_buttons[music_filename] = self.music_box.add(ListItem(texture=self.thumbnails[music_filename], font_name="Roboto", font_size=13, text=music_filename, width=self.window.width / 1.2, height=self.window.height / 11))
|
||||
self.music_buttons[music_filename].button.on_click = lambda event, music_filename=music_filename: self.music_button_click(event, music_filename)
|
||||
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)
|
||||
|
||||
self.music_box._update_size_hints()
|
||||
|
||||
self.music_buttons["add_music"] = self.music_box.add(ListItem(texture=plus_icon, font_name="Roboto", font_size=13, text="Add Music", width=self.window.width / 1.2, height=self.window.height / 11))
|
||||
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"].button.on_click = lambda event: self.add_music()
|
||||
|
||||
self.anchor.detect_focusable_widgets()
|
||||
@@ -334,6 +352,8 @@ class Main(arcade.gui.UIView):
|
||||
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)
|
||||
@@ -346,8 +366,10 @@ 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.thumbnails:
|
||||
self.thumbnails[f"{expanded_tab}/{filename}"] = get_audio_thumbnail_texture(f"{expanded_tab}/{filename}", self.window.size)
|
||||
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.tab_content[expanded_tab].append(filename)
|
||||
|
||||
for playlist, content in self.settings_dict.get("playlists", {}).items():
|
||||
@@ -356,9 +378,10 @@ class Main(arcade.gui.UIView):
|
||||
content.remove(file) # also removes reference from self.settings_dict["playlists"]
|
||||
continue
|
||||
|
||||
if file not in self.thumbnails:
|
||||
self.thumbnails[file] = get_audio_thumbnail_texture(file, self.window.size)
|
||||
|
||||
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.playlist_content[playlist] = content
|
||||
|
||||
def load_tabs(self):
|
||||
@@ -391,7 +414,7 @@ class Main(arcade.gui.UIView):
|
||||
if len(self.queue) > 0:
|
||||
music_path = self.queue.pop(0)
|
||||
|
||||
artist, title = extract_metadata(music_path)
|
||||
artist, title = self.file_metadata[music_path]["artist"], self.file_metadata[music_path]["title"]
|
||||
|
||||
music_name = f"{artist} - {title}"
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import arcade.color
|
||||
from arcade.types import Color
|
||||
from arcade.gui.widgets.buttons import UITextureButtonStyle, UIFlatButtonStyle
|
||||
from arcade.gui.widgets.buttons import UIFlatButtonStyle
|
||||
from arcade.gui.widgets.slider import UISliderStyle
|
||||
|
||||
menu_background_color = (17, 17, 17)
|
||||
log_dir = 'logs'
|
||||
discord_presence_id = 1368277020332523530
|
||||
|
||||
audio_extensions = ["mp3", "m4a", "mp4", "aac", "flac", "ogg", "opus", "wav"]
|
||||
audio_extensions = ["mp3", "m4a", "aac", "flac", "ogg", "opus", "wav"]
|
||||
|
||||
DARK_GRAY = Color(45, 45, 45)
|
||||
GRAY = Color(70, 70, 70)
|
||||
|
||||
135
utils/utils.py
135
utils/utils.py
@@ -1,4 +1,4 @@
|
||||
import logging, sys, traceback, os, re, platform, urllib.request, io, base64, tempfile
|
||||
import logging, sys, traceback, os, re, platform, urllib.request, io, base64, tempfile, struct
|
||||
|
||||
from mutagen.easyid3 import EasyID3
|
||||
from mutagen.id3 import ID3
|
||||
@@ -87,28 +87,41 @@ class UIFocusTextureButton(arcade.gui.UITextureButton):
|
||||
else:
|
||||
self.resize(width=self.width / 1.1, height=self.height / 1.1)
|
||||
|
||||
class ListItem(arcade.gui.UIBoxLayout):
|
||||
def __init__(self, width: int, height: int, font_name: str, font_size: int, text: str, texture: arcade.Texture, padding=10):
|
||||
class MusicItem(arcade.gui.UIBoxLayout):
|
||||
def __init__(self, metadata: dict, width: int, height: int, texture: arcade.Texture, 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,
|
||||
width=width * 0.1,
|
||||
height=height
|
||||
width=height * 1.5,
|
||||
height=height,
|
||||
))
|
||||
|
||||
self.button = self.add(arcade.gui.UITextureButton(
|
||||
text=text,
|
||||
text=f"{metadata['artist']} - {metadata['title']}" if metadata else "Add Music",
|
||||
texture=button_texture,
|
||||
texture_hovered=button_hovered_texture,
|
||||
texture_pressed=button_texture,
|
||||
texture_disabled=button_texture,
|
||||
style=button_style,
|
||||
width=width * 0.9,
|
||||
width=width * 0.85,
|
||||
height=height,
|
||||
interaction_buttons=[arcade.MOUSE_BUTTON_LEFT, arcade.MOUSE_BUTTON_RIGHT]
|
||||
))
|
||||
|
||||
if metadata:
|
||||
self.view_metadata_button = self.add(arcade.gui.UITextureButton(
|
||||
text="View Metadata",
|
||||
texture=button_texture,
|
||||
texture_hovered=button_hovered_texture,
|
||||
texture_pressed=button_texture,
|
||||
texture_disabled=button_texture,
|
||||
style=button_style,
|
||||
width=width * 0.1,
|
||||
height=height,
|
||||
))
|
||||
|
||||
def on_exception(*exc_info):
|
||||
logging.error(f"Unhandled exception:\n{''.join(traceback.format_exception(exc_info[1], limit=None))}")
|
||||
|
||||
@@ -171,88 +184,84 @@ def truncate_end(text: str, max_length: int) -> str:
|
||||
return text
|
||||
return text[:max_length - 3] + '...'
|
||||
|
||||
def extract_metadata(filename):
|
||||
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 = os.path.splitext(basename)[0]
|
||||
|
||||
name_only = re.sub(r'\s*\[[a-zA-Z0-9\-_]{5,}\]$', '', name_only)
|
||||
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])
|
||||
|
||||
artist_title_match = re.search(r'^.+\s*-\s*.+$', title) # check for Artist - Title titles, so Artist doesnt appear twice
|
||||
|
||||
except KeyError:
|
||||
artist_title_match = re.search(r'^.+\s*-\s*.+$', title)
|
||||
if artist_title_match:
|
||||
title = title.split("- ")[1]
|
||||
|
||||
if artist != "Unknown" and title:
|
||||
return artist, title
|
||||
except:
|
||||
pass
|
||||
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
|
||||
|
||||
return artist, title
|
||||
|
||||
if not title:
|
||||
title = name_only
|
||||
|
||||
return artist, title
|
||||
|
||||
def get_audio_thumbnail_texture(audio_path: str, window_resolution: tuple) -> arcade.Texture:
|
||||
ext = os.path.splitext(audio_path)[1].lower().lstrip('.')
|
||||
thumb_audio = File(audio_path)
|
||||
|
||||
thumb_image_data = None
|
||||
|
||||
try:
|
||||
if ext == 'mp3':
|
||||
for tag in thumb_audio.values():
|
||||
if tag.FrameID == "APIC":
|
||||
thumb_image_data = tag.data
|
||||
break
|
||||
|
||||
elif ext in ('m4a', 'mp4', 'aac'):
|
||||
if 'covr' in thumb_audio:
|
||||
thumb_image_data = thumb_audio['covr'][0]
|
||||
|
||||
elif ext == 'flac':
|
||||
if thumb_audio.pictures:
|
||||
thumb_image_data = thumb_audio.pictures[0].data
|
||||
|
||||
elif ext in ('ogg', 'opus'):
|
||||
if "metadata_block_picture" in thumb_audio:
|
||||
pic_data = base64.b64decode(thumb_audio["metadata_block_picture"][0])
|
||||
import struct
|
||||
header_len = struct.unpack(">I", pic_data[0:4])[0]
|
||||
thumb_image_data = pic_data[4 + header_len:]
|
||||
|
||||
if thumb_image_data:
|
||||
pil_image = Image.open(io.BytesIO(thumb_image_data)).convert("RGBA")
|
||||
pil_image = pil_image.resize((int(window_resolution[0] / 5), int(window_resolution[1] / 8)))
|
||||
thumb_texture = arcade.Texture(pil_image)
|
||||
return thumb_texture
|
||||
|
||||
except Exception as e:
|
||||
logging.debug(f"[Thumbnail Error] {audio_path}: {e}")
|
||||
|
||||
if thumb_texture is None:
|
||||
from utils.preload import music_icon
|
||||
return 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:
|
||||
|
||||
6
uv.lock
generated
6
uv.lock
generated
@@ -307,9 +307,9 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.13.2"
|
||||
version = "4.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload_time = "2025-04-10T14:19:05.416Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload_time = "2025-06-02T14:52:11.399Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload_time = "2025-04-10T14:19:03.967Z" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user