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 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 == "download-key":
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__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
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.3'
21
- __version_tuple__ = version_tuple = (0, 4, 3)
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
- bank = Int16ub.parse(tag_data[28:30])
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
- # deobfuscate tag_data[18:] using xor with XOR_MASK+len_entries
127
- len_entries = Int16ub.parse(tag_data[16:])
128
- tag_data = bytearray(data[i : i + len(tag_data)])
129
- for x in range(len(tag_data[18:])):
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
- tag_data[x + 18] ^= mask
134
- tag_data = bytes(tag_data)
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
- len_tag = tag.struct.len_tag
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
- tag_struct = structs.AnlzTag.parse(tag_data)
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)
@@ -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
- try:
148
- key = rb_config["dp"]
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
- if not key.startswith("402fd"):
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
- # Assume the format
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
- assert re.match(r"^[+-]?\d{1,2}:\d{2}", tzinfo)
140
- datestr = datestr.strip() + tzinfo
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, tzinfo = value, ""
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
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-2024, Dylan Jones
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 :: 3 - Alpha
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
- Tested Rekordbox versions: ``5.8.6 | 6.7.7 | 7.0.9``
88
+ Check the [changelog][CHANGELOG] for recent changes!
91
89
 
92
- > [!WARNING]
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
+ [![BuyMeACoffee](https://raw.githubusercontent.com/pachadotdev/buymeacoffee-badges/main/bmc-white.svg)](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=ogn1wEOue1RUjGA0BxmgVIphcSCkoM1jZKRND0cAVLA,6016
3
- pyrekordbox/_version.py,sha256=zJwW9_MgFPmVYNh3YnSsdJ4y2EqGvu1bzYeID1Rrd0A,511
4
- pyrekordbox/config.py,sha256=q9QTB1T0eMUsUwpo6ro_CcTcSQ1AUhpCFiJhMCaKnQc,28770
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=h0z2LZ2cI3AlBE9hO-gSXIzOMjIJhYh_5HRlxao61Fg,4500
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=Att_2FKK2AiCpV991EyF1DQVRwlGyxfL0YuePAi-qyo,7842
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=IhYyFixGBX-1MfhlF-HYWozNsUydNGzafMTtio9A5wA,86584
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=75SZyWbneArIVYZKAEjVTltm0oKePM5Af0PaMOtAW-8,69140
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.3.dist-info/licenses/LICENSE,sha256=VwG9ZgC2UZnI0gTezGz1qkcAZ7sknBUQ1M62Z2nht54,1074
22
- pyrekordbox-0.4.3.dist-info/METADATA,sha256=4i4gsSTfTrxxowkb5gNgewVrlC6AXCNdFaKCD_mOguw,15480
23
- pyrekordbox-0.4.3.dist-info/WHEEL,sha256=DnLRTWE75wApRYVsjgc6wsVswC54sMSJhAEd4xhDpBk,91
24
- pyrekordbox-0.4.3.dist-info/top_level.txt,sha256=bUHkyxIHZDgSB6zhYnF1o4Yf1EQlTGGIkVRq9uEtsa4,12
25
- pyrekordbox-0.4.3.dist-info/RECORD,,
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.4.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2022-2024, Dylan Jones
3
+ Copyright (c) 2022-2025, Dylan Jones
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal