Compare commits
54
Commits
8676b00ae3
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8660d3200
|
||
|
|
a611362ad8
|
||
|
|
0373572eb7
|
||
|
|
6773d6d9bc
|
||
|
|
7c6ba261f8
|
||
|
|
0b69162d0b
|
||
|
|
740cee3d44
|
||
|
|
be55bb91dc
|
||
|
|
b3cefa32b7
|
||
|
|
30b9bdf37c
|
||
|
|
008ec35176
|
||
|
|
fda41d1bdd
|
||
|
|
3f0e459236
|
||
|
|
58a096db29
|
||
|
|
ef6e52c0cb
|
||
|
|
5066ce695d
|
||
|
|
625a6d72f7
|
||
|
|
a5c50bd8f9
|
||
|
|
4fa29a9161
|
||
|
|
e4c5806974
|
||
|
|
5573a6b127
|
||
|
|
6fe89af3cc
|
||
|
|
8f6a42f8b4
|
||
|
|
7fbea281b8
|
||
|
|
4064b4d9b7
|
||
|
|
8afeb618d3
|
||
|
|
ee8215e7bc
|
||
|
|
d114672681
|
||
|
|
40c516dcc8
|
||
|
|
6840a57e0b
|
||
|
|
2c555ba8e0
|
||
|
|
df2ada6e4b
|
||
|
|
85ba86596a
|
||
|
|
bbdaa34d7c
|
||
|
|
4c6ef77a27
|
||
|
|
fa3df3d2e2
|
||
|
|
92a001bf52
|
||
|
|
df5fc37419
|
||
|
|
b5649a349b
|
||
|
|
e4f7172f88
|
||
|
|
2ffae9ceda
|
||
|
|
4c00b01c78
|
||
|
|
12fdb5045c
|
||
|
|
e17d4054d5
|
||
|
|
c47c481731
|
||
|
|
4ccccf8667
|
||
|
|
fb031fcaf6
|
||
|
|
86a870d429
|
||
|
|
3d9f8d46ee
|
||
|
|
f2c0e5a4f7
|
||
|
|
b731339806
|
||
|
|
9fb5b3b05f
|
||
|
|
a29d52856c
|
||
|
|
9450e8d54f
|
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"semi": false,
|
||||||
|
"arrowParens": "avoid"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
@@ -1,23 +1,91 @@
|
|||||||
# Karaokatalog
|
# Karaokatalog
|
||||||
|
|
||||||
Tools to manage an Ultrastar DX song library. Features include:
|
Tools to manage an UltraStar DX song library. Features include:
|
||||||
|
|
||||||
1. Deduplication
|
1. Web-based catalogue UI
|
||||||
|
2. Deduplication
|
||||||
|
3. Organization
|
||||||
|
4. Recoding
|
||||||
|
5. Jsonification
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
```bash
|
Make sure you have `uv` installed.
|
||||||
python3 -m venv venv
|
|
||||||
source venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
### Deduplication
|
We assume that your song library is stored at `$SONG_LIBRARY`. Replace this placeholder when running the command, or just set the variable, e.g., like this:
|
||||||
|
|
||||||
If your song library is stored at `/path/to/library`, run:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m karaokatalog.deduplicate /path/to/library
|
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.
|
||||||
|
|
||||||
|
⚠️ This will _irreversibly_ delete all song folders it considers to be exact duplicates.
|
||||||
|
|
||||||
|
ℹ️ 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
|
||||||
|
uv run -m karaokatalog.deduplicate $SONG_LIBRARY
|
||||||
|
```
|
||||||
|
|
||||||
|
### Organization
|
||||||
|
|
||||||
|
**Rename/move every song folder to `$SONG_LIBRARY/<artist>/<title>`.** If such a folder already exists, a number is appended to distinguish.
|
||||||
|
|
||||||
|
ℹ️ Moving will not overwrite already existing files, the operation is therefore risk-free.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import filecmp
|
import filecmp
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Self
|
from typing import Self
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from karaokatalog.parse_song_txt import parse_song_txt
|
from karaokatalog.parse_song_txt import parse_song_txt
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ class Song:
|
|||||||
video: Path | None
|
video: Path | None
|
||||||
cover: Path | None
|
cover: Path | None
|
||||||
song_txt: Path
|
song_txt: Path
|
||||||
|
uuid: UUID = field(default=uuid4())
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def dir(self) -> Path:
|
def dir(self) -> Path:
|
||||||
@@ -52,6 +54,7 @@ class Song:
|
|||||||
audio_name = tags.get("AUDIO", tags.get("MP3"))
|
audio_name = tags.get("AUDIO", tags.get("MP3"))
|
||||||
video_name = tags.get("VIDEO")
|
video_name = tags.get("VIDEO")
|
||||||
cover_name = tags.get("COVER")
|
cover_name = tags.get("COVER")
|
||||||
|
uuid = tags.get("UUID")
|
||||||
|
|
||||||
if not title or not artist:
|
if not title or not artist:
|
||||||
# Both are mandatory according to the specification
|
# Both are mandatory according to the specification
|
||||||
@@ -64,11 +67,12 @@ class Song:
|
|||||||
video=song_txt / video_name if video_name else None,
|
video=song_txt / video_name if video_name else None,
|
||||||
cover=song_txt / cover_name if cover_name else None,
|
cover=song_txt / cover_name if cover_name else None,
|
||||||
song_txt=song_txt,
|
song_txt=song_txt,
|
||||||
|
uuid=UUID(uuid) if uuid else uuid4(),
|
||||||
)
|
)
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, str]:
|
def as_dict(self) -> dict[str, str]:
|
||||||
return {
|
return {
|
||||||
|
"uuid": str(self.uuid),
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
"artist": self.artist,
|
"artist": self.artist,
|
||||||
# TODO More fields
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import argparse
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
|
|
||||||
from karaokatalog.deduplicate.find_duplicates import find_duplicates
|
from karaokatalog.deduplicate.find_duplicates import find_duplicates
|
||||||
from karaokatalog.deduplicate.prune import prune
|
from karaokatalog.deduplicate.prune import prune
|
||||||
|
from karaokatalog.get_parser import get_parser
|
||||||
from karaokatalog.Library import Library
|
from karaokatalog.Library import Library
|
||||||
from karaokatalog.Song import Song
|
from karaokatalog.Song import Song
|
||||||
from karaokatalog.util.get_equivalence_classes import get_equivalence_classes
|
from karaokatalog.util.get_equivalence_classes import get_equivalence_classes
|
||||||
@@ -15,23 +14,10 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
prog="python3 -m karaokatalog.deduplicate",
|
|
||||||
description="Deduplicate UltraStar Deluxe song libraries",
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"library_path",
|
|
||||||
type=Path,
|
|
||||||
help="The directory which contains the songs, the one you'd also configure UltraStar Deluxe to use",
|
|
||||||
)
|
|
||||||
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
args = parse_args()
|
args = get_parser(
|
||||||
|
"deduplicate", "Deduplicate UltraStar Deluxe song libraries"
|
||||||
|
).parse_args()
|
||||||
logging.info("Karaokatalog Deduplication started")
|
logging.info("Karaokatalog Deduplication started")
|
||||||
|
|
||||||
logging.info("Loading library")
|
logging.info("Loading library")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import re
|
import re
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from karaokatalog.deduplicate.instructions.DeleteInstruction import DeleteInstruction
|
from karaokatalog.instructions.DeleteInstruction import DeleteInstruction
|
||||||
from karaokatalog.Song import Song
|
from karaokatalog.Song import Song
|
||||||
|
|
||||||
DISCOURAGED_DIR_PATTERN = re.compile(r"/UltrastarDX/Ultrastar DX/(Unsortiert/)?")
|
DISCOURAGED_DIR_PATTERN = re.compile(r"/UltrastarDX/Ultrastar DX/(Unsortiert/)?")
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def get_parser(module_name: str, description: str) -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog=f"python3 -m karaokatalog.{module_name}",
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"library_path",
|
||||||
|
type=Path,
|
||||||
|
help="The directory which contains the songs, the one you'd also configure UltraStar Deluxe to use",
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser
|
||||||
+1
-1
@@ -2,7 +2,7 @@ import shutil
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from karaokatalog.deduplicate.instructions.Instruction import Instruction
|
from karaokatalog.instructions.Instruction import Instruction
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from karaokatalog.instructions.Instruction import Instruction
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MoveInstruction(Instruction):
|
||||||
|
"""
|
||||||
|
Move the old path (might point to a file, a folder or a link) to the new path, no questions asked.
|
||||||
|
Does not override existing paths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
old_path: Path
|
||||||
|
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)
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
from karaokatalog.get_parser import get_parser
|
||||||
|
from karaokatalog.Library import Library
|
||||||
|
from karaokatalog.organize.move import move
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s", level=logging.INFO
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
args = get_parser(
|
||||||
|
"organize", "Organize UltraStar Deluxe song libraries"
|
||||||
|
).parse_args()
|
||||||
|
logging.info("Karaokatalog Organization started")
|
||||||
|
|
||||||
|
logging.info("Loading library")
|
||||||
|
library = Library.from_dir(args.library_path)
|
||||||
|
logging.info("Library loaded")
|
||||||
|
|
||||||
|
logging.info("Generating move instructions")
|
||||||
|
move_instructions = move(library.songs, args.library_path)
|
||||||
|
logging.info(f"{len(move_instructions)} move instructions generated")
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
"""
|
||||||
|
Get the (relative) canonical directory in which this song should be located, that is
|
||||||
|
the directory f"{song.artist}/{song.title}".
|
||||||
|
|
||||||
|
Illegal characters are replaced. If variant > 0, f" ({variant})" is appended to the result.
|
||||||
|
"""
|
||||||
|
variant_suffix = f" ({variant})" if variant > 0 else ""
|
||||||
|
return Path(FORBIDDEN_CHARACTERS.sub("", song.artist)) / Path(
|
||||||
|
FORBIDDEN_CHARACTERS.sub("", song.title) + variant_suffix
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 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 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_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_dir_strs_lower.remove(song_dir_str_lower)
|
||||||
|
|
||||||
|
# Find a canonical song dir variant that does not yet exist
|
||||||
|
variant = 1
|
||||||
|
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
|
||||||
|
|
||||||
|
move_instructions.append(
|
||||||
|
MoveInstruction(absolute_song_dir, base_dir / canonical_song_dir)
|
||||||
|
)
|
||||||
|
|
||||||
|
return tuple(move_instructions)
|
||||||
@@ -26,8 +26,20 @@ def _parse_tag_line(tag_line: str) -> tuple[str, str | None]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_song_txt(song_txt: Path) -> dict[str, Any]:
|
def _parse_song_txt_with_encoding(song_txt: Path, encoding: str) -> dict[str, Any]:
|
||||||
with song_txt.open(encoding="utf-8", errors="ignore") as f:
|
with song_txt.open(encoding=encoding) as f:
|
||||||
tags = dict(_parse_tag_line(line) for line in f if line.startswith("#"))
|
tags = dict(_parse_tag_line(line) for line in f if line.startswith("#"))
|
||||||
|
|
||||||
return tags
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def parse_song_txt(song_txt: Path) -> dict[str, Any]:
|
||||||
|
encodings_to_try = ("utf-8", "cp1252")
|
||||||
|
|
||||||
|
for encoding_to_try in encodings_to_try:
|
||||||
|
try:
|
||||||
|
return _parse_song_txt_with_encoding(song_txt, encoding_to_try)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise UnicodeError(f"Could not guess encoding for {song_txt}")
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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.
|
||||||
+1
File diff suppressed because one or more lines are too long
@@ -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() {}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
[project]
|
[project]
|
||||||
|
name = "karaokatalog"
|
||||||
|
version = "0.1.0"
|
||||||
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"flask>=3.1.2",
|
||||||
|
"tqdm>=4.67.1",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = [
|
select = [
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
tqdm~=4.67.1
|
|
||||||
@@ -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" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user