pyrekordbox 0.4.2__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/__init__.py +1 -0
- pyrekordbox/__main__.py +1 -51
- pyrekordbox/_version.py +16 -3
- pyrekordbox/anlz/__init__.py +6 -6
- pyrekordbox/anlz/file.py +56 -43
- pyrekordbox/anlz/tags.py +108 -70
- pyrekordbox/config.py +18 -356
- pyrekordbox/db6/aux_files.py +40 -14
- pyrekordbox/db6/database.py +384 -236
- pyrekordbox/db6/registry.py +48 -34
- pyrekordbox/db6/smartlist.py +12 -12
- pyrekordbox/db6/tables.py +60 -58
- pyrekordbox/mysettings/__init__.py +3 -2
- pyrekordbox/mysettings/file.py +27 -24
- pyrekordbox/rbxml.py +321 -142
- pyrekordbox/utils.py +27 -6
- {pyrekordbox-0.4.2.dist-info → pyrekordbox-0.4.4.dist-info}/METADATA +13 -38
- pyrekordbox-0.4.4.dist-info/RECORD +25 -0
- {pyrekordbox-0.4.2.dist-info → pyrekordbox-0.4.4.dist-info}/WHEEL +1 -1
- {pyrekordbox-0.4.2.dist-info → pyrekordbox-0.4.4.dist-info}/licenses/LICENSE +1 -1
- pyrekordbox-0.4.2.dist-info/RECORD +0 -25
- {pyrekordbox-0.4.2.dist-info → pyrekordbox-0.4.4.dist-info}/top_level.txt +0 -0
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
|
-
from typing import Union
|
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
|
|
@@ -151,7 +116,7 @@ def get_pioneer_app_dir(path: Union[str, Path] = None) -> Path: # pragma: no co
|
|
151
116
|
return path
|
152
117
|
|
153
118
|
|
154
|
-
def _convert_type(s):
|
119
|
+
def _convert_type(s: str) -> Union[str, int, float, List[int], List[float]]:
|
155
120
|
# Try to parse as int, float, list of int, list of float
|
156
121
|
types_ = int, float
|
157
122
|
for type_ in types_:
|
@@ -167,7 +132,7 @@ def _convert_type(s):
|
|
167
132
|
return s
|
168
133
|
|
169
134
|
|
170
|
-
def read_rekordbox_settings(rekordbox_app_dir: Union[str, Path]) ->
|
135
|
+
def read_rekordbox_settings(rekordbox_app_dir: Union[str, Path]) -> Dict[str, Any]:
|
171
136
|
"""Finds and parses the 'rekordbox3.settings' file in the Rekordbox 5 or 6 app-dir.
|
172
137
|
|
173
138
|
The settings file usually is called 'rekordbox3.settings' and is
|
@@ -197,12 +162,12 @@ def read_rekordbox_settings(rekordbox_app_dir: Union[str, Path]) -> dict:
|
|
197
162
|
val = _convert_type(element.attrib["val"])
|
198
163
|
except KeyError:
|
199
164
|
device_setup = element.find("DEVICESETUP")
|
200
|
-
val = {k: _convert_type(v) for k, v in device_setup.attrib.items()}
|
165
|
+
val = {k: _convert_type(v) for k, v in device_setup.attrib.items()} # type: ignore
|
201
166
|
settings[name] = val
|
202
167
|
return settings
|
203
168
|
|
204
169
|
|
205
|
-
def read_rekordbox6_options(pioneer_app_dir: Union[str, Path]) ->
|
170
|
+
def read_rekordbox6_options(pioneer_app_dir: Union[str, Path]) -> Dict[str, Any]:
|
206
171
|
"""Finds and parses the Rekordbox 6 `options.json` file with additional settings.
|
207
172
|
|
208
173
|
The options file contains additional settings used by Rekordbox 6, for example the
|
@@ -231,47 +196,7 @@ def read_rekordbox6_options(pioneer_app_dir: Union[str, Path]) -> dict:
|
|
231
196
|
return options
|
232
197
|
|
233
198
|
|
234
|
-
def
|
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
|
-
def _extract_version(name, major_version):
|
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()
|
277
202
|
if not ver_str:
|
@@ -284,7 +209,7 @@ def _get_rb_config(
|
|
284
209
|
pioneer_app_dir: Path,
|
285
210
|
major_version: int,
|
286
211
|
application_dirname: str = "",
|
287
|
-
) ->
|
212
|
+
) -> Dict[str, Any]:
|
288
213
|
"""Get the program configuration for a given Rekordbox major version.
|
289
214
|
|
290
215
|
Parameters
|
@@ -378,207 +303,18 @@ def _get_rb_config(
|
|
378
303
|
return conf
|
379
304
|
|
380
305
|
|
381
|
-
def _get_rb5_config(
|
306
|
+
def _get_rb5_config(
|
307
|
+
pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str = ""
|
308
|
+
) -> Dict[str, Any]:
|
382
309
|
"""Get the program configuration for Rekordbox v5.x.x."""
|
383
310
|
major_version = 5
|
384
311
|
conf = _get_rb_config(pioneer_prog_dir, pioneer_app_dir, major_version, dirname)
|
385
312
|
return conf
|
386
313
|
|
387
314
|
|
388
|
-
def
|
389
|
-
|
390
|
-
|
391
|
-
match_result = re.search('pass: ".(.*?)"', asar_data)
|
392
|
-
if match_result is None:
|
393
|
-
raise RuntimeError("Could not read `app.asar` file.")
|
394
|
-
match = match_result.group(0)
|
395
|
-
pw = match.replace("pass: ", "").strip('"')
|
396
|
-
return pw
|
397
|
-
|
398
|
-
|
399
|
-
class KeyExtractor:
|
400
|
-
"""Extracts the Rekordbox database key using code injection.
|
401
|
-
|
402
|
-
This method works by injecting code into the Rekordbox process and intercepting the
|
403
|
-
call to unlock the database. Works for any Rekordbox version.
|
404
|
-
"""
|
405
|
-
|
406
|
-
# fmt: off
|
407
|
-
SCRIPT = textwrap.dedent("""
|
408
|
-
var sqlite3_key = Module.findExportByName(null, 'sqlite3_key');
|
409
|
-
|
410
|
-
Interceptor.attach(sqlite3_key, {
|
411
|
-
onEnter: function(args) {
|
412
|
-
var size = args[2].toInt32();
|
413
|
-
var key = args[1].readUtf8String(size);
|
414
|
-
send('sqlite3_key: ' + key);
|
415
|
-
}
|
416
|
-
});
|
417
|
-
""")
|
418
|
-
# fmt: on
|
419
|
-
|
420
|
-
def __init__(self, rekordbox_executable):
|
421
|
-
self.executable = str(rekordbox_executable)
|
422
|
-
self.key = ""
|
423
|
-
|
424
|
-
def on_message(self, message, data):
|
425
|
-
payload = message["payload"]
|
426
|
-
if payload.startswith("sqlite3_key"):
|
427
|
-
self.key = payload.split(": ")[1]
|
428
|
-
|
429
|
-
def run(self):
|
430
|
-
pid = get_rekordbox_pid()
|
431
|
-
if pid:
|
432
|
-
raise RuntimeError(
|
433
|
-
"Rekordbox is running. Please close Rekordbox before running the `KeyExtractor`."
|
434
|
-
)
|
435
|
-
# Spawn Rekordbox process and attach to it
|
436
|
-
pid = frida.spawn(self.executable)
|
437
|
-
frida.resume(pid)
|
438
|
-
session = frida.attach(pid)
|
439
|
-
script = session.create_script(self.SCRIPT)
|
440
|
-
script.on("message", self.on_message)
|
441
|
-
script.load()
|
442
|
-
# Wait for key to be extracted
|
443
|
-
while not self.key:
|
444
|
-
time.sleep(0.1)
|
445
|
-
# Kill Rekordbox process
|
446
|
-
frida.kill(pid)
|
447
|
-
|
448
|
-
return self.key
|
449
|
-
|
450
|
-
|
451
|
-
def write_db6_key_cache(key: str) -> None: # pragma: no cover
|
452
|
-
r"""Writes the decrypted Rekordbox6 database key to the cache file.
|
453
|
-
|
454
|
-
This method can also be used to manually cache the database key, provided
|
455
|
-
the user has found the key somewhere else. The key can be, for example,
|
456
|
-
found in some other projects that hard-coded it.
|
457
|
-
|
458
|
-
The cache file is stored in the application data directory of pyrekordbox:
|
459
|
-
Windows: `C:\Users\<user>\AppData\Roaming\pyrekordbox`
|
460
|
-
macOS: `~/Library/Application Support/pyrekordbox`
|
461
|
-
|
462
|
-
Parameters
|
463
|
-
----------
|
464
|
-
key : str
|
465
|
-
The decrypted database key. To make sure the key is valid, the first
|
466
|
-
five characters are checked before writing the key to the cache file.
|
467
|
-
The key should start with '402fd'.
|
468
|
-
|
469
|
-
Examples
|
470
|
-
--------
|
471
|
-
>>> from pyrekordbox.config import write_db6_key_cache
|
472
|
-
>>> from pyrekordbox import Rekordbox6Database
|
473
|
-
>>> write_db6_key_cache("402fd...")
|
474
|
-
>>> db = Rekordbox6Database() # The db can now be opened without providing the key
|
475
|
-
"""
|
476
|
-
# Check if the key looks like a valid key
|
477
|
-
if not key.startswith("402fd"):
|
478
|
-
raise ValueError("The provided database key doesn't look valid!")
|
479
|
-
lines = list()
|
480
|
-
lines.append(f"version: {_cache_file_version}")
|
481
|
-
lines.append("dp: " + key)
|
482
|
-
text = "\n".join(lines)
|
483
|
-
|
484
|
-
cache_file = get_cache_file()
|
485
|
-
if not cache_file.parent.exists():
|
486
|
-
cache_file.parent.mkdir()
|
487
|
-
|
488
|
-
with open(cache_file, "w") as fh:
|
489
|
-
fh.write(text)
|
490
|
-
# Set the config key to make sure the key is present after calling method
|
491
|
-
if __config__["rekordbox6"]:
|
492
|
-
__config__["rekordbox6"]["dp"] = key
|
493
|
-
if __config__["rekordbox7"]:
|
494
|
-
__config__["rekordbox7"]["dp"] = key
|
495
|
-
|
496
|
-
|
497
|
-
def _update_sqlite_key(opts, conf):
|
498
|
-
cache_version = 0
|
499
|
-
pw, dp = "", ""
|
500
|
-
|
501
|
-
cache_file = get_cache_file()
|
502
|
-
|
503
|
-
if cache_file.exists(): # pragma: no cover
|
504
|
-
logger.debug("Found cache file %s", cache_file)
|
505
|
-
# Read cache file
|
506
|
-
with open(cache_file, "r") as fh:
|
507
|
-
text = fh.read()
|
508
|
-
lines = text.splitlines()
|
509
|
-
if lines[0].startswith("version:"):
|
510
|
-
cache_version = int(lines[0].split(":")[1].strip())
|
511
|
-
else:
|
512
|
-
cache_version = 1
|
513
|
-
if cache_version == 1:
|
514
|
-
# Cache file introduced in pyrekordbox 0.1.6 contains only the password
|
515
|
-
pw = lines[0]
|
516
|
-
logger.debug("Found pw in cache file")
|
517
|
-
elif cache_version == 2:
|
518
|
-
# Cache file introduced in pyrekordbox 0.1.7 contains version and db key
|
519
|
-
dp = lines[1].split(":")[1].strip()
|
520
|
-
logger.debug("Found dp in cache file")
|
521
|
-
else:
|
522
|
-
raise ValueError(f"Invalid cache version: {cache_version}")
|
523
|
-
else:
|
524
|
-
logger.debug("No cache file found")
|
525
|
-
|
526
|
-
if cache_version < _cache_file_version: # pragma: no cover
|
527
|
-
# Update cache file
|
528
|
-
if not pw:
|
529
|
-
logger.debug("Extracting pw")
|
530
|
-
try:
|
531
|
-
pw = _extract_pw(conf["install_dir"])
|
532
|
-
logger.debug("Extracted pw from 'app.asar'")
|
533
|
-
except (FileNotFoundError, RuntimeError):
|
534
|
-
logger.debug("Could not extract pw from 'app.asar'")
|
535
|
-
pw = ""
|
536
|
-
|
537
|
-
if not dp:
|
538
|
-
if pw:
|
539
|
-
cipher = blowfish.Cipher(pw.encode())
|
540
|
-
dp = base64.standard_b64decode(opts["dp"])
|
541
|
-
dp = b"".join(cipher.decrypt_ecb(dp)).decode()
|
542
|
-
logger.debug("Unlocked dp from pw: %s", dp)
|
543
|
-
else:
|
544
|
-
if sys.platform == "win32":
|
545
|
-
executable = conf["install_dir"] / "rekordbox.exe"
|
546
|
-
elif sys.platform == "darwin":
|
547
|
-
install_dir = conf["install_dir"]
|
548
|
-
if not str(install_dir).endswith(".app"):
|
549
|
-
install_dir = install_dir / "rekordbox.app"
|
550
|
-
executable = install_dir / "Contents" / "MacOS" / "rekordbox"
|
551
|
-
else:
|
552
|
-
# Linux: not supported
|
553
|
-
logger.warning(f"OS {sys.platform} not supported!")
|
554
|
-
executable = Path("")
|
555
|
-
|
556
|
-
if not executable.exists():
|
557
|
-
logger.warning(f"Could not find Rekordbox executable: {executable}")
|
558
|
-
else:
|
559
|
-
extractor = KeyExtractor(executable)
|
560
|
-
try:
|
561
|
-
dp = extractor.run()
|
562
|
-
logger.debug("Extracted dp from Rekordbox process")
|
563
|
-
except Exception as e:
|
564
|
-
logger.info(f"`KeyExtractor` failed: {e}")
|
565
|
-
|
566
|
-
if dp:
|
567
|
-
logger.debug("Writing dp to cache file")
|
568
|
-
write_db6_key_cache(dp)
|
569
|
-
else:
|
570
|
-
logging.warning(
|
571
|
-
"Could not retrieve db-key or read it from cache file,"
|
572
|
-
"use the CLI to download it from external sources: "
|
573
|
-
"`python -m pyrekordbox download-key`"
|
574
|
-
)
|
575
|
-
|
576
|
-
# Add database key to config if found
|
577
|
-
if dp:
|
578
|
-
conf["dp"] = dp
|
579
|
-
|
580
|
-
|
581
|
-
def _get_rb6_config(pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str = "") -> dict:
|
315
|
+
def _get_rb6_config(
|
316
|
+
pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str = ""
|
317
|
+
) -> Dict[str, Any]:
|
582
318
|
"""Get the program configuration for Rekordbox v6.x.x."""
|
583
319
|
major_version = 6
|
584
320
|
conf = _get_rb_config(pioneer_prog_dir, pioneer_app_dir, major_version, dirname)
|
@@ -590,13 +326,12 @@ def _get_rb6_config(pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str
|
|
590
326
|
assert str(conf["db_dir"]) == str(db_dir)
|
591
327
|
assert str(conf["db_path"]) == str(db_path)
|
592
328
|
|
593
|
-
# Update SQLite key
|
594
|
-
_update_sqlite_key(opts, conf)
|
595
|
-
|
596
329
|
return conf
|
597
330
|
|
598
331
|
|
599
|
-
def _get_rb7_config(
|
332
|
+
def _get_rb7_config(
|
333
|
+
pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str = ""
|
334
|
+
) -> Dict[str, Any]:
|
600
335
|
"""Get the program configuration for Rekordbox v7.x.x."""
|
601
336
|
major_version = 7
|
602
337
|
conf = _get_rb_config(pioneer_prog_dir, pioneer_app_dir, major_version, dirname)
|
@@ -608,76 +343,16 @@ def _get_rb7_config(pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str
|
|
608
343
|
assert str(conf["db_dir"]) == str(db_dir)
|
609
344
|
assert str(conf["db_path"]) == str(db_path)
|
610
345
|
|
611
|
-
# Update SQLite key
|
612
|
-
_update_sqlite_key(opts, conf)
|
613
|
-
|
614
346
|
return conf
|
615
347
|
|
616
348
|
|
617
|
-
# noinspection PyPackageRequirements,PyUnresolvedReferences
|
618
|
-
def _read_config_file(path: str) -> dict:
|
619
|
-
path = Path(path)
|
620
|
-
if not path.exists():
|
621
|
-
raise FileNotFoundError(f"No such file or directory: '{path}'")
|
622
|
-
|
623
|
-
ext = path.suffix.lower()
|
624
|
-
if ext == ".cfg":
|
625
|
-
from configparser import ConfigParser
|
626
|
-
|
627
|
-
parser = ConfigParser()
|
628
|
-
parser.read(path)
|
629
|
-
return parser
|
630
|
-
elif ext == ".toml":
|
631
|
-
import toml
|
632
|
-
|
633
|
-
return toml.load(path)
|
634
|
-
elif ext in (".yaml", ".yml"):
|
635
|
-
import yaml
|
636
|
-
|
637
|
-
with open(path, "r") as stream:
|
638
|
-
return yaml.safe_load(stream)
|
639
|
-
return dict()
|
640
|
-
|
641
|
-
|
642
|
-
def read_pyrekordbox_configuration():
|
643
|
-
"""Reads the pyrekordbox configuration.
|
644
|
-
|
645
|
-
So far only the `pioneer-install-dir` and `pioneer-app-dir` fileds in the
|
646
|
-
`rekordbox` section are supported. Supported configuration files are
|
647
|
-
pyproject.toml, setup.cfg, rekordbox.toml, rekordbox.cfg and rekordbox.yml.
|
648
|
-
The files are searched for in that order.
|
649
|
-
|
650
|
-
Returns
|
651
|
-
-------
|
652
|
-
pyrekordbox_config : dict
|
653
|
-
The pyrekordbox configuration data, if found.
|
654
|
-
"""
|
655
|
-
files = ["pyproject.toml", "setup.cfg"]
|
656
|
-
for ext in [".toml", ".cfg", ".yaml", ".yml"]:
|
657
|
-
files.append("pyrekordbox" + ext)
|
658
|
-
|
659
|
-
for file in files:
|
660
|
-
try:
|
661
|
-
data = _read_config_file(file)
|
662
|
-
config = dict(data["rekordbox"])
|
663
|
-
logger.debug("Read configuration from '%s'", file)
|
664
|
-
except (ImportError, FileNotFoundError) as e:
|
665
|
-
logger.debug("Could not read config file '%s': %s", file, e)
|
666
|
-
except KeyError:
|
667
|
-
pass
|
668
|
-
else:
|
669
|
-
if config:
|
670
|
-
return config
|
671
|
-
return dict()
|
672
|
-
|
673
|
-
|
674
349
|
def update_config(
|
675
350
|
pioneer_install_dir: Union[str, Path] = None,
|
676
351
|
pioneer_app_dir: Union[str, Path] = None,
|
677
352
|
rb5_install_dirname: str = "",
|
678
353
|
rb6_install_dirname: str = "",
|
679
354
|
rb7_install_dirname: str = "",
|
680
|
-
):
|
355
|
+
) -> None:
|
681
356
|
"""Update the pyrekordbox configuration.
|
682
357
|
|
683
358
|
This method scans the system for the Rekordbox installation and application data
|
@@ -707,19 +382,6 @@ def update_config(
|
|
707
382
|
The name of the Rekordbox 7 installation directory. By default, the normal
|
708
383
|
directory name is used (Windows: 'rekordbox 7.x.x', macOS: 'rekordbox 7.app').
|
709
384
|
"""
|
710
|
-
# Read config file
|
711
|
-
conf = read_pyrekordbox_configuration()
|
712
|
-
if pioneer_install_dir is None and "pioneer-install-dir" in conf:
|
713
|
-
pioneer_install_dir = conf["pioneer-install-dir"]
|
714
|
-
if pioneer_app_dir is None and "pioneer-app-dir" in conf:
|
715
|
-
pioneer_app_dir = conf["pioneer-app-dir"]
|
716
|
-
if not rb5_install_dirname and "rekordbox5-install-dirname" in conf:
|
717
|
-
rb5_install_dirname = conf["rekordbox5-install-dirname"]
|
718
|
-
if not rb6_install_dirname and "rekordbox6-install-dirname" in conf:
|
719
|
-
rb6_install_dirname = conf["rekordbox6-install-dirname"]
|
720
|
-
if not rb7_install_dirname and "rekordbox7-install-dirname" in conf:
|
721
|
-
rb7_install_dirname = conf["rekordbox7-install-dirname"]
|
722
|
-
|
723
385
|
# Pioneer installation directory
|
724
386
|
try:
|
725
387
|
pioneer_install_dir = get_pioneer_install_dir(pioneer_install_dir)
|
@@ -758,7 +420,7 @@ def update_config(
|
|
758
420
|
logger.info(e)
|
759
421
|
|
760
422
|
|
761
|
-
def get_config(section: str, key: str = None):
|
423
|
+
def get_config(section: str, key: str = None) -> Any:
|
762
424
|
"""Gets a section or value of the pyrekordbox configuration.
|
763
425
|
|
764
426
|
Parameters
|
pyrekordbox/db6/aux_files.py
CHANGED
@@ -5,10 +5,20 @@
|
|
5
5
|
import xml.etree.cElementTree as xml
|
6
6
|
from datetime import datetime
|
7
7
|
from pathlib import Path
|
8
|
+
from typing import Any, Dict, List, Optional, Union
|
8
9
|
|
9
10
|
from ..config import get_config
|
10
11
|
from ..utils import pretty_xml
|
11
12
|
|
13
|
+
Attribs = Dict[str, Any]
|
14
|
+
|
15
|
+
|
16
|
+
class XmlElementNotInitializedError(Exception):
|
17
|
+
"""Raised when an XML element is not initialized."""
|
18
|
+
|
19
|
+
def __init__(self, name: str) -> None:
|
20
|
+
super().__init__(f"XML element {name} is not initialized!")
|
21
|
+
|
12
22
|
|
13
23
|
class MasterPlaylistXml:
|
14
24
|
"""Rekordbox v6 masterPlaylists6.xml file handler.
|
@@ -26,7 +36,7 @@ class MasterPlaylistXml:
|
|
26
36
|
|
27
37
|
KEYS = ["Id", "ParentId", "Attributes", "Timestamp", "Lib_Type", "CheckType"]
|
28
38
|
|
29
|
-
def __init__(self, path=None, db_dir=None):
|
39
|
+
def __init__(self, path: Union[str, Path] = None, db_dir: Union[str, Path] = None):
|
30
40
|
if path is None:
|
31
41
|
if db_dir is None:
|
32
42
|
db_dir = get_config("rekordbox6", "db_dir")
|
@@ -40,29 +50,33 @@ class MasterPlaylistXml:
|
|
40
50
|
self._changed = False
|
41
51
|
|
42
52
|
@property
|
43
|
-
def version(self):
|
53
|
+
def version(self) -> str:
|
44
54
|
return self.root.attrib["Version"]
|
45
55
|
|
46
56
|
@property
|
47
|
-
def automatic_sync(self):
|
57
|
+
def automatic_sync(self) -> str:
|
48
58
|
return self.root.attrib["AutomaticSync"]
|
49
59
|
|
50
60
|
@property
|
51
|
-
def rekordbox_version(self):
|
61
|
+
def rekordbox_version(self) -> str:
|
62
|
+
if self.product is None:
|
63
|
+
raise XmlElementNotInitializedError("product")
|
52
64
|
return self.product.attrib["Version"]
|
53
65
|
|
54
66
|
@property
|
55
|
-
def modified(self):
|
67
|
+
def modified(self) -> bool:
|
56
68
|
return self._changed
|
57
69
|
|
58
|
-
def get_playlists(self):
|
70
|
+
def get_playlists(self) -> List[Dict[str, Any]]:
|
59
71
|
"""Returns a list of the attributes of all playlist elements."""
|
72
|
+
if self.playlists is None:
|
73
|
+
raise XmlElementNotInitializedError("playlists")
|
60
74
|
items = list()
|
61
75
|
for playlist in self.playlists:
|
62
76
|
items.append(playlist.attrib)
|
63
77
|
return items
|
64
78
|
|
65
|
-
def get(self, playlist_id):
|
79
|
+
def get(self, playlist_id: Union[str, int]) -> Optional[Attribs]:
|
66
80
|
"""Returns element attribs with the PlaylistID used in the `master.db` database.
|
67
81
|
|
68
82
|
Parameters
|
@@ -75,11 +89,13 @@ class MasterPlaylistXml:
|
|
75
89
|
-------
|
76
90
|
playlist : dict
|
77
91
|
"""
|
92
|
+
if self.playlists is None:
|
93
|
+
raise XmlElementNotInitializedError("playlists")
|
78
94
|
hex_id = f"{int(playlist_id):X}"
|
79
95
|
element = self.playlists.find(f'.//NODE[@Id="{hex_id}"]')
|
80
96
|
if element is None:
|
81
97
|
return None
|
82
|
-
attribs = dict(element.attrib)
|
98
|
+
attribs: Attribs = dict(element.attrib)
|
83
99
|
attribs["Attribute"] = int(attribs["Attribute"])
|
84
100
|
attribs["Timestamp"] = datetime.fromtimestamp(int(attribs["Timestamp"]) / 1000)
|
85
101
|
attribs["Lib_Type"] = int(attribs["Lib_Type"])
|
@@ -94,7 +110,7 @@ class MasterPlaylistXml:
|
|
94
110
|
updated_at: datetime,
|
95
111
|
lib_type: int = 0,
|
96
112
|
check_type: int = 0,
|
97
|
-
):
|
113
|
+
) -> xml.Element:
|
98
114
|
"""Adds a new element with the PlaylistID used in the `master.db` database.
|
99
115
|
|
100
116
|
Parameters
|
@@ -119,6 +135,9 @@ class MasterPlaylistXml:
|
|
119
135
|
element : xml.Element
|
120
136
|
The newly created element.
|
121
137
|
"""
|
138
|
+
if self.playlists is None:
|
139
|
+
raise XmlElementNotInitializedError("playlists")
|
140
|
+
|
122
141
|
hex_id = f"{int(playlist_id):X}"
|
123
142
|
parent_id = f"{int(parent_id):X}" if parent_id != "root" else "0"
|
124
143
|
timestamp = int(updated_at.timestamp() * 1000)
|
@@ -136,7 +155,7 @@ class MasterPlaylistXml:
|
|
136
155
|
self._changed = True
|
137
156
|
return element
|
138
157
|
|
139
|
-
def remove(self, playlist_id):
|
158
|
+
def remove(self, playlist_id: Union[str, int]) -> None:
|
140
159
|
"""Removes the element with the PlaylistID used in the `master.db` database.
|
141
160
|
|
142
161
|
Parameters
|
@@ -145,6 +164,9 @@ class MasterPlaylistXml:
|
|
145
164
|
The playlist ID used in the main `master.db` database. This id is converted
|
146
165
|
to hexadecimal format before searching.
|
147
166
|
"""
|
167
|
+
if self.playlists is None:
|
168
|
+
raise XmlElementNotInitializedError("playlists")
|
169
|
+
|
148
170
|
hex_id = f"{int(playlist_id):X}"
|
149
171
|
element = self.playlists.find(f'.//NODE[@Id="{hex_id}"]')
|
150
172
|
if element is None:
|
@@ -160,7 +182,7 @@ class MasterPlaylistXml:
|
|
160
182
|
updated_at: datetime = None,
|
161
183
|
lib_type: int = None,
|
162
184
|
check_type: int = None,
|
163
|
-
):
|
185
|
+
) -> None:
|
164
186
|
"""Updates the element with the PlaylistID used in the `master.db` database.
|
165
187
|
|
166
188
|
Parameters
|
@@ -180,6 +202,9 @@ class MasterPlaylistXml:
|
|
180
202
|
check_type : int, optional
|
181
203
|
The check type. It seems to be always 0.
|
182
204
|
"""
|
205
|
+
if self.playlists is None:
|
206
|
+
raise XmlElementNotInitializedError("playlists")
|
207
|
+
|
183
208
|
hex_id = f"{int(playlist_id):X}"
|
184
209
|
element = self.playlists.find(f'.//NODE[@Id="{hex_id}"]')
|
185
210
|
if element is None:
|
@@ -200,10 +225,11 @@ class MasterPlaylistXml:
|
|
200
225
|
element.attrib.update(attribs)
|
201
226
|
self._changed = True
|
202
227
|
|
203
|
-
def to_string(self, indent=None):
|
204
|
-
|
228
|
+
def to_string(self, indent: str = None) -> str:
|
229
|
+
text: str = pretty_xml(self.root, indent, encoding="utf-8")
|
230
|
+
return text
|
205
231
|
|
206
|
-
def save(self, path=None, indent=None):
|
232
|
+
def save(self, path: Union[str, Path] = None, indent: str = None) -> None:
|
207
233
|
if path is None:
|
208
234
|
path = self.path
|
209
235
|
path = str(path)
|