commit 9967a0416183126c698784f581c946895ba9de7c Author: everbarry Date: Wed Apr 8 13:56:30 2026 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/License.txt b/License.txt new file mode 100644 index 0000000..5b6e7c6 --- /dev/null +++ b/License.txt @@ -0,0 +1,340 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc. + 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Library General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + 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 + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Library General +Public License instead of this License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d887409 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# ipodutils + +Rebuild the on-disk database for **1st- and 2nd-generation Apple iPod shuffle** players (`iTunesSD`, shuffle order, playback state, and related files) so you can manage music by copying files—without iTunes—for each sync. + +Python 3 port of **KeyJ’s rebuild_db** ([Martin J. Fiedler](mailto:martin.fiedler@gmx.net)). + +## Supported hardware + +| Supported | Not supported | +|-----------|----------------| +| iPod shuffle (1st gen), iPod shuffle (2nd gen) | 3rd-gen shuffle and later (VoiceOver, different DB format) | + +The volume must already contain `iPod_Control/` (typically after one-time setup with iTunes or an equivalent). + +## Requirements + +- **Python** ≥ 3.14 +- Write access to the iPod’s mounted volume + +## Install + +From a checkout: + +```bash +uv sync +``` + +From [PyPI](https://pypi.org/) (after you publish): + +```bash +pip install ipodutils +``` + +The console entry point is **`rebuild-db`** (implements `main:cli`). + +## Usage + +Run with the working directory set to the **iPod root**, or pass **`--ipod-path`**: + +```bash +# From repo / env that has the package installed +uv run rebuild-db --ipod-path /run/media/$USER/IPOD + +# Or: cd to the mount, then +rebuild-db +``` + +Full options: + +```bash +rebuild-db --help +``` + +### Common options + +| Option | Meaning | +|--------|---------| +| `--ipod-path DIR` | Use `DIR` as the iPod root (mount point) | +| `-s` / `--nosmart` | Plain random shuffle instead of “smart shuffle” | +| `-i` / `--interactive` | Confirm each directory while scanning | +| `-v N` | Playback volume (0–38) | +| `-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` | + +Optional rules file: **`rebuild_db.rules`** in the iPod root (see `--help` and original rebuild_db documentation for the rule syntax). + +## Logging + +By default, output is also written to **`rebuild_db.log.txt`** on the iPod root. Use **`--nolog`** to disable. + +## License + +GPL v2 — see [`License.txt`](License.txt). + +## See also + +- Original project lineage: *KeyJ’s iPod shuffle Database Builder* / `rebuild_db` diff --git a/main.py b/main.py new file mode 100644 index 0000000..e158997 --- /dev/null +++ b/main.py @@ -0,0 +1,569 @@ +#!/usr/bin/env python3 +# -*- coding: iso-8859-1 -*- +# GPL v2 — see License.txt. Derived from KeyJ's rebuild_db (Martin Fiedler). +import array +import fnmatch +import getopt +import os +import random +import sys +from contextlib import contextmanager +from functools import cmp_to_key + +__title__ = "KeyJ's iPod shuffle Database Builder" +__version__ = "1.0-rc1" +KNOWN_PROPS = frozenset( + ("filename", "size", "ignore", "type", "shuffle", "reuse", "bookmark") +) +DEFAULT_RULES = [ + ([("filename", "~", "*.mp3")], {"type": 1, "shuffle": 1, "bookmark": 0}), + ([("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}), + ([("filename", "~", "*.book.???")], {"shuffle": 0, "bookmark": 1}), + ([("filename", "~", "*.announce.???")], {"shuffle": 0, "bookmark": 0}), + ([("filename", "~", "/recycled/*")], {"ignore": 1}), +] +AUDIO_EXT = frozenset((".mp3", ".m4a", ".m4b", ".m4p", ".aa", ".wav")) +_SAFE = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") +GETOPT_LONG = ( + "help", + "interactive", + "volume=", + "nosmart", + "nochdir", + "nolog", + "force", + "logfile=", + "rename", + "ipod-path=", +) +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." +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 :)" + + +def _u24(i: int) -> bytes: + if i < 0: + i += 0x1000000 + return bytes((i & 0xFF, (i >> 8) & 0xFF, (i >> 16) & 0xFF)) + + +def _circ(s, u, sc): + return min(abs(s - u), abs(s - u + sc), abs(s - u - sc)) + + +class ShuffleRebuild: + def __init__(self) -> None: + self.opts = { + "volume": None, + "interactive": False, + "smart": True, + "home": True, + "logging": True, + "reuse": 1, + "logfile": "rebuild_db.log.txt", + "rename": False, + "ipod_path": None, + } + self.rules = list(DEFAULT_RULES) + self.domains, self.total_count, self.known_entries = [], 0, {} + self._log_fp = self._sd = None + self._entry_template = array.array("B") + + def log(self, *a, end="\n") -> None: + print(*a, end=end) + if self._log_fp: + try: + print(*a, end=end, file=self._log_fp) + except OSError: + pass + + @contextmanager + def log_to_file(self): + self._log_fp = None + if self.opts["logging"]: + try: + self._log_fp = open(self.opts["logfile"], "w", encoding="utf-8") + except OSError: + pass + try: + yield + finally: + if self._log_fp: + self._log_fp.close() + self._log_fp = None + + def chdir_for_run(self, argv0: str) -> None: + ip = self.opts["ipod_path"] + if ip is not None: + p = os.path.abspath(os.path.expanduser(ip)) + if not os.path.isdir(p): + print("error: --ipod-path is not a directory: %s" % p, file=sys.stderr) + sys.exit(1) + try: + os.chdir(p) + except OSError as e: + print( + "error: cannot change to directory %s: %s" % (p, e), file=sys.stderr + ) + sys.exit(1) + elif self.opts["home"]: + try: + os.chdir(os.path.split(argv0)[0]) + except OSError: + pass + + def _load_user_rules_file(self) -> None: + try: + lines = ( + open("rebuild_db.rules", "r", encoding="iso-8859-1").read().split("\n") + ) + except OSError: + return + for line in lines: + p = self._parse_rule_line(line) + if p is not None: + self.rules.append(p) + + def _parse_rule_line(self, line: str): + line = line.strip() + if not line or line[0] == "#": + return None + try: + tmp = line.split(":") + ruleset = [x.strip() for x in ":".join(tmp[:-1]).split(",")] + actions = dict(map(self._parse_action, tmp[-1].split(","))) + if len(ruleset) == 1 and not ruleset[0]: + return ([], actions) + return (list(map(self._parse_rule, ruleset)), actions) + except (ValueError, TypeError, KeyError): + self.log("WARNING: rule `%s' is malformed, ignoring" % line) + return None + + def _parse_rule(self, rule: str): + positions = [rule.find(sep) for sep in "~=<>"] + candidates = [p for p in positions if p > 0] + if not candidates: + raise ValueError("no operator in rule") + sep_pos = min(candidates) + sep = rule[sep_pos] + prop = rule[:sep_pos].strip() + if prop not in KNOWN_PROPS: + self.log("WARNING: unknown property `%s'" % prop) + return (prop, sep, self._parse_value(rule[sep_pos + 1 :].strip())) + + def _parse_action(self, action: str): + prop, value = [x.strip() for x in action.split("=", 1)] + if prop not in KNOWN_PROPS: + self.log("WARNING: unknown property `%s'" % prop) + return (prop, self._parse_value(value)) + + @staticmethod + def _parse_value(val: str): + 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 + + @staticmethod + def _match_rule(props: dict, rule) -> bool: + try: + prop, op, ref = props[rule[0]], rule[1], rule[2] + except KeyError: + return False + if op == "~": + return fnmatch.fnmatchcase(prop.lower(), ref.lower()) + c = (prop > ref) - (prop < ref) + return (op == "=" and c == 0) or (op == ">" and c > 0) or (op == "<" and c < 0) + + @staticmethod + def _filesize(path: str): + try: + return os.stat(path).st_size + except OSError: + return None + + def _rename_safely(self, path: str, name: str) -> str: + base, ext = os.path.splitext(name) + newname = "".join(c if c in _SAFE else "_" for c in base) + if name == newname + ext: + return name + if os.path.exists("%s/%s%s" % (path, newname, ext)): + i = 0 + while os.path.exists("%s/%s_%d%s" % (path, newname, i, ext)): + i += 1 + newname += "_%d" % i + newname += ext + try: + os.rename("%s/%s" % (path, name), "%s/%s" % (path, newname)) + except OSError: + pass + return newname + + @staticmethod + def _make_sort_key(s: str): + if not s: + return s + s = s.lower() + for i in range(len(s)): + if s[i].isdigit(): + break + if not s[i].isdigit(): + return 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]), ShuffleRebuild._make_sort_key(s[j:])) + + @staticmethod + def _key_repr(x): + if isinstance(x, tuple): + return "%s%d%s" % (x[0], x[1], ShuffleRebuild._key_repr(x[2])) + return x + + @classmethod + def _cmp_sort_key(cls, a, b) -> int: + if isinstance(a, tuple) and isinstance(b, tuple): + for xa, xb in (a[0], b[0]), (a[1], b[1]): + c = (xa > xb) - (xa < xb) + if c: + return c + return cls._cmp_sort_key(a[2], b[2]) + ka, kb = cls._key_repr(a), cls._key_repr(b) + return (ka > kb) - (ka < kb) + + def _file_entry(self, path: str, name: str, prefix: str = ""): + if not name or name[0] == ".": + return None + fullname = "%s/%s" % (path, name) + may_rename = not fullname.startswith("./iPod_Control") and self.opts["rename"] + try: + if os.path.islink(fullname): + return None + if os.path.isdir(fullname): + if may_rename: + name = self._rename_safely(path, name) + return (0, self._make_sort_key(name), prefix + name) + if os.path.splitext(name)[1].lower() in AUDIO_EXT: + if may_rename: + name = self._rename_safely(path, name) + return (1, self._make_sort_key(name), prefix + name) + except OSError: + pass + return None + + def _browse(self, path: str, interactive: bool) -> None: + if path.endswith("/"): + path = path[:-1] + displaypath = path[1:] or "/" + if interactive: + while True: + 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": + interactive = False + break + if choice in "yjos": + break + if choice in "n": + return + try: + files = [ + x for x in (self._file_entry(path, n) for n in os.listdir(path)) if x + ] + 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 sub in subdirs: + sp = "%s/%s" % (path, sub) + try: + files.extend( + x + for x in ( + self._file_entry(sp, n, sub + "/") for n in os.listdir(sp) + ) + if x and x[0] + ) + except OSError: + pass + files.sort(key=cmp_to_key(self._cmp_sort_key)) + nfiles = sum(1 for x in files if x[0]) + if nfiles: + self.domains.append([]) + real = 0 + for it in files: + fp = "%s/%s" % (path, it[2]) + if it[0]: + real += self._write_track(fp[1:]) + else: + self._browse(fp, interactive) + self.log( + "%s: %d files%s" + % (displaypath, real, "" if real == nfiles else " (out of %d)" % nfiles) + ) + + def _write_track(self, filename: str) -> int: + props = { + "filename": filename, + "size": self._filesize(filename[1:]), + "ignore": 0, + "type": 1, + "shuffle": 1, + "reuse": self.opts["reuse"], + "bookmark": 0, + } + for ruleset, action in self.rules: + if all(self._match_rule(props, r) for r in ruleset): + props.update(action) + if props["ignore"]: + return 0 + entry = props["reuse"] and self.known_entries.get(filename) + if not entry: + self._entry_template[29] = props["type"] + fn = filename[:261].encode("latin-1", "replace") + part = b"".join(bytes((b, 0)) for b in fn) + pad = b"\0" * (525 - 2 * len(fn)) + entry = self._entry_template.tobytes() + part + pad + self._sd.write( + entry[:555] + bytes((props["shuffle"], props["bookmark"])) + entry[557:] + ) + if props["shuffle"]: + self.domains[-1].append(self.total_count) + self.total_count += 1 + return 1 + + def _smart_shuffle(self) -> list: + try: + sc = max(map(len, self.domains)) + except ValueError: + return [] + slices = [[] for _ in range(sc)] + fill = [0] * sc + for d in range(len(self.domains)): + if not self.domains[d]: + continue + used = [] + for n in self.domains[d]: + metric = [ + min([sc] + [_circ(s, u, sc) for u in used]) for s in range(sc) + ] + th = (max(metric) + 1) // 2 + 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) + slices[s].append((n, d)) + fill[s] += 1 + used.append(s) + seq, last = [], -1 + for sl in slices: + random.shuffle(sl) + if len(sl) > 2 and sl[0][1] == last: + sl.append(sl.pop(0)) + seq += [x[0] for x in sl] + last = sl[-1][1] + return seq + + def _write_sidecars(self) -> bool: + v, n = self.opts["volume"], self.total_count + self.log("Setting playback state ...", end=" ") + ps = [] + try: + with open("iPod_Control/iTunes/iTunesPState", "rb") as f: + a = array.array("B") + a.frombytes(f.read()) + ps = a.tolist() + except OSError, EOFError: + pass + if len(ps) != 21: + ps = list(_u24(29)) + [0] * 15 + list(_u24(1)) + ps[3:15] = [0] * 6 + [1] + [0] * 5 + if v is not None: + ps[:3] = list(_u24(v)) + ok = True + try: + with open("iPod_Control/iTunes/iTunesPState", "wb") as f: + array.array("B", ps).tofile(f) + self.log("OK.") + except OSError: + self.log("FAILED.") + ok = False + self.log("Creating statistics file ...", end=" ") + try: + with open("iPod_Control/iTunes/iTunesStats", "wb") as f: + f.write(_u24(n) + b"\0" * 3 + (_u24(18) + b"\xff" * 3 + b"\0" * 12) * n) + self.log("OK.") + except OSError: + self.log("FAILED.") + ok = False + random.seed() + if self.opts["smart"]: + self.log("Generating smart shuffle sequence ...", end=" ") + seq = self._smart_shuffle() + else: + self.log("Generating shuffle sequence ...", end=" ") + seq = list(range(n)) + random.shuffle(seq) + try: + with open("iPod_Control/iTunes/iTunesShuffle", "wb") as f: + f.write(b"".join(_u24(x) for x in seq)) + self.log("OK.") + except OSError: + self.log("FAILED.") + ok = False + return ok + + def run(self, dirs: list) -> None: + self.log("Welcome to %s, version %s" % (__title__, __version__)) + self.log() + self._load_user_rules_file() + if not os.path.isdir("iPod_Control/iTunes"): + self.log(ERR_NO_CTRL) + sys.exit(1) + self._entry_template = array.array("B") + sd_read = None + try: + sd_read = open("iPod_Control/iTunes/iTunesSD", "rb") + self._entry_template.fromfile(sd_read, 51) + if self.opts["reuse"]: + sd_read.seek(18) + ent = sd_read.read(558) + while len(ent) == 558: + self.known_entries[ + ent[33::2].split(b"\0", 1)[0].decode("latin-1", "replace") + ] = ent + ent = sd_read.read(558) + except OSError, EOFError: + pass + if sd_read: + sd_read.close() + et = self._entry_template + if len(et) == 51: + self.log("Using iTunesSD headers from existing database.") + if self.known_entries: + self.log( + "Collected %d entries from existing database." + % len(self.known_entries) + ) + else: + del et[18:] + if len(et) == 18: + self.log("Using iTunesSD main header from existing database.") + else: + del et[:] + self.log("Rebuilding iTunesSD main header from scratch.") + et.fromlist([0, 0, 0, 1, 6, 0, 0, 0, 18] + [0] * 9) + self.log("Rebuilding iTunesSD entry header from scratch.") + et.fromlist([0, 2, 46, 90, 165, 1] + [0] * 20 + [100, 0, 0, 1, 0, 2, 0]) + self.log() + try: + self._sd = open("iPod_Control/iTunes/iTunesSD", "wb") + et[:18].tofile(self._sd) + except OSError: + self.log(ERR_NO_SD) + sys.exit(1) + del et[:18] + self.log("Searching for files on your iPod.") + try: + if dirs: + for d in dirs: + self._browse("./" + d, self.opts["interactive"]) + else: + self._browse(".", self.opts["interactive"]) + self.log("%d playable files were found on your iPod." % self.total_count) + self.log() + self.log("Fixing iTunesSD header.") + self._sd.seek(0) + self._sd.write( + bytes((0, (self.total_count >> 8) & 0xFF, self.total_count & 0xFF)) + ) + self._sd.close() + self._sd = None + except OSError: + self.log("ERROR: Some strange errors occured while writing iTunesSD.") + self.log(" You may have to re-initialize the iPod using iTunes.") + sys.exit(1) + ok = self._write_sidecars() + if ok: + self.log() + self.log("The iPod shuffle database was rebuilt successfully.") + self.log("Have fun listening to your music!") + else: + self.log() + self.log( + "WARNING: The main database file was rebuilt successfully, but there were errors\n" + " while resetting the other files. However, playback MAY work correctly." + ) + + +def _opterr(msg) -> None: + print("parse error:", msg) + print("use `%s -h' to get help" % sys.argv[0]) + sys.exit(1) + + +def parse_argv(argv: list, s: ShuffleRebuild) -> list: + try: + opts, args = getopt.getopt(argv[1:], "hiv:snlfL:rp:", GETOPT_LONG) + except getopt.GetoptError as e: + _opterr(e) + o = s.opts + for opt, arg in opts: + if opt in ("-h", "--help"): + print("Usage: %s [OPTION]... [DIRECTORY]...\n%s" % (argv[0], HELP)) + sys.exit(0) + if opt in ("-i", "--interactive"): + o["interactive"] = True + elif opt in ("-v", "--volume"): + try: + o["volume"] = int(arg) + except ValueError: + _opterr("invalid volume") + elif opt in ("-s", "--nosmart"): + o["smart"] = False + elif opt in ("-n", "--nochdir"): + o["home"] = False + elif opt in ("-p", "--ipod-path"): + o["ipod_path"] = arg + elif opt in ("-l", "--nolog"): + o["logging"] = False + elif opt in ("-f", "--force"): + o["reuse"] = 0 + elif opt in ("-L", "--logfile"): + o["logfile"] = arg + elif opt in ("-r", "--rename"): + o["rename"] = True + return args + + +def cli() -> None: + s = ShuffleRebuild() + dirs = parse_argv(sys.argv, s) + s.chdir_for_run(sys.argv[0]) + with s.log_to_file(): + try: + s.run(dirs) + except KeyboardInterrupt: + s.log() + s.log("You decided to cancel processing. This is OK, but please note that") + s.log("the iPod database is now corrupt and the iPod won't play!") + + +if __name__ == "__main__": + cli() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a9d82c3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,54 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "ipodutils" +version = "0.1.0" +description = "Rebuild iTunesSD and shuffle playlists for 1st/2nd generation iPod shuffle without iTunes" +readme = "README.md" +requires-python = ">=3.14" +license = "GPL-2.0-only" +license-files = ["License.txt"] +keywords = ["ipod", "shuffle", "itunes", "music", "mp3"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: End Users/Desktop", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.14", + "Topic :: Multimedia :: Sound/Audio", +] +dependencies = [] + +[project.scripts] +rebuild-db = "main:cli" + +[tool.setuptools] +py-modules = ["main"] + +[tool.uv] +package = true + +[[tool.uv.index]] +name = "barrys-pypi" +url = "https://barrys.cloud/pypi/simple/" +publish-url = "https://barrys.cloud/pypi/" +explicit = true + +[dependency-groups] +dev = ["ruff>=0.15.9"] + +[tool.ruff] +target-version = "py314" +line-length = 88 + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] +ignore = [ + "E501", # long HELP / error strings + "UP031", # percent formatting (legacy style) + "UP015", # open() mode +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..b05c2d1 --- /dev/null +++ b/uv.lock @@ -0,0 +1,43 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "ipodutils" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "ruff", specifier = ">=0.15.9" }] + +[[package]] +name = "ruff" +version = "0.15.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, +]