initial commit
This commit is contained in:
commit
9967a04161
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@ -0,0 +1 @@
|
||||
3.14
|
||||
340
License.txt
Normal file
340
License.txt
Normal file
@ -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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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.
|
||||
|
||||
<signature of Ty Coon>, 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.
|
||||
78
README.md
Normal file
78
README.md
Normal file
@ -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`
|
||||
569
main.py
Normal file
569
main.py
Normal file
@ -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()
|
||||
54
pyproject.toml
Normal file
54
pyproject.toml
Normal file
@ -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
|
||||
]
|
||||
43
uv.lock
generated
Normal file
43
uv.lock
generated
Normal file
@ -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" },
|
||||
]
|
||||
Loading…
x
Reference in New Issue
Block a user