pyrekordbox 0.4.3__py3-none-any.whl → 0.4.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyrekordbox/__main__.py +1 -51
- pyrekordbox/_version.py +16 -3
- pyrekordbox/anlz/file.py +18 -13
- pyrekordbox/config.py +0 -344
- pyrekordbox/db6/database.py +8 -22
- pyrekordbox/db6/tables.py +9 -6
- pyrekordbox/utils.py +20 -0
- {pyrekordbox-0.4.3.dist-info → pyrekordbox-0.4.4.dist-info}/METADATA +13 -38
- {pyrekordbox-0.4.3.dist-info → pyrekordbox-0.4.4.dist-info}/RECORD +12 -12
- {pyrekordbox-0.4.3.dist-info → pyrekordbox-0.4.4.dist-info}/WHEEL +1 -1
- {pyrekordbox-0.4.3.dist-info → pyrekordbox-0.4.4.dist-info}/licenses/LICENSE +1 -1
- {pyrekordbox-0.4.3.dist-info → pyrekordbox-0.4.4.dist-info}/top_level.txt +0 -0
pyrekordbox/__main__.py
CHANGED
@@ -3,30 +3,10 @@
|
|
3
3
|
# Date: 2023-08-15
|
4
4
|
|
5
5
|
import os
|
6
|
-
import re
|
7
6
|
import shutil
|
8
7
|
import sys
|
9
|
-
import urllib.request
|
10
8
|
from pathlib import Path
|
11
9
|
|
12
|
-
from pyrekordbox.config import get_cache_file, write_db6_key_cache
|
13
|
-
|
14
|
-
KEY_SOURCES = [
|
15
|
-
{
|
16
|
-
"url": r"https://raw.githubusercontent.com/mganss/CueGen/19878e6eb3f586dee0eb3eb4f2ce3ef18309de9d/CueGen/Generator.cs", # noqa: E501
|
17
|
-
"regex": re.compile(
|
18
|
-
r'((.|\n)*)Config\.UseSqlCipher.*\?.*"(?P<dp>.*)".*:.*null',
|
19
|
-
flags=re.IGNORECASE | re.MULTILINE,
|
20
|
-
),
|
21
|
-
},
|
22
|
-
{
|
23
|
-
"url": r"https://raw.githubusercontent.com/dvcrn/go-rekordbox/8be6191ba198ed7abd4ad6406d177ed7b4f749b5/cmd/getencryptionkey/main.go", # noqa: E501
|
24
|
-
"regex": re.compile(
|
25
|
-
r'((.|\n)*)fmt\.Print\("(?P<dp>.*)"\)', flags=re.IGNORECASE | re.MULTILINE
|
26
|
-
),
|
27
|
-
},
|
28
|
-
]
|
29
|
-
|
30
10
|
|
31
11
|
class WorkingDir:
|
32
12
|
def __init__(self, path):
|
@@ -130,40 +110,12 @@ def install_pysqlcipher(
|
|
130
110
|
print(f"Could not remove temporary directory '{tmpdir}'!")
|
131
111
|
|
132
112
|
|
133
|
-
def download_db6_key():
|
134
|
-
dp = ""
|
135
|
-
for source in KEY_SOURCES:
|
136
|
-
url = source["url"]
|
137
|
-
regex = source["regex"]
|
138
|
-
print(f"Looking for key: {url}")
|
139
|
-
|
140
|
-
res = urllib.request.urlopen(url)
|
141
|
-
data = res.read().decode("utf-8")
|
142
|
-
match = regex.match(data)
|
143
|
-
if match:
|
144
|
-
dp = match.group("dp")
|
145
|
-
break
|
146
|
-
if dp:
|
147
|
-
cache_file = get_cache_file()
|
148
|
-
print(f"Found key, updating cache file {cache_file}")
|
149
|
-
write_db6_key_cache(dp)
|
150
|
-
else:
|
151
|
-
print("No key found in the online sources.")
|
152
|
-
|
153
|
-
|
154
113
|
def main():
|
155
114
|
from argparse import ArgumentParser
|
156
115
|
|
157
116
|
parser = ArgumentParser("pyrekordbox")
|
158
117
|
subparsers = parser.add_subparsers(dest="command")
|
159
118
|
|
160
|
-
# Download Rekordbx 6 database key command
|
161
|
-
subparsers.add_parser(
|
162
|
-
"download-key",
|
163
|
-
help="Download the Rekordbox 6 database key from the internet "
|
164
|
-
"and write it to the cache file.",
|
165
|
-
)
|
166
|
-
|
167
119
|
# Install pysqlcipher3 command (Windows only)
|
168
120
|
install_parser = subparsers.add_parser(
|
169
121
|
"install-sqlcipher",
|
@@ -192,9 +144,7 @@ def main():
|
|
192
144
|
|
193
145
|
# Parse args and handle command
|
194
146
|
args = parser.parse_args(sys.argv[1:])
|
195
|
-
if args.command == "
|
196
|
-
download_db6_key()
|
197
|
-
elif args.command == "install-sqlcipher":
|
147
|
+
if args.command == "install-sqlcipher":
|
198
148
|
install_pysqlcipher(args.tmpdir, args.cryptolib, install=not args.buildonly)
|
199
149
|
|
200
150
|
|
pyrekordbox/_version.py
CHANGED
@@ -1,7 +1,14 @@
|
|
1
1
|
# file generated by setuptools-scm
|
2
2
|
# don't change, don't track in version control
|
3
3
|
|
4
|
-
__all__ = [
|
4
|
+
__all__ = [
|
5
|
+
"__version__",
|
6
|
+
"__version_tuple__",
|
7
|
+
"version",
|
8
|
+
"version_tuple",
|
9
|
+
"__commit_id__",
|
10
|
+
"commit_id",
|
11
|
+
]
|
5
12
|
|
6
13
|
TYPE_CHECKING = False
|
7
14
|
if TYPE_CHECKING:
|
@@ -9,13 +16,19 @@ if TYPE_CHECKING:
|
|
9
16
|
from typing import Union
|
10
17
|
|
11
18
|
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
19
|
+
COMMIT_ID = Union[str, None]
|
12
20
|
else:
|
13
21
|
VERSION_TUPLE = object
|
22
|
+
COMMIT_ID = object
|
14
23
|
|
15
24
|
version: str
|
16
25
|
__version__: str
|
17
26
|
__version_tuple__: VERSION_TUPLE
|
18
27
|
version_tuple: VERSION_TUPLE
|
28
|
+
commit_id: COMMIT_ID
|
29
|
+
__commit_id__: COMMIT_ID
|
19
30
|
|
20
|
-
__version__ = version = '0.4.
|
21
|
-
__version_tuple__ = version_tuple = (0, 4,
|
31
|
+
__version__ = version = '0.4.4'
|
32
|
+
__version_tuple__ = version_tuple = (0, 4, 4)
|
33
|
+
|
34
|
+
__commit_id__ = commit_id = None
|
pyrekordbox/anlz/file.py
CHANGED
@@ -108,30 +108,36 @@ class AnlzFile(abc.Mapping): # type: ignore[type-arg]
|
|
108
108
|
# Get the four byte struct type
|
109
109
|
tag_type = tag_data[:4].decode("ascii")
|
110
110
|
|
111
|
+
# Get tag length from generic tag header
|
112
|
+
tag_struct = structs.AnlzTag.parse(tag_data)
|
113
|
+
len_tag = tag_struct.len_tag
|
114
|
+
|
111
115
|
if tag_type == "PSSI":
|
112
116
|
# The version that rekordbox 6 *exports* is garbled with an XOR mask.
|
117
|
+
# Determine if the tag is garbled by checking the initial (masked) mood value.
|
113
118
|
# All bytes after byte 17 (len_e) are XOR-masked with a pattern that is
|
114
119
|
# generated by adding the value of len_e to each byte of XOR_MASK
|
115
120
|
|
116
121
|
# Check if the file is garbled (only on exported files)
|
117
122
|
# For this we check the validity of mood and bank
|
118
123
|
# Mood: High=1, Mid=2, Low=3
|
119
|
-
# Bank: 0-8
|
120
124
|
mood = Int16ub.parse(tag_data[18:20])
|
121
|
-
|
122
|
-
if 1 <= mood <= 3 and 0 <= bank <= 8:
|
125
|
+
if 1 <= mood <= 3:
|
123
126
|
logger.debug("PSSI is not garbled!")
|
124
127
|
else:
|
125
|
-
logger.debug("PSSI is garbled!")
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
128
|
+
logger.debug("PSSI is garbled! (raw_mood=%s)", mood)
|
129
|
+
len_entries = Int16ub.parse(tag_data[16:18])
|
130
|
+
|
131
|
+
# Copy only this tag's data so we don't mutate the remainder of file slice
|
132
|
+
mutable_tag_data = bytearray(tag_data[:len_tag])
|
133
|
+
|
134
|
+
for x in range(len(mutable_tag_data) - 18):
|
130
135
|
mask = XOR_MASK[x % len(XOR_MASK)] + len_entries
|
131
136
|
if mask > 255:
|
132
137
|
mask -= 256
|
133
|
-
|
134
|
-
|
138
|
+
mutable_tag_data[18 + x] ^= mask
|
139
|
+
|
140
|
+
tag_data = bytes(mutable_tag_data)
|
135
141
|
|
136
142
|
try:
|
137
143
|
# Parse the struct
|
@@ -140,7 +146,7 @@ class AnlzFile(abc.Mapping): # type: ignore[type-arg]
|
|
140
146
|
raise StructNotInitializedError()
|
141
147
|
tags.append(tag)
|
142
148
|
len_header = tag.struct.len_header
|
143
|
-
|
149
|
+
|
144
150
|
logger.debug(
|
145
151
|
"Parsed struct '%s' (len_header=%s, len_tag=%s)",
|
146
152
|
tag_type,
|
@@ -149,8 +155,7 @@ class AnlzFile(abc.Mapping): # type: ignore[type-arg]
|
|
149
155
|
)
|
150
156
|
except KeyError:
|
151
157
|
logger.warning("Tag '%s' not supported!", tag_type)
|
152
|
-
|
153
|
-
len_tag = tag_struct.len_tag
|
158
|
+
|
154
159
|
i += len_tag
|
155
160
|
|
156
161
|
self.file_header = file_header
|
pyrekordbox/config.py
CHANGED
@@ -8,30 +8,18 @@ Contains all the path and settings handling of the Rekordbox installation(s) on
|
|
8
8
|
the users machine.
|
9
9
|
"""
|
10
10
|
|
11
|
-
import base64
|
12
11
|
import json
|
13
12
|
import logging
|
14
13
|
import os
|
15
|
-
import re
|
16
14
|
import sys
|
17
|
-
import textwrap
|
18
|
-
import time
|
19
15
|
import xml.etree.cElementTree as xml
|
20
16
|
from pathlib import Path
|
21
17
|
from typing import Any, Dict, List, Union
|
22
18
|
|
23
|
-
import blowfish
|
24
|
-
import frida
|
25
19
|
import packaging.version
|
26
20
|
|
27
|
-
from .utils import get_rekordbox_pid
|
28
|
-
|
29
21
|
logger = logging.getLogger(__name__)
|
30
22
|
|
31
|
-
# Cache file for pyrekordbox data
|
32
|
-
_cache_file_version = 2
|
33
|
-
_cache_file_name = "rb.cache"
|
34
|
-
|
35
23
|
# Define empty pyrekordbox configuration
|
36
24
|
__config__ = {
|
37
25
|
"pioneer": {
|
@@ -48,29 +36,6 @@ class InvalidApplicationDirname(Exception):
|
|
48
36
|
pass
|
49
37
|
|
50
38
|
|
51
|
-
def get_appdata_dir() -> Path:
|
52
|
-
"""Returns the path of the application data directory.
|
53
|
-
|
54
|
-
On Windows, the application data is stored in `/Users/user/AppData/Roaming`.
|
55
|
-
On macOS the application data is stored in `~/Libary/Application Support`.
|
56
|
-
"""
|
57
|
-
if sys.platform == "win32":
|
58
|
-
# Windows: located in /Users/user/AppData/Roaming/
|
59
|
-
app_data = Path(os.environ["AppData"])
|
60
|
-
elif sys.platform == "darwin":
|
61
|
-
# MacOS: located in ~/Library/Application Support/
|
62
|
-
app_data = Path("~").expanduser() / "Library" / "Application Support"
|
63
|
-
else:
|
64
|
-
# Linux: not supported
|
65
|
-
logger.warning(f"OS {sys.platform} not supported!")
|
66
|
-
return Path("~").expanduser() / ".local" / "share"
|
67
|
-
return app_data
|
68
|
-
|
69
|
-
|
70
|
-
def get_cache_file() -> Path:
|
71
|
-
return get_appdata_dir() / "pyrekordbox" / _cache_file_name
|
72
|
-
|
73
|
-
|
74
39
|
def get_pioneer_install_dir(path: Union[str, Path] = None) -> Path: # pragma: no cover
|
75
40
|
"""Returns the path of the Pioneer program installation directory.
|
76
41
|
|
@@ -231,46 +196,6 @@ def read_rekordbox6_options(pioneer_app_dir: Union[str, Path]) -> Dict[str, Any]
|
|
231
196
|
return options
|
232
197
|
|
233
198
|
|
234
|
-
def read_rekordbox6_asar(rb6_install_dir: Union[str, Path]) -> str:
|
235
|
-
"""Finds and parses the Rekordbox 6 `app.asar` archive file.
|
236
|
-
|
237
|
-
An ASAR file is an archive used to package source code for an application
|
238
|
-
using Electron. Rekordbox 6 stores some useful information for opening the
|
239
|
-
new `master.db` database in this file.
|
240
|
-
|
241
|
-
Parameters
|
242
|
-
----------
|
243
|
-
rb6_install_dir : str or Path
|
244
|
-
The path of the Rekordbox 6 installation directory.
|
245
|
-
|
246
|
-
Returns
|
247
|
-
-------
|
248
|
-
asar_data : str
|
249
|
-
The data of the Rekordbox 6 `app.asar` archive file as ANSI encoded string.
|
250
|
-
"""
|
251
|
-
rb6_install_dir = Path(rb6_install_dir)
|
252
|
-
# Find location of app asar file
|
253
|
-
if sys.platform == "win32":
|
254
|
-
location = rb6_install_dir / "rekordboxAgent-win32-x64" / "resources"
|
255
|
-
encoding = "ANSI"
|
256
|
-
elif sys.platform == "darwin":
|
257
|
-
if not str(rb6_install_dir).endswith(".app"):
|
258
|
-
rb6_install_dir = rb6_install_dir / "rekordbox.app"
|
259
|
-
location = (
|
260
|
-
rb6_install_dir / "Contents" / "MacOS" / "rekordboxAgent.app" / "Contents" / "Resources"
|
261
|
-
)
|
262
|
-
encoding = "cp437"
|
263
|
-
else:
|
264
|
-
logger.warning(f"OS {sys.platform} not supported!")
|
265
|
-
return ""
|
266
|
-
|
267
|
-
# Read asar file
|
268
|
-
path = (location / "app.asar").absolute()
|
269
|
-
with open(path, "rb") as fh:
|
270
|
-
data = fh.read().decode(encoding, errors="replace")
|
271
|
-
return data
|
272
|
-
|
273
|
-
|
274
199
|
def _extract_version(name: str, major_version: int) -> str:
|
275
200
|
name = name.replace(".app", "") # Needed for MacOS
|
276
201
|
ver_str = name.replace("rekordbox", "").strip()
|
@@ -387,199 +312,6 @@ def _get_rb5_config(
|
|
387
312
|
return conf
|
388
313
|
|
389
314
|
|
390
|
-
def _extract_pw(pioneer_install_dir: Path) -> str: # pragma: no cover
|
391
|
-
"""Extract the password for decrypting the Rekordbox 6 database key."""
|
392
|
-
asar_data = read_rekordbox6_asar(pioneer_install_dir)
|
393
|
-
match_result = re.search('pass: ".(.*?)"', asar_data)
|
394
|
-
if match_result is None:
|
395
|
-
raise RuntimeError("Could not read `app.asar` file.")
|
396
|
-
match = match_result.group(0)
|
397
|
-
pw = match.replace("pass: ", "").strip('"')
|
398
|
-
return pw
|
399
|
-
|
400
|
-
|
401
|
-
class KeyExtractor:
|
402
|
-
"""Extracts the Rekordbox database key using code injection.
|
403
|
-
|
404
|
-
This method works by injecting code into the Rekordbox process and intercepting the
|
405
|
-
call to unlock the database. Works for any Rekordbox version.
|
406
|
-
"""
|
407
|
-
|
408
|
-
# fmt: off
|
409
|
-
SCRIPT = textwrap.dedent("""
|
410
|
-
var sqlite3_key = Module.findExportByName(null, 'sqlite3_key');
|
411
|
-
|
412
|
-
Interceptor.attach(sqlite3_key, {
|
413
|
-
onEnter: function(args) {
|
414
|
-
var size = args[2].toInt32();
|
415
|
-
var key = args[1].readUtf8String(size);
|
416
|
-
send('sqlite3_key: ' + key);
|
417
|
-
}
|
418
|
-
});
|
419
|
-
""")
|
420
|
-
# fmt: on
|
421
|
-
|
422
|
-
def __init__(self, rekordbox_executable: Union[str, Path]):
|
423
|
-
self.executable = str(rekordbox_executable)
|
424
|
-
self.key = ""
|
425
|
-
|
426
|
-
def on_message(self, message: Dict[str, Any], data: Any) -> None:
|
427
|
-
payload = message["payload"]
|
428
|
-
if payload.startswith("sqlite3_key"):
|
429
|
-
self.key = payload.split(": ")[1]
|
430
|
-
|
431
|
-
def run(self) -> str:
|
432
|
-
pid = get_rekordbox_pid()
|
433
|
-
if pid:
|
434
|
-
raise RuntimeError(
|
435
|
-
"Rekordbox is running. Please close Rekordbox before running the `KeyExtractor`."
|
436
|
-
)
|
437
|
-
# Spawn Rekordbox process and attach to it
|
438
|
-
pid = frida.spawn(self.executable)
|
439
|
-
frida.resume(pid)
|
440
|
-
session = frida.attach(pid)
|
441
|
-
script = session.create_script(self.SCRIPT)
|
442
|
-
script.on("message", self.on_message)
|
443
|
-
script.load()
|
444
|
-
# Wait for key to be extracted
|
445
|
-
while not self.key:
|
446
|
-
time.sleep(0.1)
|
447
|
-
# Kill Rekordbox process
|
448
|
-
frida.kill(pid)
|
449
|
-
|
450
|
-
return self.key
|
451
|
-
|
452
|
-
|
453
|
-
def write_db6_key_cache(key: str) -> None: # pragma: no cover
|
454
|
-
r"""Writes the decrypted Rekordbox6 database key to the cache file.
|
455
|
-
|
456
|
-
This method can also be used to manually cache the database key, provided
|
457
|
-
the user has found the key somewhere else. The key can be, for example,
|
458
|
-
found in some other projects that hard-coded it.
|
459
|
-
|
460
|
-
The cache file is stored in the application data directory of pyrekordbox:
|
461
|
-
Windows: `C:\Users\<user>\AppData\Roaming\pyrekordbox`
|
462
|
-
macOS: `~/Library/Application Support/pyrekordbox`
|
463
|
-
|
464
|
-
Parameters
|
465
|
-
----------
|
466
|
-
key : str
|
467
|
-
The decrypted database key. To make sure the key is valid, the first
|
468
|
-
five characters are checked before writing the key to the cache file.
|
469
|
-
The key should start with '402fd'.
|
470
|
-
|
471
|
-
Examples
|
472
|
-
--------
|
473
|
-
>>> from pyrekordbox.config import write_db6_key_cache
|
474
|
-
>>> from pyrekordbox import Rekordbox6Database
|
475
|
-
>>> write_db6_key_cache("402fd...")
|
476
|
-
>>> db = Rekordbox6Database() # The db can now be opened without providing the key
|
477
|
-
"""
|
478
|
-
# Check if the key looks like a valid key
|
479
|
-
if not key.startswith("402fd"):
|
480
|
-
raise ValueError("The provided database key doesn't look valid!")
|
481
|
-
lines = list()
|
482
|
-
lines.append(f"version: {_cache_file_version}")
|
483
|
-
lines.append("dp: " + key)
|
484
|
-
text = "\n".join(lines)
|
485
|
-
|
486
|
-
cache_file = get_cache_file()
|
487
|
-
if not cache_file.parent.exists():
|
488
|
-
cache_file.parent.mkdir()
|
489
|
-
|
490
|
-
with open(cache_file, "w") as fh:
|
491
|
-
fh.write(text)
|
492
|
-
# Set the config key to make sure the key is present after calling method
|
493
|
-
if __config__["rekordbox6"]:
|
494
|
-
__config__["rekordbox6"]["dp"] = key # type: ignore
|
495
|
-
if __config__["rekordbox7"]:
|
496
|
-
__config__["rekordbox7"]["dp"] = key # type: ignore
|
497
|
-
|
498
|
-
|
499
|
-
def _update_sqlite_key(opts: Dict[str, Any], conf: Dict[str, Any]) -> None:
|
500
|
-
cache_version = 0
|
501
|
-
pw, dp = "", ""
|
502
|
-
|
503
|
-
cache_file = get_cache_file()
|
504
|
-
|
505
|
-
if cache_file.exists(): # pragma: no cover
|
506
|
-
logger.debug("Found cache file %s", cache_file)
|
507
|
-
# Read cache file
|
508
|
-
with open(cache_file, "r") as fh:
|
509
|
-
text = fh.read()
|
510
|
-
lines = text.splitlines()
|
511
|
-
if lines[0].startswith("version:"):
|
512
|
-
cache_version = int(lines[0].split(":")[1].strip())
|
513
|
-
else:
|
514
|
-
cache_version = 1
|
515
|
-
if cache_version == 1:
|
516
|
-
# Cache file introduced in pyrekordbox 0.1.6 contains only the password
|
517
|
-
pw = lines[0]
|
518
|
-
logger.debug("Found pw in cache file")
|
519
|
-
elif cache_version == 2:
|
520
|
-
# Cache file introduced in pyrekordbox 0.1.7 contains version and db key
|
521
|
-
dp = lines[1].split(":")[1].strip()
|
522
|
-
logger.debug("Found dp in cache file")
|
523
|
-
else:
|
524
|
-
raise ValueError(f"Invalid cache version: {cache_version}")
|
525
|
-
else:
|
526
|
-
logger.debug("No cache file found")
|
527
|
-
|
528
|
-
if cache_version < _cache_file_version: # pragma: no cover
|
529
|
-
# Update cache file
|
530
|
-
if not pw:
|
531
|
-
logger.debug("Extracting pw")
|
532
|
-
try:
|
533
|
-
pw = _extract_pw(conf["install_dir"])
|
534
|
-
logger.debug("Extracted pw from 'app.asar'")
|
535
|
-
except (FileNotFoundError, RuntimeError):
|
536
|
-
logger.debug("Could not extract pw from 'app.asar'")
|
537
|
-
pw = ""
|
538
|
-
|
539
|
-
if not dp:
|
540
|
-
if pw:
|
541
|
-
cipher = blowfish.Cipher(pw.encode())
|
542
|
-
dp = base64.standard_b64decode(opts["dp"]) # type: ignore
|
543
|
-
dp = b"".join(cipher.decrypt_ecb(dp)).decode()
|
544
|
-
logger.debug("Unlocked dp from pw: %s", dp)
|
545
|
-
else:
|
546
|
-
if sys.platform == "win32":
|
547
|
-
executable = conf["install_dir"] / "rekordbox.exe"
|
548
|
-
elif sys.platform == "darwin":
|
549
|
-
install_dir = conf["install_dir"]
|
550
|
-
if not str(install_dir).endswith(".app"):
|
551
|
-
install_dir = install_dir / "rekordbox.app"
|
552
|
-
executable = install_dir / "Contents" / "MacOS" / "rekordbox"
|
553
|
-
else:
|
554
|
-
# Linux: not supported
|
555
|
-
logger.warning(f"OS {sys.platform} not supported!")
|
556
|
-
executable = Path("")
|
557
|
-
|
558
|
-
if not executable.exists():
|
559
|
-
logger.warning(f"Could not find Rekordbox executable: {executable}")
|
560
|
-
else:
|
561
|
-
extractor = KeyExtractor(executable)
|
562
|
-
try:
|
563
|
-
dp = extractor.run()
|
564
|
-
logger.debug("Extracted dp from Rekordbox process")
|
565
|
-
except Exception as e:
|
566
|
-
logger.info(f"`KeyExtractor` failed: {e}")
|
567
|
-
|
568
|
-
if dp:
|
569
|
-
logger.debug("Writing dp to cache file")
|
570
|
-
write_db6_key_cache(dp)
|
571
|
-
else:
|
572
|
-
logging.warning(
|
573
|
-
"Could not retrieve db-key or read it from cache file,"
|
574
|
-
"use the CLI to download it from external sources: "
|
575
|
-
"`python -m pyrekordbox download-key`"
|
576
|
-
)
|
577
|
-
|
578
|
-
# Add database key to config if found
|
579
|
-
if dp:
|
580
|
-
conf["dp"] = dp
|
581
|
-
|
582
|
-
|
583
315
|
def _get_rb6_config(
|
584
316
|
pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str = ""
|
585
317
|
) -> Dict[str, Any]:
|
@@ -594,9 +326,6 @@ def _get_rb6_config(
|
|
594
326
|
assert str(conf["db_dir"]) == str(db_dir)
|
595
327
|
assert str(conf["db_path"]) == str(db_path)
|
596
328
|
|
597
|
-
# Update SQLite key
|
598
|
-
_update_sqlite_key(opts, conf)
|
599
|
-
|
600
329
|
return conf
|
601
330
|
|
602
331
|
|
@@ -614,69 +343,9 @@ def _get_rb7_config(
|
|
614
343
|
assert str(conf["db_dir"]) == str(db_dir)
|
615
344
|
assert str(conf["db_path"]) == str(db_path)
|
616
345
|
|
617
|
-
# Update SQLite key
|
618
|
-
_update_sqlite_key(opts, conf)
|
619
|
-
|
620
346
|
return conf
|
621
347
|
|
622
348
|
|
623
|
-
# noinspection PyPackageRequirements,PyUnresolvedReferences
|
624
|
-
def _read_config_file(path: Union[str, Path]) -> Dict[str, Any]:
|
625
|
-
path = Path(path)
|
626
|
-
if not path.exists():
|
627
|
-
raise FileNotFoundError(f"No such file or directory: '{path}'")
|
628
|
-
|
629
|
-
ext = path.suffix.lower()
|
630
|
-
if ext == ".cfg":
|
631
|
-
from configparser import ConfigParser
|
632
|
-
|
633
|
-
parser = ConfigParser()
|
634
|
-
parser.read(path)
|
635
|
-
return {k: v for k, v in parser.items() if v is not None}
|
636
|
-
elif ext == ".toml":
|
637
|
-
import toml
|
638
|
-
|
639
|
-
return dict(toml.load(path))
|
640
|
-
elif ext in (".yaml", ".yml"):
|
641
|
-
import yaml
|
642
|
-
|
643
|
-
with open(path, "r") as stream:
|
644
|
-
return dict(yaml.safe_load(stream))
|
645
|
-
return dict()
|
646
|
-
|
647
|
-
|
648
|
-
def read_pyrekordbox_configuration() -> Dict[str, Any]:
|
649
|
-
"""Reads the pyrekordbox configuration.
|
650
|
-
|
651
|
-
So far only the `pioneer-install-dir` and `pioneer-app-dir` fileds in the
|
652
|
-
`rekordbox` section are supported. Supported configuration files are
|
653
|
-
pyproject.toml, setup.cfg, rekordbox.toml, rekordbox.cfg and rekordbox.yml.
|
654
|
-
The files are searched for in that order.
|
655
|
-
|
656
|
-
Returns
|
657
|
-
-------
|
658
|
-
pyrekordbox_config : dict
|
659
|
-
The pyrekordbox configuration data, if found.
|
660
|
-
"""
|
661
|
-
files = ["pyproject.toml", "setup.cfg"]
|
662
|
-
for ext in [".toml", ".cfg", ".yaml", ".yml"]:
|
663
|
-
files.append("pyrekordbox" + ext)
|
664
|
-
|
665
|
-
for file in files:
|
666
|
-
try:
|
667
|
-
data = _read_config_file(file)
|
668
|
-
config = dict(data["rekordbox"])
|
669
|
-
logger.debug("Read configuration from '%s'", file)
|
670
|
-
except (ImportError, FileNotFoundError) as e:
|
671
|
-
logger.debug("Could not read config file '%s': %s", file, e)
|
672
|
-
except KeyError:
|
673
|
-
pass
|
674
|
-
else:
|
675
|
-
if config:
|
676
|
-
return config
|
677
|
-
return dict()
|
678
|
-
|
679
|
-
|
680
349
|
def update_config(
|
681
350
|
pioneer_install_dir: Union[str, Path] = None,
|
682
351
|
pioneer_app_dir: Union[str, Path] = None,
|
@@ -713,19 +382,6 @@ def update_config(
|
|
713
382
|
The name of the Rekordbox 7 installation directory. By default, the normal
|
714
383
|
directory name is used (Windows: 'rekordbox 7.x.x', macOS: 'rekordbox 7.app').
|
715
384
|
"""
|
716
|
-
# Read config file
|
717
|
-
conf = read_pyrekordbox_configuration()
|
718
|
-
if pioneer_install_dir is None and "pioneer-install-dir" in conf:
|
719
|
-
pioneer_install_dir = conf["pioneer-install-dir"]
|
720
|
-
if pioneer_app_dir is None and "pioneer-app-dir" in conf:
|
721
|
-
pioneer_app_dir = conf["pioneer-app-dir"]
|
722
|
-
if not rb5_install_dirname and "rekordbox5-install-dirname" in conf:
|
723
|
-
rb5_install_dirname = conf["rekordbox5-install-dirname"]
|
724
|
-
if not rb6_install_dirname and "rekordbox6-install-dirname" in conf:
|
725
|
-
rb6_install_dirname = conf["rekordbox6-install-dirname"]
|
726
|
-
if not rb7_install_dirname and "rekordbox7-install-dirname" in conf:
|
727
|
-
rb7_install_dirname = conf["rekordbox7-install-dirname"]
|
728
|
-
|
729
385
|
# Pioneer installation directory
|
730
386
|
try:
|
731
387
|
pioneer_install_dir = get_pioneer_install_dir(pioneer_install_dir)
|
pyrekordbox/db6/database.py
CHANGED
@@ -17,7 +17,7 @@ from sqlalchemy.sql.sqltypes import DateTime, String
|
|
17
17
|
|
18
18
|
from ..anlz import AnlzFile, get_anlz_paths, read_anlz_files # type: ignore[attr-defined]
|
19
19
|
from ..config import get_config
|
20
|
-
from ..utils import get_rekordbox_pid
|
20
|
+
from ..utils import deobfuscate, get_rekordbox_pid
|
21
21
|
from . import tables
|
22
22
|
from .aux_files import MasterPlaylistXml
|
23
23
|
from .registry import RekordboxAgentRegistry
|
@@ -33,23 +33,18 @@ except ImportError: # pragma: no cover
|
|
33
33
|
|
34
34
|
_sqlcipher_available = False
|
35
35
|
|
36
|
-
MAX_VERSION = "6.6.5"
|
37
36
|
SPECIAL_PLAYLIST_IDS = [
|
38
37
|
"100000", # Cloud Library Sync
|
39
38
|
"200000", # CUE Analysis Playlist
|
40
39
|
]
|
41
40
|
|
41
|
+
BLOB = b"PN_Pq^*N>(JYe*u^8;Yg76HuZ<mR13S?=>)b9;DpoTXV(6ItkU`}8*m6tx_I{Solh_N#dfe{v="
|
42
|
+
|
42
43
|
logger = logging.getLogger(__name__)
|
43
44
|
|
44
45
|
PathLike = Union[str, Path]
|
45
46
|
ContentLike = Union[DjmdContent, int, str]
|
46
47
|
PlaylistLike = Union[DjmdPlaylist, int, str]
|
47
|
-
|
48
|
-
|
49
|
-
class NoCachedKey(Exception):
|
50
|
-
pass
|
51
|
-
|
52
|
-
|
53
48
|
T = TypeVar("T", bound=tables.Base)
|
54
49
|
|
55
50
|
|
@@ -143,24 +138,15 @@ class Rekordbox6Database:
|
|
143
138
|
if unlock:
|
144
139
|
if not _sqlcipher_available: # pragma: no cover
|
145
140
|
raise ImportError("Could not unlock database: 'sqlcipher3' package not found")
|
141
|
+
|
146
142
|
if not key: # pragma: no cover
|
147
|
-
|
148
|
-
|
149
|
-
except KeyError:
|
150
|
-
raise NoCachedKey(
|
151
|
-
"Could not unlock database: No key found\n"
|
152
|
-
f"If you are using Rekordbox>{MAX_VERSION} the key cannot be "
|
153
|
-
f"extracted automatically!\n"
|
154
|
-
"Please use the CLI of pyrekordbox to download the key or "
|
155
|
-
"use the `key` parameter to manually provide it."
|
156
|
-
)
|
157
|
-
else:
|
143
|
+
key = deobfuscate(BLOB)
|
144
|
+
elif not key.startswith("402fd"):
|
158
145
|
# Check if key looks like a valid key
|
159
|
-
|
160
|
-
raise ValueError("The provided database key doesn't look valid!")
|
146
|
+
raise ValueError("The provided database key doesn't look valid!")
|
161
147
|
|
162
|
-
logger.debug("Key: %s", key)
|
163
148
|
# Unlock database and create engine
|
149
|
+
logger.debug("Key: %s", key)
|
164
150
|
url = f"sqlite+pysqlcipher://:{key}@/{db_path}?"
|
165
151
|
engine = create_engine(url, module=sqlite3)
|
166
152
|
else:
|
pyrekordbox/db6/tables.py
CHANGED
@@ -127,21 +127,24 @@ def datetime_to_str(value: datetime) -> str:
|
|
127
127
|
|
128
128
|
def string_to_datetime(value: str) -> datetime:
|
129
129
|
try:
|
130
|
+
# Normalize 'Z' (Zulu/UTC) to '+00:00' for fromisoformat compatibility
|
131
|
+
if value.endswith("Z"):
|
132
|
+
value = value[:-1] + "+00:00"
|
130
133
|
dt = datetime.fromisoformat(value)
|
131
134
|
except ValueError:
|
132
135
|
if len(value.strip()) > 23:
|
133
|
-
#
|
134
|
-
# "2025-04-12 19:11:29.274 -05:00" or
|
136
|
+
# Handles formats like:
|
135
137
|
# "2025-04-12 19:11:29.274 -05:00 (Central Daylight Time)"
|
136
138
|
datestr, tzinfo = value[:23], value[23:30]
|
137
139
|
datestr = datestr.strip()
|
138
140
|
tzinfo = tzinfo.strip()
|
139
|
-
|
140
|
-
|
141
|
+
if tzinfo == "Z":
|
142
|
+
tzinfo = "+00:00"
|
143
|
+
assert re.match(r"^[+-]?\d{1,2}:\d{2}", tzinfo), f"Invalid tzinfo: {tzinfo}"
|
144
|
+
datestr = datestr + tzinfo
|
141
145
|
else:
|
142
|
-
datestr
|
146
|
+
datestr = value.strip()
|
143
147
|
dt = datetime.fromisoformat(datestr)
|
144
|
-
# Convert to local timezone and return without timezone
|
145
148
|
return dt.astimezone().replace(tzinfo=None)
|
146
149
|
|
147
150
|
|
pyrekordbox/utils.py
CHANGED
@@ -4,15 +4,19 @@
|
|
4
4
|
|
5
5
|
"""This module contains common constants and methods used in other modules."""
|
6
6
|
|
7
|
+
import base64
|
7
8
|
import os
|
8
9
|
import warnings
|
9
10
|
import xml.etree.cElementTree as xml
|
11
|
+
import zlib
|
10
12
|
from xml.dom import minidom
|
11
13
|
|
12
14
|
import psutil
|
13
15
|
|
14
16
|
warnings.simplefilter("always", DeprecationWarning)
|
15
17
|
|
18
|
+
BLOB_KEY = b"657f48f84c437cc1"
|
19
|
+
|
16
20
|
|
17
21
|
def warn_deprecated(name: str, new_name: str = "", hint: str = "", remove_in: str = "") -> None:
|
18
22
|
s = f"'{name}' is deprecated"
|
@@ -162,3 +166,19 @@ def pretty_xml(element: xml.Element, indent: str = None, encoding: str = "utf-8"
|
|
162
166
|
# Remove annoying empty lines
|
163
167
|
string = "\n".join([line for line in string.splitlines() if line.strip()])
|
164
168
|
return string
|
169
|
+
|
170
|
+
|
171
|
+
def obfuscate(plaintext: str) -> bytes:
|
172
|
+
"""Obfuscates a plaintext string using zlib compression and XOR encryption."""
|
173
|
+
key = BLOB_KEY
|
174
|
+
data = zlib.compress(plaintext.encode("utf-8"))
|
175
|
+
xored = bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
|
176
|
+
return base64.b85encode(xored) # bytes
|
177
|
+
|
178
|
+
|
179
|
+
def deobfuscate(blob: bytes) -> str:
|
180
|
+
"""Deobfuscates a previously obfuscated string."""
|
181
|
+
key = BLOB_KEY
|
182
|
+
data = base64.b85decode(blob)
|
183
|
+
xored = bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
|
184
|
+
return zlib.decompress(xored).decode("utf-8")
|
@@ -1,11 +1,11 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: pyrekordbox
|
3
|
-
Version: 0.4.
|
3
|
+
Version: 0.4.4
|
4
4
|
Summary: Inofficial Python package for interacting with the library of Pioneers Rekordbox DJ software.
|
5
5
|
Author-email: Dylan Jones <dylanljones94@gmail.com>
|
6
6
|
License: MIT License
|
7
7
|
|
8
|
-
Copyright (c) 2022-
|
8
|
+
Copyright (c) 2022-2025, Dylan Jones
|
9
9
|
|
10
10
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
11
11
|
of this software and associated documentation files (the "Software"), to deal
|
@@ -29,7 +29,7 @@ Project-URL: Source, https://github.com/dylanljones/pyrekordbox
|
|
29
29
|
Project-URL: Documentation, https://pyrekordbox.readthedocs.io/en/stable/
|
30
30
|
Project-URL: Tracker, https://github.com/dylanljones/pyrekordbox/issues
|
31
31
|
Platform: any
|
32
|
-
Classifier: Development Status ::
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
33
33
|
Classifier: Intended Audience :: Developers
|
34
34
|
Classifier: Intended Audience :: Information Technology
|
35
35
|
Classifier: License :: OSI Approved :: MIT License
|
@@ -48,14 +48,12 @@ Requires-Python: >=3.8
|
|
48
48
|
Description-Content-Type: text/markdown
|
49
49
|
License-File: LICENSE
|
50
50
|
Requires-Dist: bidict>=0.21.0
|
51
|
-
Requires-Dist: blowfish>=0.6.0
|
52
51
|
Requires-Dist: construct>=2.10.0
|
53
52
|
Requires-Dist: numpy>=1.19.0
|
54
53
|
Requires-Dist: packaging
|
55
54
|
Requires-Dist: psutil>=5.9.0
|
56
55
|
Requires-Dist: sqlalchemy>=2.0.0
|
57
56
|
Requires-Dist: sqlcipher3-wheels
|
58
|
-
Requires-Dist: frida-tools
|
59
57
|
Requires-Dist: python-dateutil
|
60
58
|
Provides-Extra: test
|
61
59
|
Requires-Dist: hypothesis>=6.0.0; extra == "test"
|
@@ -87,11 +85,9 @@ Pioneers Rekordbox DJ Software. It currently supports
|
|
87
85
|
- Analysis files (ANLZ)
|
88
86
|
- My-Setting files
|
89
87
|
|
90
|
-
|
88
|
+
Check the [changelog][CHANGELOG] for recent changes!
|
91
89
|
|
92
|
-
|
93
|
-
> This project is still under development and might contain bugs or have breaking API changes in the future.
|
94
|
-
> Check the [changelog][CHANGELOG] for recent changes!
|
90
|
+
Tested Rekordbox versions: ``5.8.6 | 6.7.7 | 7.0.9``
|
95
91
|
|
96
92
|
|
97
93
|
## 🔧 Installation
|
@@ -143,10 +139,6 @@ from pyrekordbox.config import update_config
|
|
143
139
|
update_config("<pioneer_install_dir>", "<pioneer_app_dir>")
|
144
140
|
````
|
145
141
|
|
146
|
-
Alternatively the two paths can be specified in a configuration file under the section
|
147
|
-
`rekordbox`. Supported configuration files are pyproject.toml, setup.cfg, pyrekordbox.toml,
|
148
|
-
pyrekordbox.cfg and pyrekordbox.yaml.
|
149
|
-
|
150
142
|
|
151
143
|
### Rekordbox 6/7 database
|
152
144
|
|
@@ -193,19 +185,6 @@ So far only a few tables support adding or deleting entries:
|
|
193
185
|
- ``DjmdGenre``: Genres
|
194
186
|
- ``DjmdLabel``: Labels
|
195
187
|
|
196
|
-
If the automatic key extraction fails the command line interface of ``pyrekordbox``
|
197
|
-
provides a command for downloading the key from known sources and writing it to the
|
198
|
-
cache file:
|
199
|
-
````shell
|
200
|
-
python -m pyrekordbox download-key
|
201
|
-
````
|
202
|
-
Once the key is cached the database can be opened without providing the key.
|
203
|
-
The key can also be provided manually:
|
204
|
-
````python
|
205
|
-
db = Rekordbox6Database(key="<insert key here>")
|
206
|
-
````
|
207
|
-
|
208
|
-
|
209
188
|
### Rekordbox XML
|
210
189
|
|
211
190
|
The Rekordbox XML database is used for importing (and exporting) Rekordbox collections
|
@@ -263,9 +242,6 @@ Changing and creating the Rekordbox analysis files is planned as well, but for t
|
|
263
242
|
full structure of the analysis files has to be understood.
|
264
243
|
|
265
244
|
Unsupported ANLZ tags:
|
266
|
-
- PCOB
|
267
|
-
- PCO2
|
268
|
-
- PSSI
|
269
245
|
- PWV6
|
270
246
|
- PWV7
|
271
247
|
- PWVC
|
@@ -317,22 +293,20 @@ instead of opening an issue.
|
|
317
293
|
Pyrekordbox is tested on Windows and MacOS, however some features can't be tested in
|
318
294
|
the CI setup since it requires a working Rekordbox installation.
|
319
295
|
|
296
|
+
## ♡ Sponsor
|
297
|
+
|
298
|
+
If pyrekordbox has helped you or saved you time, consider supporting its development - every coffee makes a difference!
|
299
|
+
|
300
|
+
[](https://www.buymeacoffee.com/dylanljones)
|
301
|
+
|
320
302
|
|
321
303
|
## 🔗 Related Projects and References
|
322
304
|
|
323
305
|
- [crate-digger]: Java library for fetching and parsing rekordbox exports and track analysis files.
|
324
306
|
- [rekordcrate]: Library for parsing Pioneer Rekordbox device exports
|
325
307
|
- [supbox]: Get the currently playing track from Rekordbox v6 as Audio Hijack Shoutcast/Icecast metadata, display in your OBS video broadcast or export as JSON.
|
326
|
-
- Deep Symmetry has an extensive analysis of Rekordbox's ANLZ and .edb export file formats
|
308
|
+
- [Deep Symmetry] has an extensive analysis of Rekordbox's ANLZ and .edb export file formats
|
327
309
|
https://djl-analysis.deepsymmetry.org/djl-analysis
|
328
|
-
- rekordcrate reverse engineered the format of the Rekordbox MySetting files
|
329
|
-
https://holzhaus.github.io/rekordcrate/rekordcrate/setting/index.html
|
330
|
-
- rekordcloud went into detail about the internals of Rekordbox 6
|
331
|
-
https://rekord.cloud/blog/technical-inspection-of-rekordbox-6-and-its-new-internals.
|
332
|
-
- supbox has a nice implementation on finding the Rekordbox 6 database key
|
333
|
-
https://github.com/gabek/supbox
|
334
|
-
|
335
|
-
|
336
310
|
|
337
311
|
|
338
312
|
[tests-badge]: https://img.shields.io/github/actions/workflow/status/dylanljones/pyrekordbox/tests.yml?branch=master&label=tests&logo=github&style=flat
|
@@ -379,3 +353,4 @@ the CI setup since it requires a working Rekordbox installation.
|
|
379
353
|
[rekordcrate]: https://github.com/Holzhaus/rekordcrate
|
380
354
|
[crate-digger]: https://github.com/Deep-Symmetry/crate-digger
|
381
355
|
[supbox]: https://github.com/gabek/supbox
|
356
|
+
[Deep Symmetry]: https://deepsymmetry.org/
|
@@ -1,25 +1,25 @@
|
|
1
1
|
pyrekordbox/__init__.py,sha256=XtHMC6j7c11dxpf2mrXGz8Ux8TJnlIGcGZTwhs6NpVU,664
|
2
|
-
pyrekordbox/__main__.py,sha256=
|
3
|
-
pyrekordbox/_version.py,sha256=
|
4
|
-
pyrekordbox/config.py,sha256=
|
2
|
+
pyrekordbox/__main__.py,sha256=uo_UyoTBXQ-mE3oxe45YWuWH9N2t9HJ5RuzEXQYELVY,4431
|
3
|
+
pyrekordbox/_version.py,sha256=ReYqsThJkI0PbGcOHlpeQrPIWshdyOrU6h3QkRk8bHw,704
|
4
|
+
pyrekordbox/config.py,sha256=roJEkEoKJ8Nj4OlzyE8NXaxa8riXp1GR2tYZnhS1hxI,16485
|
5
5
|
pyrekordbox/logger.py,sha256=dq1BtXBGavuAjuc45mvjF6mOWaeZqZFzo2aBOJdJ0Ik,483
|
6
6
|
pyrekordbox/rbxml.py,sha256=rb7bRnr_Bmf3P3bMOh8VDtR2EsVe7E4VYuClLkA-_Lc,46764
|
7
|
-
pyrekordbox/utils.py,sha256=
|
7
|
+
pyrekordbox/utils.py,sha256=JGFvmXY7P_Kn1-yvSvWPILpaEVBe_gkNtDH_DWsbIK0,5133
|
8
8
|
pyrekordbox/anlz/__init__.py,sha256=1sArQ6P-bxBJFdcnzW4CAN20pNl3YbmDvm-8pQizTZY,3308
|
9
|
-
pyrekordbox/anlz/file.py,sha256=
|
9
|
+
pyrekordbox/anlz/file.py,sha256=oUYuAjFj7hr1Y0EoAyNsLfk0W4ryqdkdzEEKs25nPF4,7898
|
10
10
|
pyrekordbox/anlz/structs.py,sha256=Lt4fkb3SAE8w146eWeWGnpgRoP6jhLMWrSMoMwPjG04,7925
|
11
11
|
pyrekordbox/anlz/tags.py,sha256=rlbUAyLc-J5_l4g4xiw59Rl3dW-SClDEb7ZLRXu1xLg,16791
|
12
12
|
pyrekordbox/db6/__init__.py,sha256=TZX_BPGZIkc4zSTULIc8yd_bf91MAezGtZevKNh3kZ0,856
|
13
13
|
pyrekordbox/db6/aux_files.py,sha256=kKRTj2UWJdHx043MX3-yK5HUWrnxjDNZYBDOHp5Oi14,8726
|
14
|
-
pyrekordbox/db6/database.py,sha256=
|
14
|
+
pyrekordbox/db6/database.py,sha256=CXqhCx4dmyG9yI_QhuPp1tV-Tefmw4UGQ0lek7jsTdk,86111
|
15
15
|
pyrekordbox/db6/registry.py,sha256=a7qpCdvoM9EzL8iQIq4WeYDcwgdhMZtM17xOo3uXftk,10617
|
16
16
|
pyrekordbox/db6/smartlist.py,sha256=ukQpKmA06iWAfdzS81Vb0g0qaTVnXQ7pgKa28KcMkiY,12300
|
17
|
-
pyrekordbox/db6/tables.py,sha256=
|
17
|
+
pyrekordbox/db6/tables.py,sha256=vKjFsAE49JHnc7470z5T9DJMxBGaIGULgzDNcw2h1UE,69268
|
18
18
|
pyrekordbox/mysettings/__init__.py,sha256=czSTZpv8pHYJGbqsNrwxvmpt2zHV1LPEF6f2aVoqpB8,795
|
19
19
|
pyrekordbox/mysettings/file.py,sha256=yAPQmkT5eTRyhJJ-7hKLmyw1EzQxrkFueFBmu3jGJSY,13181
|
20
20
|
pyrekordbox/mysettings/structs.py,sha256=5Y1F3qTmsP1fRB39_BEHpQVxKx2DO9BytEuJUG_RNcY,8472
|
21
|
-
pyrekordbox-0.4.
|
22
|
-
pyrekordbox-0.4.
|
23
|
-
pyrekordbox-0.4.
|
24
|
-
pyrekordbox-0.4.
|
25
|
-
pyrekordbox-0.4.
|
21
|
+
pyrekordbox-0.4.4.dist-info/licenses/LICENSE,sha256=JfWrNYI7JQpFyPTnZJmttDJqrJLIJKN9qVARn9m9Hyk,1074
|
22
|
+
pyrekordbox-0.4.4.dist-info/METADATA,sha256=HYouMIPHbHZEopQN9LgNNIhP12MuBDpH0nzznJ98vaE,14555
|
23
|
+
pyrekordbox-0.4.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
24
|
+
pyrekordbox-0.4.4.dist-info/top_level.txt,sha256=bUHkyxIHZDgSB6zhYnF1o4Yf1EQlTGGIkVRq9uEtsa4,12
|
25
|
+
pyrekordbox-0.4.4.dist-info/RECORD,,
|
File without changes
|