fixes + converter

This commit is contained in:
everbarry 2026-04-10 14:24:26 +02:00
parent ce8c934436
commit 42f3331c2d
4 changed files with 257 additions and 6 deletions

View File

@ -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:** Apples 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 KeyJs 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
View 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 shufflefriendly 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
View File

@ -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

View File

@ -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