Compare commits

...

4 commits

Author SHA1 Message Date
12a747b425
replace readme 2025-06-20 17:01:57 -06:00
3e70280cc4
remove --nochdir and bump version 2025-06-20 17:00:45 -06:00
5047f729eb
use f-strings where possible 2025-06-20 16:39:52 -06:00
141f6bdeb1
use argparse + some minor cleanups 2025-06-20 16:17:38 -06:00
3 changed files with 141 additions and 266 deletions

3
README.md Normal file
View file

@ -0,0 +1,3 @@
Work-in-progress fork of Martin Fiedler's [iPod shuffle database builder](https://shuffle-db.sourceforge.net/), updated to Python 3 and modernized a little.
Better readme someday, maybe.

View file

@ -1,22 +0,0 @@
iPod shuffle Database Builder
=============================
This release contains multiple versions of the program:
rebuild_db.py - a Python script for almost any platform (RECOMMENDED)
rebuild_db.exe - a standalone Win32 console application (DEPRECATED)
Please use the Python version, because it is the only one that is actively
developed, and it has a lot more features compared to the Win32 version.
To use the Database Builder, copy rebuild_db.py to your iPod's root directory
and start it, e.g. with a double click in Explorer or Finder.
Additionally, this archive contains rebuild_db-win32.src.zip, which is an
archive of the C source code for the Win32 version (compile with lcc-win32).
For any other details regarding the software, please visit the webpage at
http://shuffle-db.sourceforge.net
Have fun with the program (and with your iPod as well),
Martin J. Fiedler <martin.fiedler@gmx.net>

View file

