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) |
|
||||
| `-r` / `--rename` | Rename unsafe paths outside `iPod_Control` |
|
||||
| `-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).
|
||||
|
||||
### 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
|
||||
|
||||
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", "~", "*.m4b")], {"shuffle": 0, "bookmark": 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", "~", "*.announce.???")], {"shuffle": 0, "bookmark": 0}),
|
||||
([("filename", "~", "/recycled/*")], {"ignore": 1}),
|
||||
@ -38,8 +40,10 @@ GETOPT_LONG = (
|
||||
"logfile=",
|
||||
"rename",
|
||||
"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_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",
|
||||
"rename": False,
|
||||
"ipod_path": None,
|
||||
"shuffle_all": False,
|
||||
"verbose": False,
|
||||
}
|
||||
self.rules = list(DEFAULT_RULES)
|
||||
self.domains, self.total_count, self.known_entries = [], 0, {}
|
||||
@ -342,8 +348,15 @@ class ShuffleRebuild:
|
||||
for ruleset, action in self.rules:
|
||||
if all(self._match_rule(props, r) for r in ruleset):
|
||||
props.update(action)
|
||||
if self.opts["shuffle_all"]:
|
||||
props["shuffle"] = 1
|
||||
if props["ignore"]:
|
||||
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)
|
||||
if not entry:
|
||||
self._entry_template[29] = props["type"]
|
||||
@ -378,12 +391,15 @@ class ShuffleRebuild:
|
||||
far = [s for s in range(sc) if metric[s] >= th]
|
||||
th = (min(fill) + max(fill) + 1) // 2
|
||||
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))
|
||||
fill[s] += 1
|
||||
used.append(s)
|
||||
seq, last = [], -1
|
||||
for sl in slices:
|
||||
if not sl:
|
||||
continue
|
||||
random.shuffle(sl)
|
||||
if len(sl) > 2 and sl[0][1] == last:
|
||||
sl.append(sl.pop(0))
|
||||
@ -400,7 +416,7 @@ class ShuffleRebuild:
|
||||
a = array.array("B")
|
||||
a.frombytes(f.read())
|
||||
ps = a.tolist()
|
||||
except OSError, EOFError:
|
||||
except (OSError, EOFError):
|
||||
pass
|
||||
if len(ps) != 21:
|
||||
ps = list(_u24(29)) + [0] * 15 + list(_u24(1))
|
||||
@ -431,6 +447,16 @@ class ShuffleRebuild:
|
||||
self.log("Generating shuffle sequence ...", end=" ")
|
||||
seq = list(range(n))
|
||||
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:
|
||||
with open("iPod_Control/iTunes/iTunesShuffle", "wb") as f:
|
||||
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
|
||||
ent = sd_read.read(558)
|
||||
except OSError, EOFError:
|
||||
except (OSError, EOFError):
|
||||
pass
|
||||
if sd_read:
|
||||
sd_read.close()
|
||||
@ -560,6 +586,10 @@ def parse_argv(argv: list, s: ShuffleRebuild) -> list:
|
||||
o["logfile"] = arg
|
||||
elif opt in ("-r", "--rename"):
|
||||
o["rename"] = True
|
||||
elif opt == "--shuffle-all":
|
||||
o["shuffle_all"] = True
|
||||
elif opt == "--verbose":
|
||||
o["verbose"] = True
|
||||
return args
|
||||
|
||||
|
||||
|
||||
@ -25,9 +25,10 @@ dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
rebuild-db = "main:cli"
|
||||
shuffle-convert = "convert_for_shuffle:main"
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["main"]
|
||||
py-modules = ["main", "convert_for_shuffle"]
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user