diff --git a/README.md b/README.md deleted file mode 100644 index 1a58f41..0000000 --- a/README.md +++ /dev/null @@ -1,3 +0,0 @@ -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. \ No newline at end of file diff --git a/Readme.txt b/Readme.txt new file mode 100644 index 0000000..008fff7 --- /dev/null +++ b/Readme.txt @@ -0,0 +1,22 @@ +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 diff --git a/rebuild_db.py b/rebuild_db.py index 34bd8cb..1ef9580 100755 --- a/rebuild_db.py +++ b/rebuild_db.py @@ -1,4 +1,5 @@ #!/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 @@ -13,12 +14,17 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -__version__ = "2.0-pre1" +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 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) @@ -61,10 +67,7 @@ __version__ = "2.0-pre1" * initial public release, Win32 only """ -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 +import sys, os, os.path, array, getopt, random, fnmatch, operator, string # @formatter:off KnownProps = ('filename', 'size', 'ignore', 'type', 'shuffle', 'reuse', 'bookmark') @@ -80,43 +83,69 @@ 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 not args.nolog: - try: logfile = open(args.logfile, "w") - except IOError:logfile = None - else: logfile = None + if Options['logging']: + try: + logfile = open(Options['logfile'], "w") + except IOError: + logfile = None + else: + logfile = None def log(line="", newline=True): global logfile - if newline: line += "\n" - else: line += " " - - print(line, end="") - if logfile: logfile.write(line) + if newline: + print(line) + line += "\n" + else: + print(line, end=' ') + line += " " + if logfile: + try: + logfile.write(line) + except IOError: + pass def close_log(): global logfile - if logfile: logfile.close() + if logfile: + logfile.close() + + +def go_home(): + if Options['home']: + try: + os.chdir(os.path.split(sys.argv[0])[0]) + except OSError: + pass + def filesize(filename): - try: return os.stat(filename)[6] - except OSError: return None + try: + return os.stat(filename)[6] + except OSError: + return None ################################################################################ @@ -127,33 +156,40 @@ 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(f"WARNING: unknown property '{prop}'") - return prop, rule[sep_pos], ParseValue(rule[sep_pos + 1:].strip()) + log("WARNING: unknown property `%s'" % 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(f"WARNING: unknown property '{prop}'") - return prop, ParseValue(value) + log("WARNING: unknown property `%s'" % prop) + return (prop, ParseValue(value)) def ParseRuleLine(line): @@ -166,11 +202,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(f"WARNING: rule '{line}' is malformed, ignoring") + log("WARNING: rule `%s' is malformed, ignoring" % line) return None @@ -178,8 +214,9 @@ def ParseRuleLine(line): def safe_char(c): - if c in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_": return c - else: return "_" + if c in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_": + return c + return "_" def rename_safely(path, name): @@ -187,21 +224,21 @@ def rename_safely(path, name): newname = ''.join(map(safe_char, base)) if name == newname + ext: return name - if os.path.exists(f"{path}/{newname}{ext}"): + if os.path.exists("%s/%s%s" % (path, newname, ext)): i = 0 - while os.path.exists(f"{path}/{newname}_{i}{ext}"): + while os.path.exists("%s/%s_%d%s" % (path, newname, i, ext)): i += 1 - newname += f"_{i}" + newname += "_%d" % i newname += ext try: - os.rename(f"{path}/{name}", f"{path}/{newname}") + os.rename("%s/%s" % (path, name), "%s/%s" % (path, newname)) except OSError: pass # don't fail if the rename didn't work return newname def write_to_db(filename): - global iTunesSD_file, domains, total_count, KnownEntries, Rules + global iTunesSD, domains, total_count, KnownEntries, Rules # set default properties props = { @@ -210,13 +247,13 @@ def write_to_db(filename): 'ignore': 0, 'type': 1, 'shuffle': 1, - 'reuse': not args.force, + 'reuse': Options['reuse'], 'bookmark': 0 } # check and apply rules for ruleset, action in Rules: - if functools.reduce(operator.__and__, [MatchRule(props, rule) for rule in ruleset], True): + if reduce(operator.__and__, [MatchRule(props, rule) for rule in ruleset], True): props.update(action) if props['ignore']: return 0 @@ -229,7 +266,7 @@ def write_to_db(filename): "\0" * (525 - 2 * len(filename))).encode() # write entry, modifying shuffleflag and bookmarkflag at least - iTunesSD_file.write(entry[:555] + bytes([props['shuffle']]) + bytes([props['bookmark']]) + bytes([entry[557]])) + iTunesSD.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 @@ -244,18 +281,21 @@ 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 @@ -265,8 +305,8 @@ def cmp(a, b): def file_entry(path, name, prefix=""): if not name or name[0] == ".": return None - fullname = f"{path}/{name}" - may_rename = not (fullname.startswith("./iPod_Control")) and args.rename + fullname = "%s/%s" % (path, name) + may_rename = not (fullname.startswith("./iPod_Control")) and Options['rename'] try: if os.path.islink(fullname): return None @@ -276,41 +316,48 @@ 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: string, interactive: bool): +def browse(path, interactive): global domains if path[-1] == "/": path = path[:-1] displaypath = path[1:] - if not displaypath: displaypath = "" + if not displaypath: displaypath = "/" - while interactive: - choice = input(f"include '{displaypath}'? [(Y)es, (N)o, (A)ll] ")[:1].lower() - if not choice: continue + 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/ + interactive = 0 + break + if choice in "yjos": # yes/ja/oui/si + break + if choice in "n": # no/nein/non/non? + return 0 - # all/alle/tous/ - 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 + 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 = f"{path}/{dir}" + subpath = "%s/%s" % (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]]) @@ -318,49 +365,53 @@ def browse(path: string, interactive: bool): real_count = 0 for item in files: - fullname = f"{path}/{item[2]}" + fullname = "%s/%s" % (path, item[2]) if item[0]: real_count += write_to_db(fullname[1:]) else: browse(fullname, interactive) - log(f"{displaypath}: {real_count} files (out of {count})") + if real_count == count: + log("%s: %d files" % (displaypath, count)) + else: + log("%s: %d files (out of %d)" % (displaypath, real_count, count)) ################################################################################ -def stringval(i: int) -> bytes: +def stringval(i): if i < 0: i += 0x1000000 return b"%c%c%c" % (i & 0xFF, (i >> 8) & 0xFF, (i >> 16) & 0xFF) -def listval(i: int) -> list[int]: +def listval(i): if i < 0: i += 0x1000000 return [i & 0xFF, (i >> 8) & 0xFF, (i >> 16) & 0xFF] -def write_playback_state(): +def make_playback_state(volume=None): # I'm not at all proud of this function. Why can't stupid Python make strings # mutable?! log("Setting playback state ...", False) - p_state = [] + PState = [] try: f = open("iPod_Control/iTunes/iTunesPState", "rb") a = array.array('B') a.frombytes(f.read()) - p_state = a.tolist() + PState = a.tolist() f.close() except IOError: - del p_state[:] + del PState[:] #if len(PState) != 21: + # print("catstare") # PState = listval(29) + [0] * 15 + listval(1) # volume 29, FW ver 1.0 - 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) + PState[3:15] = [0] * 6 + [1] + [0] * 5 # track 0, shuffle mode, start of track + if volume is not None: + PState[:3] = listval(volume) try: f = open("iPod_Control/iTunes/iTunesPState", "wb") - array.array('B', p_state).tofile(f) + array.array('B', PState).tofile(f) f.close() except IOError: log("FAILED.") @@ -369,7 +420,7 @@ def write_playback_state(): return 1 -def write_stats(count): +def make_stats(count): log("Creating statistics file ...", False) try: file = open("iPod_Control/iTunes/iTunesStats", "wb") @@ -386,9 +437,10 @@ def write_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 @@ -399,8 +451,7 @@ 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] @@ -410,7 +461,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) @@ -426,15 +477,15 @@ def smart_shuffle(): return seq -def write_shuffle(count): +def make_shuffle(count): random.seed() - if args.nosmart: + if Options['smart']: + log("Generating smart shuffle sequence ...", False) + seq = smart_shuffle() + else: 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))) @@ -449,8 +500,8 @@ def write_shuffle(count): def main(dirs): - global header, iTunesSD_file, total_count, KnownEntries, Rules - log(f"Welcome to ShuffleDB, version {__version__}") + global header, iTunesSD, total_count, KnownEntries, Rules + log("Welcome to %s, version %s" % (__title__, __version__)) log() try: @@ -468,26 +519,29 @@ Please make sure that: sys.exit(1) header = array.array('B') + iTunesSD = None try: - 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) + iTunesSD = open("iPod_Control/iTunes/iTunesSD", "rb") + header.fromfile(iTunesSD, 51) + if Options['reuse']: + iTunesSD.seek(18) + entry = iTunesSD.read(558) while len(entry) == 558: filename = entry[33::2].split(b"\0", 1)[0] KnownEntries[filename] = entry - entry = iTunesSD_file.read(558) + entry = iTunesSD.read(558) except (IOError, EOFError): pass - if iTunesSD_file: iTunesSD_file.close() + if iTunesSD: iTunesSD.close() if len(header) == 51: log("Using iTunesSD headers from existing database.") - if KnownEntries: log(f"Collected {len(KnownEntries)} entries from existing database.") + if KnownEntries: + log("Collected %d entries from existing database." % len(KnownEntries)) 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.") @@ -497,8 +551,8 @@ Please make sure that: log() try: - iTunesSD_file = open("iPod_Control/iTunes/iTunesSD", "wb") - header[:18].tofile(iTunesSD_file) + iTunesSD = open("iPod_Control/iTunes/iTunesSD", "wb") + header[:18].tofile(iTunesSD) except IOError: log("""ERROR: Cannot write to the iPod database file (iTunesSD)! Please make sure that: @@ -509,22 +563,25 @@ Please make sure that: log("Searching for files on your iPod.") try: - for dir in dirs: - browse(dir, args.interactive) - log(f"{total_count} playable files were found on your iPod.") + 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) log() log("Fixing iTunesSD header.") - iTunesSD_file.seek(0) - iTunesSD_file.write(b"\0%c%c" % (total_count >> 8, total_count & 0xFF)) - iTunesSD_file.close() + iTunesSD.seek(0) + iTunesSD.write(b"\0%c%c" % (total_count >> 8, total_count & 0xFF)) + iTunesSD.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 write_playback_state() * \ - write_stats(total_count) * \ - write_shuffle(total_count): + if make_playback_state(Options['volume']) * \ + make_stats(total_count) * \ + make_shuffle(total_count): log() log("The iPod shuffle database was rebuilt successfully.") log("Have fun listening to your music!") @@ -536,26 +593,75 @@ 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__": - 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() - + args = parse_options() + go_home() open_log() try: - main(args.directories) + main(args) except KeyboardInterrupt: log() log("You decided to cancel processing. This is OK, but please note that")