@ -1,5 +1,4 @@
#!/usr/bin/env python
import functools
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
@ -14,17 +13,12 @@ import functools
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
from builtins import input
from builtins import map
from builtins import range
from functools import reduce
__title__ = "KeyJ's iPod shuffle Database Builder"
__version__ = "1.0"
__author__ = "Martin Fiedler"
__email__ = "martin.fiedler@gmx.net"
__version__ = "2.0-pre1"
""" VERSION HISTORY
2.0-pre1 (2025-06-20)
* updated to python 3
* removed --nochdir flag
1.0-rc1 (2006-04-26)
* finally(!) added the often-requested auto-rename feature (-r option)
0.7-pre1 (2005-06-09)
@ -67,7 +61,10 @@ __email__ = "martin.fiedler@gmx.net"
* initial public release, Win32 only
"""
import sys, os, os.path, array, getopt, random, fnmatch, operator, string
import functools, sys, os, os.path, array, random, fnmatch, operator, string
from builtins import map, input, range
from io import BytesIO
from typing import TextIO
# @formatter:off
KnownProps = ('filename', 'size', 'ignore', 'type', 'shuffle', 'reuse', 'bookmark')
@ -83,69 +80,43 @@ Rules = [
]
# @formatter:on
Options = {
"volume": None,
"interactive": False,
"smart": True,
"home": True,
"logging": True,
"reuse": 1,
"logfile": "rebuild_db.log.txt",
"rename": False
}
domains = []
total_count = 0
KnownEntries = {}
logfile: TextIO | None = None
args = None
iTunesSD_file: BytesIO | None = None
################################################################################
def open_log():
global logfile
if Options['logging']:
try:
logfile = open(Options['logfile'], "w")
except IOError:
logfile = None
else:
logfile = None
if not args.nolog:
try: logfile = open(args.logfile, "w")
except IOError:logfile = None
else: logfile = None
def log(line="", newline=True):
global logfile
if newline:
print(line)
line += "\n"
else:
print(line, end=' ')
line += " "
if logfile:
try:
logfile.write(line)
except IOError:
pass
if newline: line += "\n"
else: line += " "
print(line, end="")
if logfile: logfile.write(line)
def close_log():
global logfile
if logfile:
logfile.close()
def go_home():
if Options['home']:
try:
os.chdir(os.path.split(sys.argv[0])[0])
except OSError:
pass
if logfile: logfile.close()
def filesize(filename):
try:
return os.stat(filename)[6]
except OSError:
return None
try: return os.stat(filename)[6]
except OSError: return None
################################################################################
@ -156,40 +127,33 @@ def MatchRule(props, rule):
prop, op, ref = props[rule[0]], rule[1], rule[2]
except KeyError:
return False
if rule[1] == '~':
return fnmatch.fnmatchcase(prop.lower(), ref.lower())
elif rule[1] == '=':
return prop == ref
elif rule[1] == '>':
return prop > ref
elif rule[1] == '<':
return prop < ref
else:
return False
if rule[1] == '~': return fnmatch.fnmatchcase(prop.lower(), ref.lower())
elif rule[1] == '=': return prop == ref
elif rule[1] == '>': return prop > ref
elif rule[1] == '<': return prop < ref
else: return False
def ParseValue(val):
if len(val) >= 2 and ((val[0] == "'" and val[-1] == "'") or (val[0] == '"' and val[-1] == '"')):
return val[1:-1]
try:
return int(val)
except ValueError:
return val
try: return int(val)
except ValueError: return val
def ParseRule(rule):
sep_pos = min([rule.find(sep) for sep in "~=<>" if rule.find(sep) > 0])
prop = rule[:sep_pos].strip()
if not prop in KnownProps:
log("WARNING: unknown property `%s'" % prop)
return (prop, rule[sep_pos], ParseValue(rule[sep_pos + 1:].strip()))
log(f"WARNING: unknown property '{prop}'")
return prop, rule[sep_pos], ParseValue(rule[sep_pos + 1:].strip())
def ParseAction(action):
prop, value = list(map(str.strip, action.split('=', 1)))
if not prop in KnownProps:
log("WARNING: unknown property `%s'" % prop)
return (prop, ParseValue(value))
log(f"WARNING: unknown property '{prop}'")
return prop, ParseValue(value)
def ParseRuleLine(line):
@ -202,11 +166,11 @@ def ParseRuleLine(line):
ruleset = list(map(str.strip, ":".join(tmp[:-1]).split(",")))
actions = dict(list(map(ParseAction, tmp[-1].split(","))))
if len(ruleset) == 1 and not (ruleset[0]):
return ([], actions)
return [], actions
else:
return (list(map(ParseRule, ruleset)), actions)
return list(map(ParseRule, ruleset)), actions
except OSError: # (ValueError,IndexError,KeyError):
log("WARNING: rule `%s' is malformed, ignoring" % line)
log(f"WARNING: rule '{line}' is malformed, ignoring")
return None
@ -214,9 +178,8 @@ def ParseRuleLine(line):
def safe_char(c):
if c in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_":
return c
return "_"
if c in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_": return c
else: return "_"
def rename_safely(path, name):
@ -224,21 +187,21 @@ def rename_safely(path, name):
newname = ''.join(map(safe_char, base))
if name == newname + ext:
return name
if os.path.exists("%s/%s%s" % (path, newname, ext)):
if os.path.exists(f"{path}/{newname}{ext}"):
i = 0
while os.path.exists("%s/%s_%d%s" % (path, newname, i, ext)):
while os.path.exists(f"{path}/{newname}_{i}{ext}"):
i += 1
newname += "_%d" % i
newname += f"_{i}"
newname += ext
try:
os.rename("%s/%s" % (path, name), "%s/%s" % (path, newname))
os.rename(f"{path}/{name}", f"{path}/{newname}")
except OSError:
pass # don't fail if the rename didn't work
return newname
def write_to_db(filename):
global iTunesSD, domains, total_count, KnownEntries, Rules
global iTunesSD_file, domains, total_count, KnownEntries, Rules
# set default properties
props = {
@ -247,13 +210,13 @@ def write_to_db(filename):
'ignore': 0,
'type': 1,
'shuffle': 1,
'reuse': Options['reuse'],
'reuse': not args.force,
'bookmark': 0
}
# check and apply rules
for ruleset, action in Rules:
if reduce(operator.__and__, [MatchRule(props, rule) for rule in ruleset], True):
if functools.reduce(operator.__and__, [MatchRule(props, rule) for rule in ruleset], True):
props.update(action)
if props['ignore']: return 0
@ -266,7 +229,7 @@ def write_to_db(filename):
"\0" * (525 - 2 * len(filename))).encode()
# write entry, modifying shuffleflag and bookmarkflag at least
iTunesSD.write(entry[:555] + bytes([props['shuffle']]) + bytes([props['bookmark']]) + bytes([entry[557]]))
iTunesSD_file.write(entry[:555] + bytes([props['shuffle']]) + bytes([props['bookmark']]) + bytes([entry[557]]))
if props['shuffle']: domains[-1].append(total_count)
total_count += 1
return 1
@ -281,21 +244,18 @@ def make_key(s):
for j in range(i, len(s)):
if not s[j].isdigit(): break
if s[j].isdigit(): j += 1
return (s[:i], int(s[i:j]), make_key(s[j:]))
return s[:i], int(s[i:j]), make_key(s[j:])
def key_repr(x):
if type(x) == tuple:
return b"%s%d%s" % (x[0], x[1], key_repr(x[2]))
else:
return x
if type(x) == tuple: return b"%s%d%s" % (x[0], x[1], key_repr(x[2]))
else: return x
def cmp_key(a, b):
if type(a) == tuple and type(b) == tuple:
return cmp(a[0], b[0]) or cmp(a[1], b[1]) or cmp_key(a[2], b[2])
else:
return cmp(key_repr(a), key_repr(b))
else: return cmp(key_repr(a), key_repr(b))
def cmp(a, b):
if a < b: return -1
@ -305,8 +265,8 @@ def cmp(a, b):
def file_entry(path, name, prefix=""):
if not name or name[0] == ".": return None
fullname = "%s/%s" % (path, name)
may_rename = not (fullname.startswith("./iPod_Control")) and Options['rename']
fullname = f"{path}/{name}"
may_rename = not (fullname.startswith("./iPod_Control")) and args.rename
try:
if os.path.islink(fullname):
return None
@ -316,48 +276,41 @@ def file_entry(path, name, prefix=""):
if os.path.splitext(name)[1].lower() in (".mp3", ".m4a", ".m4b", ".m4p", ".aa", ".wav"):
if may_rename: name = rename_safely(path, name)
return 1, make_key(name), prefix + name
except OSError:
pass
except OSError: pass
return None
def browse(path, interactive):
def browse(path: string, interactive: bool):
global domains
if path[-1] == "/": path = path[:-1]
displaypath = path[1:]
if not displaypath: displaypath = "/"
if not displaypath: displaypath = ""
if interactive:
while 1:
try:
choice = input("include `%s'? [(Y)es, (N)o, (A)ll] " % displaypath)[:1].lower()
except EOFError:
raise KeyboardInterrupt
if not choice: continue
if choice in "at": # all/alle/tous/<dontknow>
interactive = 0
break
if choice in "yjos": # yes/ja/oui/si
break
if choice in "n": # no/nein/non/non?
return 0
while interactive:
choice = input(f"include '{displaypath}'? [(Y)es, (N)o, (A)ll] ")[:1].lower()
if not choice: continue
try:
files = [_f for _f in [file_entry(path, name) for name in os.listdir(path)] if _f]
except OSError:
return
# all/alle/tous/<dontknow>
if choice in "at": interactive = 0
# yes/ja/oui/si
if choice in "yjos": break
# no/nein/non/non?
if choice in "n": return
try: files = [_f for _f in [file_entry(path, name) for name in os.listdir(path)] if _f]
except OSError: return
if path == "./iPod_Control/Music":
subdirs = [x[2] for x in files if not x[0]]
files = [x for x in files if x[0]]
for dir in subdirs:
subpath = "%s/%s" % (path, dir)
subpath = f"{path}/{dir}"
try:
files.extend(
[x for x in [file_entry(subpath, name, dir + "/") for name in os.listdir(subpath)] if x and x[0]])
except OSError:
pass
files.extend([x for x in [
file_entry(subpath, name, dir + "/") for name in os.listdir(subpath)
] if x and x[0]])
except OSError: pass
files.sort(key = functools.cmp_to_key(cmp_key))
count = len([None for x in files if x[0]])
@ -365,53 +318,49 @@ def browse(path, interactive):
real_count = 0
for item in files:
fullname = "%s/%s" % (path, item[2])
fullname = f"{path}/{item[2]}"
if item[0]:
real_count += write_to_db(fullname[1:])
else:
browse(fullname, interactive)
if real_count == count:
log("%s: %d files" % (displaypath, count))
else:
log("%s: %d files (out of %d)" % (displaypath, real_count, count))
log(f"{displaypath}: {real_count} files (out of {count})")
################################################################################
def stringval(i):
def stringval(i: int) -> bytes:
if i < 0: i += 0x1000000
return b"%c%c%c" % (i & 0xFF, (i >> 8) & 0xFF, (i >> 16) & 0xFF)
def listval(i):
def listval(i: int) -> list[int]:
if i < 0: i += 0x1000000
return [i & 0xFF, (i >> 8) & 0xFF, (i >> 16) & 0xFF]
def make_playback_state(volume=None):
def write_playback_state():
# I'm not at all proud of this function. Why can't stupid Python make strings
# mutable?!
log("Setting playback state ...", False)
PState = []
p_state = []
try:
f = open("iPod_Control/iTunes/iTunesPState", "rb")
a = array.array('B')
a.frombytes(f.read())
PState = a.tolist()
p_state = a.tolist()
f.close()
except IOError:
del PState[:]
del p_state[:]
#if len(PState) != 21:
# print("catstare")
# PState = listval(29) + [0] * 15 + listval(1) # volume 29, FW ver 1.0
PState[3:15] = [0] * 6 + [1] + [0] * 5 # track 0, shuffle mode, start of track
if volume is not None:
PState[:3] = listval(volume)
p_state[3:15] = [0] * 6 + [1] + [0] * 5 # track 0, shuffle mode, start of track
if args.volume is not None:
p_state[:3] = listval(args.volume)
try:
f = open("iPod_Control/iTunes/iTunesPState", "wb")
array.array('B', PState).tofile(f)
array.array('B', p_state).tofile(f)
f.close()
except IOError:
log("FAILED.")
@ -420,7 +369,7 @@ def make_playback_state(volume=None):
return 1
def make_stats(count):
def write_stats(count):
log("Creating statistics file ...", False)
try:
file = open("iPod_Control/iTunes/iTunesStats", "wb")
@ -437,10 +386,9 @@ def make_stats(count):
def smart_shuffle():
try:
slice_count = max(list(map(len, domains)))
except ValueError:
return []
try: slice_count = max(list(map(len, domains)))
except ValueError: return []
slices = [[] for x in range(slice_count)]
slice_fill = [0] * slice_count
@ -451,7 +399,8 @@ def smart_shuffle():
# find slices where the nearest track of the same domain is far away
metric = [
min([slice_count] + [min(abs(s - u), abs(s - u + slice_count), abs(s - u - slice_count)) for u in used])
for s in range(slice_count)]
for s in range(slice_count)
]
thresh = (max(metric) + 1) // 2
farthest = [s for s in range(slice_count) if metric[s] >= thresh]
@ -461,7 +410,7 @@ def smart_shuffle():
# choose one of the remaining candidates and add the track to the chosen slice
s = random.choice(emptiest or farthest)
slices[s].append((n, d))
slices[s].append([n, d])
slice_fill[s] += 1
used.append(s)
@ -477,15 +426,15 @@ def smart_shuffle():
return seq
def make_shuffle(count):
def write_shuffle(count):
random.seed()
if Options['smart']:
log("Generating smart shuffle sequence ...", False)
seq = smart_shuffle()
else:
if args.nosmart:
log("Generating shuffle sequence ...", False)
seq = list(range(count))
random.shuffle(seq)
else:
log("Generating smart shuffle sequence ...", False)
seq = smart_shuffle()
try:
with open("iPod_Control/iTunes/iTunesShuffle", "wb") as file:
file.write(b"".join(map(stringval, seq)))
@ -500,8 +449,8 @@ def make_shuffle(count):
def main(dirs):
global header, iTunesSD, total_count, KnownEntries, Rules
log("Welcome to %s, version %s" % (__title__, __version__))
global header, iTunesSD_file, total_count, KnownEntries, Rules
log(f"Welcome to ShuffleDB, version {__version__}")
log()
try:
@ -519,29 +468,26 @@ Please make sure that:
sys.exit(1)
header = array.array('B')
iTunesSD = None
try:
iTunesSD = open("iPod_Control/iTunes/iTunesSD", "rb")
header.fromfile(iTunesSD, 51)
if Options['reuse']:
iTunesSD.seek(18)
entry = iTunesSD.read(558)
iTunesSD_file = open("iPod_Control/iTunes/iTunesSD", "rb")
header.fromfile(iTunesSD_file, 51)
if not args.force:
iTunesSD_file.seek(18)
entry = iTunesSD_file.read(558)
while len(entry) == 558:
filename = entry[33::2].split(b"\0", 1)[0]
KnownEntries[filename] = entry
entry = iTunesSD.read(558)
entry = iTunesSD_file.read(558)
except (IOError, EOFError):
pass
if iTunesSD: iTunesSD.close()
if iTunesSD_file: iTunesSD_file.close()
if len(header) == 51:
log("Using iTunesSD headers from existing database.")
if KnownEntries:
log("Collected %d entries from existing database." % len(KnownEntries))
if KnownEntries: log(f"Collected {len(KnownEntries)} entries from existing database.")
else:
del header[18:]
if len(header) == 18:
log("Using iTunesSD main header from existing database.")
if len(header) == 18: log("Using iTunesSD main header from existing database.")
else:
del header[:]
log("Rebuilding iTunesSD main header from scratch.")
@ -551,8 +497,8 @@ Please make sure that:
log()
try:
iTunesSD = open("iPod_Control/iTunes/iTunesSD", "wb")
header[:18].tofile(iTunesSD)
iTunesSD_file = open("iPod_Control/iTunes/iTunesSD", "wb")
header[:18].tofile(iTunesSD_file)
except IOError:
log("""ERROR: Cannot write to the iPod database file (iTunesSD)!
Please make sure that:
@ -563,25 +509,22 @@ Please make sure that:
log("Searching for files on your iPod.")
try:
if dirs:
for dir in dirs:
browse("./" + dir, Options['interactive'])
else:
browse(".", Options['interactive'])
log("%d playable files were found on your iPod." % total_count)
for dir in dirs:
browse(dir, args.interactive)
log(f"{total_count} playable files were found on your iPod.")
log()
log("Fixing iTunesSD header.")
iTunesSD.seek(0)
iTunesSD.write(b"\0%c%c" % (total_count >> 8, total_count & 0xFF))
iTunesSD.close()
iTunesSD_file.seek(0)
iTunesSD_file.write(b"\0%c%c" % (total_count >> 8, total_count & 0xFF))
iTunesSD_file.close()
except IOError:
log("ERROR: Some strange errors occured while writing iTunesSD.")
log(" You may have to re-initialize the iPod using iTunes.")
sys.exit(1)
if make_playback_state(Options['volume']) * \
make_stats(total_count) * \
make_shuffle(total_count):
if write_playback_state() * \
write_stats(total_count) * \
write_shuffle(total_count):
log()
log("The iPod shuffle database was rebuilt successfully.")
log("Have fun listening to your music!")
@ -593,75 +536,26 @@ Please make sure that:
################################################################################
def help():
print("Usage: %s [OPTION]... [DIRECTORY]..." % sys.argv[0])
print("""Rebuild iPod shuffle database.
Mandatory arguments to long options are mandatory for short options too.
-h, --help display this help text
-i, --interactive prompt before browsing each directory
-v, --volume=VOL set playback volume to a value between 0 and 38
-s, --nosmart do not use smart shuffle
-n, --nochdir do not change directory to this scripts directory first
-l, --nolog do not create a log file
-f, --force always rebuild database entries, do not re-use old ones
-L, --logfile set log file name
Must be called from the iPod's root directory. By default, the whole iPod is
searched for playable files, unless at least one DIRECTORY is specified.""")
def opterr(msg):
print("parse error:", msg)
print("use `%s -h' to get help" % sys.argv[0])
sys.exit(1)
def parse_options():
try:
opts, args = getopt.getopt(sys.argv[1:], "hiv:snlfL:r", \
["help", "interactive", "volume=", "nosmart", "nochdir", "nolog", "force",
"logfile=", "rename"])
except getopt.GetoptError as message:
opterr(message)
sys.exit(1)
for opt, arg in opts:
if opt in ("-h", "--help"):
help()
sys.exit(0)
elif opt in ("-i", "--interactive"):
Options['interactive'] = True
elif opt in ("-v", "--volume"):
try:
Options['volume'] = int(arg)
except ValueError:
opterr("invalid volume")
elif opt in ("-s", "--nosmart"):
Options['smart'] = False
elif opt in ("-n", "--nochdir"):
Options['home'] = False
elif opt in ("-l", "--nolog"):
Options['logging'] = False
elif opt in ("-f", "--force"):
Options['reuse'] = 0
elif opt in ("-L", "--logfile"):
Options['logfile'] = arg
elif opt in ("-r", "--rename"):
Options['rename'] = True
return args
################################################################################
if __name__ == "__main__":
args = parse_options()
go_home()
import argparse
parser = argparse.ArgumentParser(
description="Rebuild iPod shuffle database. Updated fork of Martin Fiedler's rebuild_db (https://shuffle-db.sourceforge.net/)",
)
parser.add_argument('directories', nargs='*', default=["."])
parser.add_argument("-i", "--interactive", action="store_true", help = "prompt before browsing each directory")
parser.add_argument("-s", "--nosmart", action="store_true", help = "do not use smart shuffle")
parser.add_argument("-l", "--nolog", action="store_true", help = "do not create a log file")
parser.add_argument("-f", "--force", action="store_true", help = "always rebuild database entries, do not re-use old ones")
parser.add_argument("-L", "--logfile", action="store", default="rebuild_db.log.txt", help = "set log file name")
parser.add_argument("-r", "--rename", action="store_true", help = "automatically rename files to safe names")
parser.add_argument("-v", "--volume", type=int, action="store", help = "set playback volume to a value between 0 and 38")
args = parser.parse_args()
open_log()
try:
main(args)
main(args.directories)
except KeyboardInterrupt:
log()
log("You decided to cancel processing. This is OK, but please note that")