Compare commits

...
44 Commits
Author SHA1 Message Date
jakob b8660d3200 Add remaining files (with AI taint) 2025-11-13 20:02:25 +01:00
jakob a611362ad8 Add Song class 2025-11-13 20:01:16 +01:00
jakob 0373572eb7 Implement favorites page 2025-11-13 19:55:40 +01:00
jakob 6773d6d9bc Add pagination elements 2025-11-13 19:48:09 +01:00
jakob 7c6ba261f8 Add SongList 2025-11-13 19:47:51 +01:00
jakob 0b69162d0b Add search bar 2025-11-13 19:47:34 +01:00
jakob 740cee3d44 Add header (featuring SearchBar and TabBar) 2025-11-13 19:47:22 +01:00
jakob be55bb91dc Add index.html
With fonts retrieved from Google Fonts, which is not ideal, but we can inlne those
2025-11-13 19:47:07 +01:00
jakob b3cefa32b7 Add tab bar 2025-11-13 19:42:27 +01:00
jakob 30b9bdf37c Add artist <section> 2025-11-13 19:42:07 +01:00
jakob 008ec35176 Add page for all songs 2025-11-13 19:41:24 +01:00
jakob fda41d1bdd Add base page (header and main) 2025-11-13 19:39:38 +01:00
jakob 3f0e459236 Migrate from pip to uv 2025-11-13 00:29:12 +01:00
jakob 58a096db29 Add Song model type 2025-11-13 00:20:32 +01:00
jakob ef6e52c0cb Add code to store search query and perform search 2025-11-13 00:20:02 +01:00
jakob 5066ce695d Add rather dull favorite handling code 2025-11-13 00:18:54 +01:00
jakob 625a6d72f7 Add Base model type
Mostly copied from gitlab.com/schwalbe-hd/schwalbe

