fixes + converter
This commit is contained in:
parent
ce8c934436
commit
42f3331c2d
25
README.md
25
README.md
@ -68,9 +68,34 @@ rebuild-db --help
|
|||||||
| `-f` / `--force` | Rebuild DB entries from scratch (do not reuse old entries; useful for `.aa` only if you understand the trade-offs) |
|
| `-f` / `--force` | Rebuild DB entries from scratch (do not reuse old entries; useful for `.aa` only if you understand the trade-offs) |
|
||||||
| `-r` / `--rename` | Rename unsafe paths outside `iPod_Control` |
|
| `-r` / `--rename` | Rename unsafe paths outside `iPod_Control` |
|
||||||
| `-l` / `--nolog` | Do not write `rebuild_db.log.txt` |
|
| `-l` / `--nolog` | Do not write `rebuild_db.log.txt` |
|
||||||
|
| `--shuffle-all` | Force `shuffle=1` on every track (overrides audiobook-style rules) |
|
||||||
|
| `--verbose` | Log each file as it is written (`type`, `shuffle`, `bookmark`), like the original KeyJ tool |
|
||||||
|
|
||||||
Optional rules file: **`rebuild_db.rules`** in the iPod root (see `--help` and original rebuild_db documentation for the rule syntax).
|
Optional rules file: **`rebuild_db.rules`** in the iPod root (see `--help` and original rebuild_db documentation for the rule syntax).
|
||||||
|
|
||||||
|
### WAV, M4A, and “not showing up”
|
||||||
|
|
||||||
|
**Hardware:** Apple’s specs for the 1st- and 2nd-gen iPod shuffle list **MP3, AAC (M4A/M4P/M4B), Audible, and WAV** (and AIFF on 2nd gen) as supported formats. The shuffle has **no screen**—tracks do not “appear” anywhere except through playback order and, on disk, under `iPod_Control/Music/`.
|
||||||
|
|
||||||
|
**Database:** This tool assigns **type 1** (MP3), **type 2** (AAC/M4A), **type 4** (WAV) in `iTunesSD`, matching KeyJ’s rebuild_db. If something still will not play:
|
||||||
|
|
||||||
|
1. Run with **`--verbose`** and confirm every file is listed with **`type=4`** and **`shuffle=1`** for WAV. If **`shuffle=0`**, a rule matched (e.g. **`*.book.???`** treats names like `anything.book.wav` as audiobooks). Use **`--shuffle-all`** or adjust **`rebuild_db.rules`**.
|
||||||
|
2. **PCM format:** The device is picky like many portables. Prefer **44.1 kHz, 16-bit, stereo, PCM WAV**. **Float WAV, 24/32-bit, or very high sample rates** may fail silently. Re-encode if needed, e.g. `ffmpeg -i in.wav -ar 44100 -ac 2 -sample_fmt s16 out.wav`.
|
||||||
|
3. **M4A** usually works because AAC matches what iTunes typically synced; if only M4A plays, format (2) vs WAV (4) on the wire is a good clue to try re-encoding WAV to AAC/MP3 as a test.
|
||||||
|
|
||||||
|
### Converting a folder of audio for the shuffle
|
||||||
|
|
||||||
|
Install **ffmpeg**, then (from a checkout or after `pip install ipodutils`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shuffle-convert /path/to/music
|
||||||
|
# writes 44.1 kHz / 16-bit / stereo WAV under /path/to/music/shuffle_ready/
|
||||||
|
shuffle-convert /path/to/music -o /path/to/out -f mp3
|
||||||
|
shuffle-convert /path/to/music --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, conversion uses **high-quality resampling** (SoXR when your ffmpeg supports it), **MP3 V0**, and **AAC 256 kbps**. If you hear ringing or “digital” grain, avoid `--fast` (that mode uses lighter resampling and lower bitrates). For the cleanest WAV, keep the default; re-encode from **lossless sources** when possible—transcoding MP3 → WAV cannot remove existing compression artifacts.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
By default, output is also written to **`rebuild_db.log.txt`** on the iPod root. Use **`--nolog`** to disable.
|
By default, output is also written to **`rebuild_db.log.txt`** on the iPod root. Use **`--nolog`** to disable.
|
||||||
|
|||||||
195
convert_for_shuffle.py
Normal file
195
convert_for_shuffle.py
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Convert audio files in a folder to formats friendly to 1st/2nd-gen iPod shuffle.
|
||||||
|
|
||||||
|
Requires ffmpeg on PATH. Defaults to 44.1 kHz, 16-bit, stereo PCM WAV."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Extensions we attempt to decode with ffmpeg (lowercase).
|
||||||
|
INPUT_EXTENSIONS = frozenset(
|
||||||
|
{
|
||||||
|
".wav",
|
||||||
|
".mp3",
|
||||||
|
".m4a",
|
||||||
|
".m4b",
|
||||||
|
".m4p",
|
||||||
|
".flac",
|
||||||
|
".ogg",
|
||||||
|
".opus",
|
||||||
|
".aif",
|
||||||
|
".aiff",
|
||||||
|
".wma",
|
||||||
|
".mpc",
|
||||||
|
".wv",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
OUTPUT_EXT = {"wav": ".wav", "mp3": ".mp3", "aac": ".m4a"}
|
||||||
|
|
||||||
|
|
||||||
|
def _ffmpeg_has_soxr() -> bool:
|
||||||
|
r = subprocess.run(
|
||||||
|
["ffmpeg", "-hide_banner", "-h", "filter=aresample"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
return "soxr" in (r.stdout + r.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def _resample_filter(high_quality: bool) -> str:
|
||||||
|
"""Filter graph for 44.1 kHz stereo; SoXR avoids metallic ringing from default resampler."""
|
||||||
|
if not high_quality:
|
||||||
|
return "aresample=44100"
|
||||||
|
if _ffmpeg_has_soxr():
|
||||||
|
return (
|
||||||
|
"aresample=44100:resampler=soxr:precision=28:cutoff=0.91:"
|
||||||
|
"dither_method=triangular_hp"
|
||||||
|
)
|
||||||
|
return "aresample=44100:filter_size=256:cutoff=0.97:cheby=1"
|
||||||
|
|
||||||
|
|
||||||
|
def ffmpeg_cmd(
|
||||||
|
src: Path,
|
||||||
|
dst: Path,
|
||||||
|
fmt: str,
|
||||||
|
*,
|
||||||
|
high_quality: bool,
|
||||||
|
) -> list[str]:
|
||||||
|
af = _resample_filter(high_quality)
|
||||||
|
base = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-nostdin",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
str(src),
|
||||||
|
"-af",
|
||||||
|
af,
|
||||||
|
"-ac",
|
||||||
|
"2",
|
||||||
|
]
|
||||||
|
if fmt == "wav":
|
||||||
|
return base + [
|
||||||
|
"-sample_fmt",
|
||||||
|
"s16",
|
||||||
|
"-c:a",
|
||||||
|
"pcm_s16le",
|
||||||
|
str(dst),
|
||||||
|
]
|
||||||
|
if fmt == "mp3":
|
||||||
|
# V0 (~245 kbps) is much cleaner than -q:a 2; optional LAME joint stereo off
|
||||||
|
q = "0" if high_quality else "2"
|
||||||
|
extra = ["-joint_stereo", "0"] if high_quality else []
|
||||||
|
return base + ["-c:a", "libmp3lame", "-q:a", q] + extra + [str(dst)]
|
||||||
|
if fmt == "aac":
|
||||||
|
br = "256k" if high_quality else "192k"
|
||||||
|
return base + ["-c:a", "aac", "-b:a", br, str(dst)]
|
||||||
|
raise ValueError(fmt)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="Convert audio in a folder to iPod shuffle–friendly format (needs ffmpeg)."
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"input_dir",
|
||||||
|
type=Path,
|
||||||
|
help="Folder containing source audio files",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"-o",
|
||||||
|
"--output-dir",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Where to write converted files (default: <input_dir>/shuffle_ready)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"-f",
|
||||||
|
"--format",
|
||||||
|
choices=("wav", "mp3", "aac"),
|
||||||
|
default="wav",
|
||||||
|
help="Output format: wav=44.1kHz s16 stereo (default), mp3, or aac (.m4a)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"-n",
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Print actions only",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--force",
|
||||||
|
action="store_true",
|
||||||
|
help="Overwrite existing outputs",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--fast",
|
||||||
|
action="store_true",
|
||||||
|
help="Faster, lighter processing (older -ar style resampling, lower MP3/AAC quality)",
|
||||||
|
)
|
||||||
|
args = p.parse_args()
|
||||||
|
high_quality = not args.fast
|
||||||
|
|
||||||
|
if not shutil.which("ffmpeg"):
|
||||||
|
print("error: ffmpeg not found on PATH; install it and try again.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
inp: Path = args.input_dir.resolve()
|
||||||
|
if not inp.is_dir():
|
||||||
|
print(f"error: not a directory: {inp}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
out_root = args.output_dir
|
||||||
|
if out_root is None:
|
||||||
|
out_root = inp / "shuffle_ready"
|
||||||
|
out_root = out_root.resolve()
|
||||||
|
|
||||||
|
ext_out = OUTPUT_EXT[args.format]
|
||||||
|
jobs: list[tuple[Path, Path]] = []
|
||||||
|
for path in sorted(inp.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
if path.suffix.lower() not in INPUT_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(inp)
|
||||||
|
dst = out_root / rel.with_suffix(ext_out)
|
||||||
|
if dst == path:
|
||||||
|
dst = out_root / (rel.as_posix() + "_converted" + ext_out)
|
||||||
|
jobs.append((path, dst))
|
||||||
|
|
||||||
|
if not jobs:
|
||||||
|
print(f"No audio files found under {inp}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
for src, dst in jobs:
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cmd = ffmpeg_cmd(src, dst, args.format, high_quality=high_quality)
|
||||||
|
if args.dry_run:
|
||||||
|
print(" ".join(cmd))
|
||||||
|
continue
|
||||||
|
if dst.exists() and not args.force:
|
||||||
|
print(f"skip (exists): {dst}")
|
||||||
|
continue
|
||||||
|
print(f"{src} -> {dst}")
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
print(f"error: ffmpeg failed on {src}\n{r.stderr}", file=sys.stderr)
|
||||||
|
sys.exit(r.returncode)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print(f"{len(jobs)} file(s) would be converted.")
|
||||||
|
else:
|
||||||
|
mode = "fast" if args.fast else "high-quality (SoXR resample when available)"
|
||||||
|
print(f"Done: {len(jobs)} file(s) -> {out_root} [{mode}]")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
40
main.py
40
main.py
@ -20,7 +20,9 @@ DEFAULT_RULES = [
|
|||||||
([("filename", "~", "*.m4?")], {"type": 2, "shuffle": 1, "bookmark": 0}),
|
([("filename", "~", "*.m4?")], {"type": 2, "shuffle": 1, "bookmark": 0}),
|
||||||
([("filename", "~", "*.m4b")], {"shuffle": 0, "bookmark": 1}),
|
([("filename", "~", "*.m4b")], {"shuffle": 0, "bookmark": 1}),
|
||||||
([("filename", "~", "*.aa")], {"type": 1, "shuffle": 0, "bookmark": 1, "reuse": 1}),
|
([("filename", "~", "*.aa")], {"type": 1, "shuffle": 0, "bookmark": 1, "reuse": 1}),
|
||||||
([("filename", "~", "*.wav")], {"type": 4, "shuffle": 0, "bookmark": 0}),
|
# type 4 = WAV to the firmware; KeyJ used shuffle=0 (voice-clips style). Use shuffle=1
|
||||||
|
# so WAVs join the normal playlist like MP3/M4A.
|
||||||
|
([("filename", "~", "*.wav")], {"type": 4, "shuffle": 1, "bookmark": 0}),
|
||||||
([("filename", "~", "*.book.???")], {"shuffle": 0, "bookmark": 1}),
|
([("filename", "~", "*.book.???")], {"shuffle": 0, "bookmark": 1}),
|
||||||
([("filename", "~", "*.announce.???")], {"shuffle": 0, "bookmark": 0}),
|
([("filename", "~", "*.announce.???")], {"shuffle": 0, "bookmark": 0}),
|
||||||
([("filename", "~", "/recycled/*")], {"ignore": 1}),
|
([("filename", "~", "/recycled/*")], {"ignore": 1}),
|
||||||
@ -38,8 +40,10 @@ GETOPT_LONG = (
|
|||||||
"logfile=",
|
"logfile=",
|
||||||
"rename",
|
"rename",
|
||||||
"ipod-path=",
|
"ipod-path=",
|
||||||
|
"shuffle-all",
|
||||||
|
"verbose",
|
||||||
)
|
)
|
||||||
HELP = "Rebuild iPod shuffle database.\n\nMandatory arguments to long options are mandatory for short options too.\n -h, --help display this help text\n -i, --interactive prompt before browsing each directory\n -v, --volume=VOL set playback volume to a value between 0 and 38\n -s, --nosmart do not use smart shuffle\n -n, --nochdir do not change directory to this scripts directory first\n -p, --ipod-path=DIR use DIR as the iPod root (mount point); skip --nochdir behavior\n -l, --nolog do not create a log file\n -f, --force always rebuild database entries, do not re-use old ones\n -L, --logfile set log file name\n\nWithout --ipod-path, run from the iPod's root directory (or rely on --nochdir /\nscript location as before). By default, the whole volume is searched for playable\nfiles, unless at least one DIRECTORY is specified."
|
HELP = "Rebuild iPod shuffle database.\n\nMandatory arguments to long options are mandatory for short options too.\n -h, --help display this help text\n -i, --interactive prompt before browsing each directory\n -v, --volume=VOL set playback volume to a value between 0 and 38\n -s, --nosmart do not use smart shuffle\n -n, --nochdir do not change directory to this scripts directory first\n -p, --ipod-path=DIR use DIR as the iPod root (mount point); skip --nochdir behavior\n -l, --nolog do not create a log file\n -f, --force always rebuild database entries, do not re-use old ones\n -L, --logfile set log file name\n --shuffle-all force shuffle=1 on every track (overrides audiobook-style rules)\n --verbose log each track as it is written (path, type, shuffle) like KeyJ\n\nWithout --ipod-path, run from the iPod's root directory (or rely on --nochdir /\nscript location as before). By default, the whole volume is searched for playable\nfiles, unless at least one DIRECTORY is specified."
|
||||||
ERR_NO_CTRL = "ERROR: No iPod control directory found!\nPlease make sure that:\n (*) this program's working directory is the iPod's root directory\n (*) the iPod was correctly initialized with iTunes"
|
ERR_NO_CTRL = "ERROR: No iPod control directory found!\nPlease make sure that:\n (*) this program's working directory is the iPod's root directory\n (*) the iPod was correctly initialized with iTunes"
|
||||||
ERR_NO_SD = "ERROR: Cannot write to the iPod database file (iTunesSD)!\nPlease make sure that:\n (*) you have sufficient permissions to write to the iPod volume\n (*) you are actually using an iPod shuffle, and not some other iPod model :)"
|
ERR_NO_SD = "ERROR: Cannot write to the iPod database file (iTunesSD)!\nPlease make sure that:\n (*) you have sufficient permissions to write to the iPod volume\n (*) you are actually using an iPod shuffle, and not some other iPod model :)"
|
||||||
|
|
||||||
@ -66,6 +70,8 @@ class ShuffleRebuild:
|
|||||||
"logfile": "rebuild_db.log.txt",
|
"logfile": "rebuild_db.log.txt",
|
||||||
"rename": False,
|
"rename": False,
|
||||||
"ipod_path": None,
|
"ipod_path": None,
|
||||||
|
"shuffle_all": False,
|
||||||
|
"verbose": False,
|
||||||
}
|
}
|
||||||
self.rules = list(DEFAULT_RULES)
|
self.rules = list(DEFAULT_RULES)
|
||||||
self.domains, self.total_count, self.known_entries = [], 0, {}
|
self.domains, self.total_count, self.known_entries = [], 0, {}
|
||||||
@ -342,8 +348,15 @@ class ShuffleRebuild:
|
|||||||
for ruleset, action in self.rules:
|
for ruleset, action in self.rules:
|
||||||
if all(self._match_rule(props, r) for r in ruleset):
|
if all(self._match_rule(props, r) for r in ruleset):
|
||||||
props.update(action)
|
props.update(action)
|
||||||
|
if self.opts["shuffle_all"]:
|
||||||
|
props["shuffle"] = 1
|
||||||
if props["ignore"]:
|
if props["ignore"]:
|
||||||
return 0
|
return 0
|
||||||
|
if self.opts["verbose"]:
|
||||||
|
self.log(
|
||||||
|
"=> %s type=%d shuffle=%d bookmark=%d"
|
||||||
|
% (filename, props["type"], props["shuffle"], props["bookmark"])
|
||||||
|
)
|
||||||
entry = props["reuse"] and self.known_entries.get(filename)
|
entry = props["reuse"] and self.known_entries.get(filename)
|
||||||
if not entry:
|
if not entry:
|
||||||
self._entry_template[29] = props["type"]
|
self._entry_template[29] = props["type"]
|
||||||
@ -378,12 +391,15 @@ class ShuffleRebuild:
|
|||||||
far = [s for s in range(sc) if metric[s] >= th]
|
far = [s for s in range(sc) if metric[s] >= th]
|
||||||
th = (min(fill) + max(fill) + 1) // 2
|
th = (min(fill) + max(fill) + 1) // 2
|
||||||
emp = [s for s in range(sc) if fill[s] <= th and s in far]
|
emp = [s for s in range(sc) if fill[s] <= th and s in far]
|
||||||
s = random.choice(emp or far)
|
choices = emp or far or list(range(sc))
|
||||||
|
s = random.choice(choices)
|
||||||
slices[s].append((n, d))
|
slices[s].append((n, d))
|
||||||
fill[s] += 1
|
fill[s] += 1
|
||||||
used.append(s)
|
used.append(s)
|
||||||
seq, last = [], -1
|
seq, last = [], -1
|
||||||
for sl in slices:
|
for sl in slices:
|
||||||
|
if not sl:
|
||||||
|
continue
|
||||||
random.shuffle(sl)
|
random.shuffle(sl)
|
||||||
if len(sl) > 2 and sl[0][1] == last:
|
if len(sl) > 2 and sl[0][1] == last:
|
||||||
sl.append(sl.pop(0))
|
sl.append(sl.pop(0))
|
||||||
@ -400,7 +416,7 @@ class ShuffleRebuild:
|
|||||||
a = array.array("B")
|
a = array.array("B")
|
||||||
a.frombytes(f.read())
|
a.frombytes(f.read())
|
||||||
ps = a.tolist()
|
ps = a.tolist()
|
||||||
except OSError, EOFError:
|
except (OSError, EOFError):
|
||||||
pass
|
pass
|
||||||
if len(ps) != 21:
|
if len(ps) != 21:
|
||||||
ps = list(_u24(29)) + [0] * 15 + list(_u24(1))
|
ps = list(_u24(29)) + [0] * 15 + list(_u24(1))
|
||||||
@ -431,6 +447,16 @@ class ShuffleRebuild:
|
|||||||
self.log("Generating shuffle sequence ...", end=" ")
|
self.log("Generating shuffle sequence ...", end=" ")
|
||||||
seq = list(range(n))
|
seq = list(range(n))
|
||||||
random.shuffle(seq)
|
random.shuffle(seq)
|
||||||
|
# KeyJ: only shuffle=1 tracks join smart-shuffle domains; shuffle=0 (audiobooks, etc.)
|
||||||
|
# is stored per entry. iTunesShuffle may be shorter than n; do not pad with fake indices.
|
||||||
|
shuf_tracks = sum(len(d) for d in self.domains)
|
||||||
|
if n and shuf_tracks < n and not self.opts["shuffle_all"]:
|
||||||
|
self.log(
|
||||||
|
"\nNOTE: %d of %d playable tracks use shuffle=1; the rest have shuffle=0 "
|
||||||
|
"(e.g. *.book.??? / *.m4b rules). Only shuffle=1 tracks appear in the shuffle "
|
||||||
|
"playlist. Use --shuffle-all, rename files, or rebuild_db.rules if that is wrong."
|
||||||
|
% (shuf_tracks, n)
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
with open("iPod_Control/iTunes/iTunesShuffle", "wb") as f:
|
with open("iPod_Control/iTunes/iTunesShuffle", "wb") as f:
|
||||||
f.write(b"".join(_u24(x) for x in seq))
|
f.write(b"".join(_u24(x) for x in seq))
|
||||||
@ -460,7 +486,7 @@ class ShuffleRebuild:
|
|||||||
ent[33::2].split(b"\0", 1)[0].decode("latin-1", "replace")
|
ent[33::2].split(b"\0", 1)[0].decode("latin-1", "replace")
|
||||||
] = ent
|
] = ent
|
||||||
ent = sd_read.read(558)
|
ent = sd_read.read(558)
|
||||||
except OSError, EOFError:
|
except (OSError, EOFError):
|
||||||
pass
|
pass
|
||||||
if sd_read:
|
if sd_read:
|
||||||
sd_read.close()
|
sd_read.close()
|
||||||
@ -560,6 +586,10 @@ def parse_argv(argv: list, s: ShuffleRebuild) -> list:
|
|||||||
o["logfile"] = arg
|
o["logfile"] = arg
|
||||||
elif opt in ("-r", "--rename"):
|
elif opt in ("-r", "--rename"):
|
||||||
o["rename"] = True
|
o["rename"] = True
|
||||||
|
elif opt == "--shuffle-all":
|
||||||
|
o["shuffle_all"] = True
|
||||||
|
elif opt == "--verbose":
|
||||||
|
o["verbose"] = True
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -25,9 +25,10 @@ dependencies = []
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
rebuild-db = "main:cli"
|
rebuild-db = "main:cli"
|
||||||
|
shuffle-convert = "convert_for_shuffle:main"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
py-modules = ["main"]
|
py-modules = ["main", "convert_for_shuffle"]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
package = true
|
package = true
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user