It allows both the names `id` and `uuid` for the identifier field of an instance
2025-11-13 00:17:42 +01:00
jakob a5c50bd8f9 Add main UI js file 2025-11-13 00:16:41 +01:00
jakob 4fa29a9161 Reference old repo 2025-11-13 00:15:20 +01:00
jakob e4c5806974 Add mithril.js dependency 2025-11-13 00:12:21 +01:00
jakob 5573a6b127 Configure prettier and ignore 2025-11-13 00:11:59 +01:00
jakob 6fe89af3cc Add bundle function 2025-11-12 16:10:10 +01:00
jakob 8f6a42f8b4 Restructure: Make serve a level 4 subsection 2025-06-22 15:43:16 +02:00
jakob 7fbea281b8 Pretend songs.json is just another static file 2025-06-22 15:40:12 +02:00
jakob 4064b4d9b7 Serve index on root 2025-06-22 15:40:03 +02:00
jakob 8afeb618d3 Add very basic Flask implementation 2025-06-22 15:15:54 +02:00
jakob ee8215e7bc Document in README how to launch web app 2025-06-22 15:15:32 +02:00
jakob d114672681 Add karaokatalog.ui.serve package 2025-06-22 15:12:14 +02:00
jakob 40c516dcc8 Fix typo 2025-06-20 19:57:45 +02:00
jakob 6840a57e0b Implement JSONification 2025-06-20 19:54:55 +02:00
jakob 2c555ba8e0 Add UUID to songs 2025-06-20 19:22:31 +02:00
jakob df2ada6e4b Change heading 2025-06-01 16:46:31 +02:00
jakob 85ba86596a Implement recoding 2025-06-01 16:37:31 +02:00
jakob bbdaa34d7c Fix typo 2025-06-01 16:37:04 +02:00
jakob 4c6ef77a27 Implement executing RecodeInstruction 2025-06-01 16:26:31 +02:00
jakob fa3df3d2e2 Add skeletons for recoding 2025-06-01 16:23:35 +02:00
jakob 92a001bf52 Reformat file 2025-06-01 16:22:49 +02:00
jakob df5fc37419 Don't generate no-ops 2025-06-01 14:46:49 +02:00
jakob b5649a349b Skip no-ops when moving 2025-06-01 14:45:00 +02:00
jakob e4f7172f88 Just log errors for now 2025-06-01 14:23:28 +02:00
jakob 2ffae9ceda Strip trailing dots, document those measures 2025-06-01 14:18:29 +02:00
jakob 4c00b01c78 Handle NTFS's case insensitivity 2025-06-01 14:07:18 +02:00
jakob 12fdb5045c Actually increase counter in loop
Stupid me
2025-06-01 14:06:51 +02:00
jakob e17d4054d5 Create parent directories if necessary 2025-06-01 13:21:41 +02:00
45 changed files with 1326 additions and 21 deletions
+14
View File
@@ -0,0 +1,14 @@
# Python-related
venv/
.venv/
__pycache__/
.mypy_cache/
# JavaScript-related
node_modules
# Local instance data and configuration
instance/
# Libraries
ui/static/lib
+5
View File
@@ -0,0 +1,5 @@
{
"tabWidth": 4,
"semi": false,
"arrowParens": "avoid"
}
+1
View File
@@ -0,0 +1 @@
3.12
+57 -10
View File
@@ -1,17 +1,16 @@
# Karaokatalog
Tools to manage an Ultrastar DX song library. Features include:
Tools to manage an UltraStar DX song library. Features include:
1. Deduplication
2. Organization
1. Web-based catalogue UI
2. Deduplication
3. Organization
4. Recoding
5. Jsonification
## Setup
```bash
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
Make sure you have `uv` installed.
## Run
@@ -21,6 +20,32 @@ We assume that your song library is stored at `$SONG_LIBRARY`. Replace this plac
export SONG_LIBRARY=/path/to/library
```
### Web-based catalogue UI
**A web-based UI to browse the song library.** It supports searching by title and artist, and allows to mark and unmark favorites on the client side (using local storage).
The UI was developed in a different repository, available for historic reasons at https://gitlab.cl.uni-heidelberg.de/moser/karaokatalog-ui.
#### Serve
**Launch a server for the catalogue UI** on localhost. Mostly useful for development purposes.
ℹ️ This is completely risk-free, as it does not change files.
```bash
uv run -m karaokatalog.ui.serve $SONG_LIBRARY
```
To speed up the start, use jsonification to create and persist a JSON of all songs in the library root dir before starting this UI.
#### Bundle
**Create a bundle `.zip` file containg web app and data** that can be uploaded to any web hoster that can host static files.
```bash
uv run -m karaokatalog.ui.bundle $SONG_LIBRARY
```
### Deduplication
**Find and delete exactly duplicated songs**, i.e., songs with the same title and artist that also consist of exactly the same files in the directory.
@@ -30,7 +55,7 @@ export SONG_LIBRARY=/path/to/library
ℹ️ Deduplication is (mostly) risk-free: As it only deletes exact duplicates, you will not lose any data (given that I've made no programming errors, which is why the operation is only _mostly_ risk-free).
```bash
python3 -m karaokatalog.deduplicate $SONG_LIBRARY
uv run -m karaokatalog.deduplicate $SONG_LIBRARY
```
### Organization
@@ -40,5 +65,27 @@ python3 -m karaokatalog.deduplicate $SONG_LIBRARY
ℹ️ Moving will not overwrite already existing files, the operation is therefore risk-free.
```bash
python3 -m karaokatalog.organize $SONG_LIBRARY
uv run -m karaokatalog.organize $SONG_LIBRARY
```
### Recoding
**Re-encode all txt files into UTF-8.** This will use normal UTF-8, i.e., UTF-8 without BOM. If the txt uses UTF-8 with BOM, BOM is removed.
⚠️ This will _irreversibly_ change the encoding of the txt files (based on a guessed encoding).
ℹ️ Deduplication is risk-reduced: We only change the encoding if we are reasonably certain our guessed encoding is correct. However, we could still make mistakes when detecting.
```bash
uv run -m karaokatalog.recode $SONG_LIBRARY
```
### Jsonification
**Create a `songs.json` file with some song metadata in the root library dir.**
ℹ️ This operation is risk-free: If a `songs.json` already exists, it is kept intact (so no data loss possible).
```bash
uv run -m karaokatalog.jsonify $SONG_LIBRARY
```
+6 -2
View File
@@ -1,9 +1,10 @@
from __future__ import annotations
import filecmp
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import Self
from uuid import UUID, uuid4
from karaokatalog.parse_song_txt import parse_song_txt
@@ -25,6 +26,7 @@ class Song:
video: Path | None
cover: Path | None
song_txt: Path
uuid: UUID = field(default=uuid4())
@property
def dir(self) -> Path:
@@ -52,6 +54,7 @@ class Song:
audio_name = tags.get("AUDIO", tags.get("MP3"))
video_name = tags.get("VIDEO")
cover_name = tags.get("COVER")
uuid = tags.get("UUID")
if not title or not artist:
# Both are mandatory according to the specification
@@ -64,11 +67,12 @@ class Song:
video=song_txt / video_name if video_name else None,
cover=song_txt / cover_name if cover_name else None,
song_txt=song_txt,
uuid=UUID(uuid) if uuid else uuid4(),
)
def as_dict(self) -> dict[str, str]:
return {
"uuid": str(self.uuid),
"title": self.title,
"artist": self.artist,
# TODO More fields
}
@@ -15,7 +15,12 @@ class MoveInstruction(Instruction):
new_path: Path
def __call__(self) -> None:
if self.old_path == self.new_path:
# No-op
return
if self.new_path.exists():
raise FileExistsError("New path already exists, not moving anything")
self.new_path.parent.mkdir(exist_ok=True, parents=True)
self.old_path.rename(self.new_path)
@@ -0,0 +1,22 @@
from dataclasses import dataclass
from pathlib import Path
from karaokatalog.instructions.Instruction import Instruction
@dataclass(frozen=True)
class RecodeInstruction(Instruction):
"""
Open the file at the given path with the given old encoding, and save it with the given new encoding.
"""
path: Path
old_encoding: str
new_encoding: str
def __call__(self) -> None:
with self.path.open("r", encoding=self.old_encoding) as f:
file_contents = f.read()
with self.path.open("w", encoding=self.new_encoding) as f:
f.write(file_contents)
View File
+30
View File
@@ -0,0 +1,30 @@
import json
import logging
from karaokatalog.get_parser import get_parser
from karaokatalog.Library import Library
logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(message)s", level=logging.INFO
)
if __name__ == "__main__":
args = get_parser(
"jsonify", "Create a JSON file containing metadata of all songs in the library"
).parse_args()
logging.info("Karaokatalog Jsonification started")
logging.info("Loading library")
library = Library.from_dir(args.library_path)
logging.info("Library loaded")
songs = [song.as_dict() for song in library.songs]
json_path = args.library_path / "songs.json"
logging.info(f"Writing JSON to {json_path}")
with json_path.open("x", encoding="utf-8") as f:
json.dump(songs, f, ensure_ascii=False, indent=4)
logging.info("Karaokatalog Jsonification done")
+5
View File
@@ -27,7 +27,12 @@ if __name__ == "__main__":
logging.warning(f"Moving {len(move_instructions)} songs!")
for instruction in tqdm(move_instructions, unit=" songs"):
try:
instruction()
except FileExistsError:
logging.error(
f"Could not move {instruction.old_path} -> {instruction.new_path}"
)
logging.info("Moving done")
logging.info("Karaokatalog Organization done")
+26 -7
View File
@@ -2,10 +2,16 @@ import re
from collections.abc import Sequence
from pathlib import Path
from tqdm import tqdm
from karaokatalog.instructions.MoveInstruction import MoveInstruction
from karaokatalog.Song import Song
FORBIDDEN_CHARACTERS = re.compile(r'[\/<>:"\\|?*]')
# Those characters are forbidden on Linux (few) or on Windows (many), so we strip them from filenames
# https://stackoverflow.com/a/31976060/
# Apparently, trailing dots are also prohibited, so we strip those as well:
# https://lkml.iu.edu/hypermail/linux/kernel/2203.2/01877.html
FORBIDDEN_CHARACTERS = re.compile(r'[\/<>:"\\|?*]|\.$')
def _get_canonical_song_dir(song: Song, variant: int = 0) -> Path:
@@ -26,29 +32,42 @@ def move(songs: Sequence[Song], base_dir: Path) -> Sequence[MoveInstruction]:
Create move instructions to move every song into the proper song directory
within the given base_dir.
"""
song_dirs = set(song.dir.relative_to(base_dir) for song in songs)
# We lower case all the song directories, because Windows filesystems are often
# case-insensitive, so we have to ignore casing when finding duplicates
song_dir_strs_lower = set(
str(song.dir.relative_to(base_dir)).lower() for song in songs
)
move_instructions = []
for song in songs:
for song in tqdm(songs, unit=" songs"):
absolute_song_dir = song.dir
song_dir = absolute_song_dir.relative_to(base_dir)
song_dir_str_lower = str(song_dir).lower()
canonical_song_dir = _get_canonical_song_dir(song)
if song_dir not in song_dirs:
if song_dir_str_lower not in song_dir_strs_lower:
# A move instruction has already been generated for the dir this song is in
# (which is possible, because some dirs contain multiple (variants of) songs)
continue
if song_dir != canonical_song_dir:
# song_dir is not a good name, we want to replace it, so this path will soon be free
song_dirs.remove(song_dir)
song_dir_strs_lower.remove(song_dir_str_lower)
# Find a canonical song dir variant that does not yet exist
variant = 1
while canonical_song_dir in song_dirs:
while str(canonical_song_dir).lower() in song_dir_strs_lower:
canonical_song_dir = _get_canonical_song_dir(song, variant)
variant += 1
song_dir_strs_lower.add(str(canonical_song_dir).lower())
if song_dir == canonical_song_dir:
# After finding a free variant index, we arrived at the index we already had previously, this means
# we do not need to generate a move instruction
continue
song_dirs.add(canonical_song_dir)
move_instructions.append(
MoveInstruction(absolute_song_dir, base_dir / canonical_song_dir)
)
View File
+30
View File
@@ -0,0 +1,30 @@
import logging
from tqdm import tqdm
from karaokatalog.get_parser import get_parser
from karaokatalog.recode.recode import recode
logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(message)s", level=logging.INFO
)
if __name__ == "__main__":
args = get_parser(
"recode", "Recode all txt files in the directory into UTF-8"
).parse_args()
logging.info("Recoding started")
logging.info("Finding *.txt files")
txt_paths = list(tqdm(args.library_path.rglob("*.txt"), unit=" files"))
logging.info(f"{len(txt_paths)} txt files found")
logging.info("Generating recode instructions")
recode_instructions = recode(txt_paths)
logging.info(f"{len(recode_instructions)} recode instructions generated")
logging.warning(f"Recoding {len(recode_instructions)} files!")
for instruction in tqdm(recode_instructions, unit=" files"):
instruction()
logging.info("Recoding done")
+38
View File
@@ -0,0 +1,38 @@
from collections.abc import Sequence
from pathlib import Path
from karaokatalog.instructions.RecodeInstruction import RecodeInstruction
ENCODINGS_TO_TRY = (
"utf-8",
"cp1252",
)
BOM = "\ufeff" # Byte Order Mark, totally useless for UTF-8, still sometimes there
def try_reading_file_with_encoding(path: Path, encoding: str) -> str:
with path.open("r", encoding=encoding) as f:
return f.read()
def guess_encoding(path: Path) -> str:
for encoding_to_try in ENCODINGS_TO_TRY:
try:
content = try_reading_file_with_encoding(path, encoding_to_try)
if encoding_to_try == "utf-8" and content.startswith(BOM):
return "utf-8-sig"
else:
return encoding_to_try
except UnicodeDecodeError:
pass # This was not the right encoding, let's try again
raise UnicodeError("Could not guess encoding.")
def recode(paths: Sequence[Path]) -> Sequence[RecodeInstruction]:
return [
RecodeInstruction(path, old_encoding=old_encoding, new_encoding="utf-8")
for path in paths
if (old_encoding := guess_encoding(path)) != "utf-8"
]
View File
View File
+47
View File
@@ -0,0 +1,47 @@
import json
import logging
from pathlib import Path
from zipfile import ZipFile
from karaokatalog.get_parser import get_parser
from karaokatalog.Library import Library
logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(message)s", level=logging.INFO
)
STATIC_DIR = Path(__file__).parent.parent / "static"
if __name__ == "__main__":
parser = get_parser(
"ui.bundle",
"Create a bundle ZIP containing the Karaokatalog web app and all data which can be uploaded to a static web server.",
)
parser.add_argument(
"-o",
"--out-path",
type=Path,
default=Path("./karaokatalog.zip"),
help="The path at which the output ZIP file should be stored",
)
args = parser.parse_args()
with ZipFile(args.out_path, "w") as out_zip:
for file_path in STATIC_DIR.rglob("*"):
relative_path = file_path.relative_to(STATIC_DIR)
out_zip.write(file_path, relative_path)
songs_json_path = args.library_path / "songs.json"
if songs_json_path.exists():
logging.info(f"Using existing songs.json at {songs_json_path}")
out_zip.write(songs_json_path, "songs.json")
else:
logging.info("Loading library")
library = Library.from_dir(args.library_path)
logging.info("Library loaded")
songs = [song.as_dict() for song in library.songs]
songs_json = json.dumps(songs, ensure_ascii=False, indent=4)
out_zip.writestr("songs.json", songs_json)
View File
+32
View File
@@ -0,0 +1,32 @@
import json
import logging
from karaokatalog.get_parser import get_parser
from karaokatalog.Library import Library
from karaokatalog.ui.serve.get_flask_app import get_flask_app
logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(message)s", level=logging.INFO
)
if __name__ == "__main__":
args = get_parser(
"ui.serve", "Serve a web UI to browse the Karaokatalog"
).parse_args()
songs_json_path = args.library_path / "songs.json"
if songs_json_path.exists():
logging.info(f"Loading {songs_json_path}")
with songs_json_path.open("r", encoding="utf-8") as f:
songs = json.load(f)
else:
logging.info("Loading library")
library = Library.from_dir(args.library_path)
logging.info("Library loaded")
songs = [song.as_dict() for song in library.songs]
logging.info("Starting UI")
app = get_flask_app(songs)
app.run(port=5657)
+20
View File
@@ -0,0 +1,20 @@
from collections.abc import Sequence
from flask import Flask, Response
def get_flask_app(songs: Sequence[dict[str, str]]) -> Flask:
app = Flask("karaokatalog.ui", static_url_path="/")
# This is only to make the type checker happy.
songs_list = list(songs)
@app.get("/songs.json")
def get_songs() -> list[dict[str, str]]:
return songs_list
@app.get("/")
def get_index() -> Response:
return app.send_static_file("index.html")
return app
@@ -0,0 +1,23 @@
import Base from "./Base.js"
import SongList from "../pieces/SongList.js"
import Song from "../../model/Song.js"
import search from "../../model/search.js"
export default {
oncreate() {
document.title = "Alle Songs · Karaokatalog"
},
oninit() {
Song.load()
},
view() {
const songs = search.apply(Song.all)
const message = !songs ? "Lade Songs..." : songs.length === 0 ? "Keine Songs gefunden." : null
return m(
Base,
message && m("p", message),
songs && m(SongList, { songs })
)
},
}
@@ -0,0 +1,7 @@
import Header from "../pieces/Header.js"
export default {
view(vnode) {
return [m(Header), m("main", vnode.children)]
},
}
@@ -0,0 +1,41 @@
import Base from "./Base.js"
import SongList from "../pieces/SongList.js"
import Song from "../../model/Song.js"
import search from "../../model/search.js"
import ClearSearchLink from "../pieces/ClearSearchLink.js"
export default {
oncreate() {
document.title = "Favoriten · Karaokatalog"
},
oninit() {
Song.load()
},
view() {
const allFavorites = Song.all?.filter(song => song.favorite)
const favoritesToShow = search.apply(allFavorites)
let message = null
if(!allFavorites) {
message = "Lade Songs..."
} else if(allFavorites.length === 0) {
message = "Du hast noch keine Favoriten markiert."
} else if(favoritesToShow.length === 0) {
message = [
`Deine Suche ergab keine Treffer unter deinen ${allFavorites.length} Favoriten. `,
m(ClearSearchLink, { totalFavoriteCount: allFavorites.length}),
]
} else if(allFavorites.length > favoritesToShow.length) {
message = [
`Deine Suche zeigt ${favoritesToShow.length} von deinen insgesamt ${allFavorites.length} Favoriten. `,
m(ClearSearchLink, { totalFavoriteCount: allFavorites.length}),
]
}
return m(
Base,
message && m("p", message),
favoritesToShow && m(SongList, { songs: favoritesToShow }),
)
},
}
@@ -0,0 +1,14 @@
import Song from "./Song.js"
export default {
view: function (vnode) {
const { artist, songs } = vnode.attrs
return m(
"section.artist",
[
m("h2", artist),
songs.map(song => m(Song, { song })),
],
)
},
}
@@ -0,0 +1,27 @@
/* TODO: TAINT: AI-generated. This file was AI-generated (Gemini and/or Cursor), and I haven't manually reviewed it yet.
I should do this at some point in the future and change things I don't like. */
import search from "../../model/search.js"
export default {
view: function (vnode) {
return ["Möchtest du ", m(
"a.clear-search-link",
{
onclick: e => {
e.preventDefault()
search.query = null
},
href: "#",
role: "button",
tabindex: 0,
onkeypress: e => {
if ((e.key === "Enter" || e.key === " ")) {
e.preventDefault()
search.query = null
}
},
},
`alle ${vnode.attrs.totalFavoriteCount} Favoriten anzeigen`,
), "?"]
},
}
@@ -0,0 +1,8 @@
import SearchBar from "./SearchBar.js"
import TabBar from "./TabBar.js"
export default {
view() {
return m("header", [m(SearchBar), m(TabBar)])
},
}
@@ -0,0 +1,16 @@
import PaginationControls from "./PaginationControls.js"
const ELEMENTS_PER_PAGE = 25
export default {
view(vnode) {
const elements = vnode.attrs.elements.toArray()
const totalPages = Math.ceil(elements.length / ELEMENTS_PER_PAGE)
const startIndex = (this.currentPageIndex || 0) * ELEMENTS_PER_PAGE
return [
elements.slice(startIndex, startIndex + ELEMENTS_PER_PAGE),
m(PaginationControls, { currentPage: (this.currentPageIndex || 0) + 1, totalPages, onPageChange: i => this.currentPageIndex = i - 1 })
]
}
}
@@ -0,0 +1,122 @@
/* TODO: TAINT: AI-generated. This file was AI-generated (Gemini and/or Cursor), and I haven't manually reviewed it yet.
I should do this at some point in the future and change things I don't like. */
const PaginationControls = {
view: function (vnode) {
const { currentPage, totalPages, onPageChange } = vnode.attrs
if (totalPages <= 1) {
return null // Keine Steuerelemente anzeigen, wenn nicht benötigt
}
const pages = []
const delta = 2 // Anzahl der Seiten, die um die aktuelle Seite herum angezeigt werden
// Hilfsfunktion zum Hinzufügen einer Seitenzahl
const addPage = pageNumber => {
pages.push(
m(
"span.page-number",
{
class:
pageNumber === currentPage ? "active" : "clickable",
onclick: () =>
pageNumber !== currentPage
? onPageChange(pageNumber)
: null,
role: "button",
tabindex: 0, // Für Tastaturnavigation
onkeypress: e => {
// Für Tastaturnavigation (Enter/Space)
if (
(e.key === "Enter" || e.key === " ") &&
pageNumber !== currentPage
) {
e.preventDefault()
onPageChange(pageNumber)
}
},
"aria-label": `Gehe zu Seite ${pageNumber}`,
"aria-current":
pageNumber === currentPage ? "page" : undefined,
},
pageNumber,
),
)
}
// "Zurück"-Button
const prevButton = m(
"button.pagination-button",
{
onclick: () => onPageChange(currentPage - 1),
disabled: currentPage === 1,
"aria-label": "Vorherige Seite",
},
"Zurück",
)
// "Weiter"-Button
const nextButton = m(
"button.pagination-button",
{
onclick: () => onPageChange(currentPage + 1),
disabled: currentPage === totalPages,
"aria-label": "Nächste Seite",
},
"Weiter",
)
// Logik zur Seitenzahlanzeige
if (totalPages <= 7) {
// Alle Seiten anzeigen, wenn es 7 oder weniger sind
for (let i = 1; i <= totalPages; i++) {
addPage(i)
}
} else {
addPage(1) // Immer die erste Seite anzeigen
if (currentPage > delta + 2) {
// Auslassungspunkte anzeigen, wenn die aktuelle Seite weit vom Anfang entfernt ist
pages.push(m("span.dots", "..."))
}
// Bereich der Seiten um die aktuelle Seite bestimmen
let start = Math.max(2, currentPage - delta)
let end = Math.min(totalPages - 1, currentPage + delta)
if (currentPage <= delta + 1) {
// Aktuelle Seite ist nahe am Anfang
start = 2
end = Math.min(totalPages - 1, 2 * delta + 1)
} else if (currentPage >= totalPages - delta) {
// Aktuelle Seite ist nahe am Ende
start = Math.max(2, totalPages - 2 * delta)
end = totalPages - 1
}
// Korrektur für den Fall, dass start und end sich überschneiden oder ungültig werden
if (start > end && currentPage < totalPages / 2) {
// Wenn start > end und wir sind eher am Anfang
end = start // Zeige zumindest die Startseite
} else if (start > end && currentPage > totalPages / 2) {
// Wenn start > end und wir sind eher am Ende
start = end // Zeige zumindest die Endseite
}
for (let i = start; i <= end; i++) {
if (i > 0) addPage(i) // Sicherstellen, dass i positiv ist
}
if (currentPage < totalPages - delta - 1 && end < totalPages - 1) {
// Auslassungspunkte anzeigen, wenn die aktuelle Seite weit vom Ende entfernt ist
pages.push(m("span.dots", "..."))
}
if (totalPages > 1) addPage(totalPages) // Immer die letzte Seite anzeigen (wenn nicht 1)
}
return m("div.pagination-controls", [prevButton, ...pages, nextButton])
},
}
export default PaginationControls
@@ -0,0 +1,15 @@
import search from "../../model/search.js"
export default {
view: function () {
return m("search", [
m("input[type=search]", {
value: search.query,
placeholder: "Songs oder Interpreten suchen...",
oninput: e => {
search.query = e.target.value
},
}),
])
},
}
@@ -0,0 +1,25 @@
export default {
view: function (vnode) {
const { song } = vnode.attrs
return m("article.song", [
m(".info", [
m(".title", song.title),
m(".artist", song.artist),
]),
m(".actions", [
m(
"span.material-symbols-outlined.heart-icon",
{
class: song.favorite ? "is-favorite" : "",
onclick: () => song.toggleFavorite(),
title: song.favorite
? "Als Favorit entfernen"
: "Als Favorit markieren",
},
"favorite",
),
]),
])
},
}
@@ -0,0 +1,11 @@
import Artist from "./Artist.js"
import Pagination from "./Pagination.js"
export default {
view: function (vnode) {
const sortedSongs = vnode.attrs.songs.toSorted((a, b) => a.artist.localeCompare(b.artist) || a.title.localeCompare(b.title))
const songsByArtist = Map.groupBy(sortedSongs, song => song.artist)
return m(Pagination, { elements: songsByArtist.entries().map(([artist, songs]) => m(Artist, { artist, songs })) })
},
}
@@ -0,0 +1,16 @@
/* TODO: TAINT: AI-generated. This file was AI-generated (Gemini and/or Cursor), and I haven't manually reviewed it yet.
I should do this at some point in the future and change things I don't like. */
export const Tab = {
view(vnode) {
return m(
m.route.Link,
{
class: m.route.get() === vnode.attrs.href ? "active" : "",
href: vnode.attrs.href,
role: "button", // Für Barrierefreiheit
tabindex: 0, // Für Tastaturnavigation
},
vnode.attrs.label,
)
},
}
@@ -0,0 +1,16 @@
import { Tab } from "./Tab.js"
export default {
view() {
return m("nav", [
m(Tab, {
href: "/all",
label: "Alle",
}),
m(Tab, {
href: "/favorites",
label: "Favoriten",
}),
])
},
}
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Karaokatalog</title>
<link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0..1,0"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main>
<p>Lade Karaokatalog-App...</p>
</main>
<script type="module" src="karaokatalog.js"></script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
import "./lib/mithril.min.js"
import All from "./components/pages/All.js"
import Favorites from "./components/pages/Favorites.js"
/*
* We leave the default routing strategy (https://mithril.js.org/route.html#routing-strategies), i.e.,
* with a #! prefix, and do not change m.route.prefix.
*
* This is to make it easier to deploy this app on servers without changing the configuration.
*/
m.route(document.body, "/all", {
"/all": All,
"/favorites": Favorites,
})
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2017 Leo Horie
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because one or more lines are too long
+64
View File
@@ -0,0 +1,64 @@
function toMap(objs) {
return new Map(objs.map(obj => [obj.id || obj.uuid, obj]))
}
export class Base {
/**
* A Map mapping ids to instances of `Base`.
* Must be treated as private.
*/
static _byId = null
/**
* @return A list of all instances of `Base`
*/
static get all() {
return this._byId ? Array.from(this._byId.values()) : null
}
/**
* @param {str} id A id
* @returns The instance with the given id, if it exists
*/
static get(id) {
return this._byId ? this._byId.get(id) : null
}
/**
* Load the objects from the API, unless the objects have already been loaded.
* They are then stored in `this.byUid`.
*
* @returns Nothing.
*/
static async load() {
if (!this._byId) {
const objects = await this.forceLoad()
// Hack: We get plain JSON objects from the server, however, we'd like
// them to actually be instances of the classes we define. So, we first create a new empty
// instance of whatever subclass of `Base` we are currently in using `new this()`,
// and then copy all properties from the plain object over to the instance
// using `Object.assign`.
const instances = objects.map(obj => Object.assign(new this(), obj))
this._byId = toMap(instances)
}
}
/**
* If the objects were already loaded, forget them and load them again.
* If the objects were not yet loaded, don't do anything.
*/
static refresh() {
if (this._byId) {
this._byId = null
this.load()
}
}
/**
* Load the objects from the API and return them.
* Abstract method, must be overriden in child classes.
*
* @returns A list of instances.
*/
static async forceLoad() {}
}
+24
View File
@@ -0,0 +1,24 @@
import { Base } from "./Base.js"
import { getFavoriteIds, addFavorite, removeFavorite } from "./favorites.js"
export default class Song extends Base {
static async forceLoad() {
return await m.request({ url: "/songs.json" })
}
get favorite() {
return getFavoriteIds().has(this.id || this.uuid)
}
set favorite(isFavorite) {
if (isFavorite) {
addFavorite(this.id || this.uuid)
} else {
removeFavorite(this.id || this.uuid)
}
}
toggleFavorite() {
this.favorite = !this.favorite
}
}
+32
View File
@@ -0,0 +1,32 @@
const FAVORITES_STORAGE_KEY = "songAppFavorites"
let favoriteIds = null
export function getFavoriteIds() {
if (favoriteIds === null) {
favoriteIds = new Set(
JSON.parse(localStorage.getItem(FAVORITES_STORAGE_KEY)) || [],
)
}
return favoriteIds
}
function setFavoriteIds(favs) {
localStorage.setItem(
FAVORITES_STORAGE_KEY,
JSON.stringify(Array.from(favs)),
)
}
export function addFavorite(id) {
const favs = getFavoriteIds()
favs.add(id)
setFavoriteIds(favs)
}
export function removeFavorite(id) {
const favs = getFavoriteIds()
favs.delete(id)
setFavoriteIds(favs)
}
+10
View File
@@ -0,0 +1,10 @@
export default {
query: null,
apply(songs) {
const normalizedQuery = this.query?.trim()?.toLowerCase()
return normalizedQuery ? songs?.filter(
song => song.title?.toLowerCase()?.includes(normalizedQuery) || song.artist?.toLowerCase()?.includes(normalizedQuery)
) : songs
}
}
+275
View File
@@ -0,0 +1,275 @@
/* TODO: TAINT: AI-generated. This file was AI-generated (Gemini and/or Cursor), and I haven't manually reviewed it yet.
I should do this at some point in the future and change things I don't like. */
/* Globale Resets und Basis-Styling (wie vorher) */
body,
html {
margin: 0;
padding: 0;
font-family: "Roboto", sans-serif;
background-color: #f4f4f9;
color: #333;
line-height: 1.6;
}
main > p {
text-align: center;
padding: 1em;
font-size: 1.1em;
color: #757575;
}
/* Suchleiste (Höhe ca. 60px) */
search {
position: fixed;
top: 0;
left: 0;
right: 0;
background-color: #3f51b5; /* Indigo - Material Primary */
padding: 12px 16px;
z-index: 1000;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
box-sizing: border-box; /* Stellt sicher, dass Padding die Höhe nicht sprengt */
height: 60px; /* Feste Höhe für genaue Positionierung */
}
input[type="search"] {
width: 100%;
padding: 10px 12px;
border-radius: 4px;
border: none;
font-size: 16px;
box-sizing: border-box;
background-color: #fff;
color: #333;
}
input[type="search"]::placeholder {
color: #888;
}
/* Tab Bar (Höhe ca. 48px) */
nav {
display: flex;
position: fixed;
top: 60px; /* Direkt unter der Suchleiste */
left: 0;
right: 0;
background-color: #3f51b5; /* Gleiche Farbe wie Suchleiste für einen einheitlichen Header-Block */
z-index: 999; /* Unter der Suchleiste, falls sie überlappen könnten */
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); /* Dezenter Schatten */
height: 48px; /* Feste Höhe */
box-sizing: border-box;
}
nav a {
flex-grow: 1;
display: flex; /* Für vertikales Zentrieren des Texts */
align-items: center;
justify-content: center;
padding: 0 8px; /* Horizontal Padding */
text-align: center;
color: rgba(
255,
255,
255,
0.75
); /* Etwas helleres, aber gedämpftes Weiß für inaktive Tabs */
cursor: pointer;
font-weight: 500;
font-size: 0.9em; /* Leicht kleiner als Haupttext */
text-transform: uppercase;
border-bottom: 3px solid transparent; /* Platzhalter für den aktiven Indikator */
transition:
color 0.2s ease-in-out,
border-bottom-color 0.2s ease-in-out;
user-select: none;
text-decoration: none;
}
nav a:hover {
background-color: rgba(255, 255, 255, 0.1); /* Leichter Hover-Effekt */
}
nav a.active {
color: #ffffff; /* Reines Weiß für aktiven Tab */
border-bottom-color: #ff4081; /* Material Accent Pink für den Indikator */
}
/* song .Liste Container */
main {
padding-top: calc(60px + 48px); /* Höhe Suchleiste + Höhe TabBar */
padding-bottom: 20px; /* Platz für Paginierung, falls am Ende */
}
/* Link zum Zurücksetzen der Suche in der main > p */
main > p a.clear-search-link,
main > p .clear-search-link {
/* Zweite Regel für den Fall, dass Mithril das 'a' nicht direkt rendert */
color: #3f51b5; /* Material Indigo, passend zum Header */
text-decoration: underline;
cursor: pointer;
font-weight: 500; /* Etwas hervorheben */
background: none; /* Sicherstellen, dass keine Button-Styles übernommen werden */
border: none;
padding: 0;
display: inline; /* Damit es sich wie ein normaler Link im Text verhält */
}
main > p a.clear-search-link:hover,
main > p a.clear-search-link:focus,
main > p .clear-search-link:hover,
main > p .clear-search-link:focus {
color: #303f9f; /* Dunkleres Indigo für Hover/Focus */
text-decoration: none; /* Optional: Unterstreichung bei Hover entfernen */
}
/* Restliche Styles für Artist-Group, song .Item etc. bleiben wie zuvor */
section.artist {
margin: 12px 8px;
background-color: #ffffff;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
section.artist > h2 {
margin: 0;
padding: 12px 16px;
background-color: #e8eaf6;
color: #303f9f;
font-size: 1.1em;
font-weight: 500;
border-bottom: 1px solid #c5cae9;
}
.song {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s ease-in-out;
}
.song:last-child {
border-bottom: none;
}
.song:hover {
background-color: #f9f9f9;
}
.song .info {
flex-grow: 1;
margin-right: 8px; /* Abstand zum Icon */
overflow: hidden; /* Verhindert Textüberlauf */
}
.song .title {
font-size: 1em;
font-weight: 400;
color: #212121;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; /* Text abschneiden bei Überlauf */
}
.song .artist {
font-size: 0.85em;
color: #757575;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.song .actions {
margin-left: auto;
flex-shrink: 0;
}
.heart-icon {
cursor: pointer;
color: #bdbdbd;
font-size: 24px;
user-select: none;
transition:
color 0.2s ease-in-out,
transform 0.1s ease-out;
vertical-align: middle;
}
.heart-icon:hover {
transform: scale(1.1);
}
.heart-icon.is-favorite {
color: #ff4081;
font-variation-settings: "FILL" 1;
}
.material-symbols-outlined {
font-variation-settings:
"FILL" 0,
"wght" 400,
"GRAD" 0,
"opsz" 24;
}
/* NEU: Pagination Controls Styles */
.pagination-controls {
display: flex;
justify-content: center;
align-items: center;
padding: 20px 0; /* Mehr Padding für bessere Sichtbarkeit */
margin-top: 10px;
user-select: none;
flex-wrap: wrap; /* Erlaubt Umbruch bei vielen Seitenzahlen auf kleinen Bildschirmen */
}
.pagination-controls button.pagination-button, /* Klasse für Buttons hinzugefügt */
.pagination-controls span.page-number {
margin: 4px; /* Etwas mehr Margin für Umbruch */
padding: 8px 12px;
border: 1px solid #ddd;
background-color: #fff;
color: #3f51b5; /* Material Indigo */
cursor: pointer;
border-radius: 4px;
transition:
background-color 0.2s,
color 0.2s,
border-color 0.2s;
font-size: 0.9em;
min-width: 36px; /* Mindestbreite für bessere Klickbarkeit */
text-align: center;
box-sizing: border-box;
}
.pagination-controls button.pagination-button:hover:not(:disabled),
.pagination-controls span.page-number.clickable:hover {
background-color: #e8eaf6; /* Heller Indigo-Ton für Hover */
border-color: #c5cae9;
}
.pagination-controls button.pagination-button:disabled {
color: #aaa;
cursor: not-allowed;
background-color: #f9f9f9;
border-color: #eee;
}
.pagination-controls span.page-number.active {
background-color: #3f51b5;
color: #fff;
border-color: #3f51b5;
font-weight: bold;
cursor: default;
}
.pagination-controls span.dots {
padding: 8px 6px;
color: #777;
margin: 4px;
font-size: 0.9em;
}
+7
View File
@@ -1,5 +1,12 @@
[project]
name = "karaokatalog"
version = "0.1.0"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"flask>=3.1.2",
"tqdm>=4.67.1",
]
[tool.ruff.lint]
select = [
-1
View File
@@ -1 +0,0 @@
tqdm~=4.67.1
Generated
+173
View File
@@ -0,0 +1,173 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "click"
version = "8.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "flask"
version = "3.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "karaokatalog"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "flask" },
{ name = "tqdm" },
]
[package.metadata]
requires-dist = [
{ name = "flask", specifier = ">=3.1.2" },
{ name = "tqdm", specifier = ">=4.67.1" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "tqdm"
version = "4.67.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925, upload-time = "2024-11-08T15:52:18.093Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" },
]