pyrekordbox 0.4.0__py3-none-any.whl → 0.4.2__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
@@ -9,7 +9,7 @@ import sys
9
9
  import urllib.request
10
10
  from pathlib import Path
11
11
 
12
- from pyrekordbox.config import _cache_file, write_db6_key_cache
12
+ from pyrekordbox.config import get_cache_file, write_db6_key_cache
13
13
 
14
14
  KEY_SOURCES = [
15
15
  {
@@ -144,7 +144,8 @@ def download_db6_key():
144
144
  dp = match.group("dp")
145
145
  break
146
146
  if dp:
147
- print(f"Found key, updating cache file {_cache_file}")
147
+ cache_file = get_cache_file()
148
+ print(f"Found key, updating cache file {cache_file}")
148
149
  write_db6_key_cache(dp)
149
150
  else:
150
151
  print("No key found in the online sources.")
pyrekordbox/_version.py CHANGED
@@ -1,8 +1,13 @@
1
- # file generated by setuptools_scm
1
+ # file generated by setuptools-scm
2
2
  # don't change, don't track in version control
3
+
4
+ __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
5
+
3
6
  TYPE_CHECKING = False
4
7
  if TYPE_CHECKING:
5
- from typing import Tuple, Union
8
+ from typing import Tuple
9
+ from typing import Union
10
+
6
11
  VERSION_TUPLE = Tuple[Union[int, str], ...]
7
12
  else:
8
13
  VERSION_TUPLE = object
@@ -12,5 +17,5 @@ __version__: str
12
17
  __version_tuple__: VERSION_TUPLE
13
18
  version_tuple: VERSION_TUPLE
14
19
 
15
- __version__ = version = '0.4.0'
16
- __version_tuple__ = version_tuple = (0, 4, 0)
20
+ __version__ = version = '0.4.2'
21
+ __version_tuple__ = version_tuple = (0, 4, 2)
pyrekordbox/anlz/tags.py CHANGED
@@ -162,13 +162,9 @@ class PQTZAnlzTag(AbstractAnlzTag):
162
162
  n_bpms = len(bpms)
163
163
  n_times = len(times)
164
164
  if n_bpms != n_beats:
165
- raise ValueError(
166
- f"Number of bpms not equal to number of beats: {n_bpms} != {n_beats}"
167
- )
165
+ raise ValueError(f"Number of bpms not equal to number of beats: {n_bpms} != {n_beats}")
168
166
  if n_times != n_beats:
169
- raise ValueError(
170
- f"Number of times not equal to number of beats: {n_bpms} != {n_times}"
171
- )
167
+ raise ValueError(f"Number of times not equal to number of beats: {n_bpms} != {n_times}")
172
168
 
173
169
  # For now only values of existing beats can be set
174
170
  if n_beats != n:
@@ -184,9 +180,7 @@ class PQTZAnlzTag(AbstractAnlzTag):
184
180
  n = len(self.content.entries)
185
181
  n_new = len(beats)
186
182
  if n_new != n:
187
- raise ValueError(
188
- f"Number of beats not equal to current content length: {n_new} != {n}"
189
- )
183
+ raise ValueError(f"Number of beats not equal to current content length: {n_new} != {n}")
190
184
 
191
185
  for i, beat in enumerate(beats):
192
186
  self.content.entries[i].beat = beat
@@ -195,9 +189,7 @@ class PQTZAnlzTag(AbstractAnlzTag):
195
189
  n = len(self.content.entries)
196
190
  n_new = len(bpms)
197
191
  if n_new != n:
198
- raise ValueError(
199
- f"Number of bpms not equal to current content length: {n_new} != {n}"
200
- )
192
+ raise ValueError(f"Number of bpms not equal to current content length: {n_new} != {n}")
201
193
 
202
194
  for i, bpm in enumerate(bpms):
203
195
  self.content.entries[i].tempo = int(bpm * 100)
@@ -206,9 +198,7 @@ class PQTZAnlzTag(AbstractAnlzTag):
206
198
  n = len(self.content.entries)
207
199
  n_new = len(times)
208
200
  if n_new != n:
209
- raise ValueError(
210
- f"Number of times not equal to current content length: {n_new} != {n}"
211
- )
201
+ raise ValueError(f"Number of times not equal to current content length: {n_new} != {n}")
212
202
 
213
203
  for i, t in enumerate(times):
214
204
  self.content.entries[i].time = int(1000 * t)
pyrekordbox/config.py CHANGED
@@ -30,7 +30,7 @@ logger = logging.getLogger(__name__)
30
30
 
31
31
  # Cache file for pyrekordbox data
32
32
  _cache_file_version = 2
33
- _cache_file = Path(__file__).parent / "rb.cache"
33
+ _cache_file_name = "rb.cache"
34
34
 
35
35
  # Define empty pyrekordbox configuration
36
36
  __config__ = {
@@ -48,6 +48,29 @@ class InvalidApplicationDirname(Exception):
48
48
  pass
49
49
 
50
50
 
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
+
51
74
  def get_pioneer_install_dir(path: Union[str, Path] = None) -> Path: # pragma: no cover
52
75
  """Returns the path of the Pioneer program installation directory.
53
76
 
@@ -234,12 +257,7 @@ def read_rekordbox6_asar(rb6_install_dir: Union[str, Path]) -> str:
234
257
  if not str(rb6_install_dir).endswith(".app"):
235
258
  rb6_install_dir = rb6_install_dir / "rekordbox.app"
236
259
  location = (
237
- rb6_install_dir
238
- / "Contents"
239
- / "MacOS"
240
- / "rekordboxAgent.app"
241
- / "Contents"
242
- / "Resources"
260
+ rb6_install_dir / "Contents" / "MacOS" / "rekordboxAgent.app" / "Contents" / "Resources"
243
261
  )
244
262
  encoding = "cp437"
245
263
  else:
@@ -249,7 +267,7 @@ def read_rekordbox6_asar(rb6_install_dir: Union[str, Path]) -> str:
249
267
  # Read asar file
250
268
  path = (location / "app.asar").absolute()
251
269
  with open(path, "rb") as fh:
252
- data = fh.read().decode(encoding)
270
+ data = fh.read().decode(encoding, errors="replace")
253
271
  return data
254
272
 
255
273
 
@@ -360,9 +378,7 @@ def _get_rb_config(
360
378
  return conf
361
379
 
362
380
 
363
- def _get_rb5_config(
364
- pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str = ""
365
- ) -> dict:
381
+ def _get_rb5_config(pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str = "") -> dict:
366
382
  """Get the program configuration for Rekordbox v5.x.x."""
367
383
  major_version = 5
368
384
  conf = _get_rb_config(pioneer_prog_dir, pioneer_app_dir, major_version, dirname)
@@ -414,8 +430,7 @@ class KeyExtractor:
414
430
  pid = get_rekordbox_pid()
415
431
  if pid:
416
432
  raise RuntimeError(
417
- "Rekordbox is running. "
418
- "Please close Rekordbox before running the `KeyExtractor`."
433
+ "Rekordbox is running. Please close Rekordbox before running the `KeyExtractor`."
419
434
  )
420
435
  # Spawn Rekordbox process and attach to it
421
436
  pid = frida.spawn(self.executable)
@@ -434,12 +449,16 @@ class KeyExtractor:
434
449
 
435
450
 
436
451
  def write_db6_key_cache(key: str) -> None: # pragma: no cover
437
- """Writes the decrypted Rekordbox6 database key to the cache file.
452
+ r"""Writes the decrypted Rekordbox6 database key to the cache file.
438
453
 
439
454
  This method can also be used to manually cache the database key, provided
440
455
  the user has found the key somewhere else. The key can be, for example,
441
456
  found in some other projects that hard-coded it.
442
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
+
443
462
  Parameters
444
463
  ----------
445
464
  key : str
@@ -461,7 +480,12 @@ def write_db6_key_cache(key: str) -> None: # pragma: no cover
461
480
  lines.append(f"version: {_cache_file_version}")
462
481
  lines.append("dp: " + key)
463
482
  text = "\n".join(lines)
464
- with open(_cache_file, "w") as fh:
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:
465
489
  fh.write(text)
466
490
  # Set the config key to make sure the key is present after calling method
467
491
  if __config__["rekordbox6"]:
@@ -473,10 +497,13 @@ def write_db6_key_cache(key: str) -> None: # pragma: no cover
473
497
  def _update_sqlite_key(opts, conf):
474
498
  cache_version = 0
475
499
  pw, dp = "", ""
476
- if _cache_file.exists(): # pragma: no cover
477
- logger.debug("Found cache file %s", _cache_file)
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)
478
505
  # Read cache file
479
- with open(_cache_file, "r") as fh:
506
+ with open(cache_file, "r") as fh:
480
507
  text = fh.read()
481
508
  lines = text.splitlines()
482
509
  if lines[0].startswith("version:"):
@@ -551,9 +578,7 @@ def _update_sqlite_key(opts, conf):
551
578
  conf["dp"] = dp
552
579
 
553
580
 
554
- def _get_rb6_config(
555
- pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str = ""
556
- ) -> dict:
581
+ def _get_rb6_config(pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str = "") -> dict:
557
582
  """Get the program configuration for Rekordbox v6.x.x."""
558
583
  major_version = 6
559
584
  conf = _get_rb_config(pioneer_prog_dir, pioneer_app_dir, major_version, dirname)
@@ -571,9 +596,7 @@ def _get_rb6_config(
571
596
  return conf
572
597
 
573
598
 
574
- def _get_rb7_config(
575
- pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str = ""
576
- ) -> dict:
599
+ def _get_rb7_config(pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str = "") -> dict:
577
600
  """Get the program configuration for Rekordbox v7.x.x."""
578
601
  major_version = 7
579
602
  conf = _get_rb_config(pioneer_prog_dir, pioneer_app_dir, major_version, dirname)
@@ -715,27 +738,21 @@ def update_config(
715
738
 
716
739
  # Update Rekordbox 5 config
717
740
  try:
718
- conf = _get_rb5_config(
719
- pioneer_install_dir, pioneer_app_dir, rb5_install_dirname
720
- )
741
+ conf = _get_rb5_config(pioneer_install_dir, pioneer_app_dir, rb5_install_dirname)
721
742
  __config__["rekordbox5"].update(conf)
722
743
  except FileNotFoundError as e:
723
744
  logger.info(e)
724
745
 
725
746
  # Update Rekordbox 6 config
726
747
  try:
727
- conf = _get_rb6_config(
728
- pioneer_install_dir, pioneer_app_dir, rb6_install_dirname
729
- )
748
+ conf = _get_rb6_config(pioneer_install_dir, pioneer_app_dir, rb6_install_dirname)
730
749
  __config__["rekordbox6"].update(conf)
731
750
  except FileNotFoundError as e:
732
751
  logger.info(e)
733
752
 
734
753
  # Update Rekordbox 7 config
735
754
  try:
736
- conf = _get_rb7_config(
737
- pioneer_install_dir, pioneer_app_dir, rb7_install_dirname
738
- )
755
+ conf = _get_rb7_config(pioneer_install_dir, pioneer_app_dir, rb7_install_dirname)
739
756
  __config__["rekordbox7"].update(conf)
740
757
  except FileNotFoundError as e:
741
758
  logger.info(e)
@@ -27,12 +27,16 @@ try:
27
27
  from sqlcipher3 import dbapi2 as sqlite3 # noqa
28
28
 
29
29
  _sqlcipher_available = True
30
- except ImportError:
30
+ except ImportError: # pragma: no cover
31
31
  import sqlite3
32
32
 
33
33
  _sqlcipher_available = False
34
34
 
35
35
  MAX_VERSION = "6.6.5"
36
+ SPECIAL_PLAYLIST_IDS = [
37
+ "100000", # Cloud Library Sync
38
+ "200000", # CUE Analysis Playlist
39
+ ]
36
40
 
37
41
  logger = logging.getLogger(__name__)
38
42
 
@@ -114,20 +118,16 @@ class Rekordbox6Database:
114
118
  path = rb_config.get("db_path", "")
115
119
  if not path:
116
120
  pdir = get_config("pioneer", "install_dir")
117
- raise FileNotFoundError(
118
- f"No Rekordbox v6/v7 directory found in '{pdir}'"
119
- )
121
+ raise FileNotFoundError(f"No Rekordbox v6/v7 directory found in '{pdir}'")
120
122
  path = Path(path)
121
123
  # make sure file exists
122
124
  if not path.exists():
123
125
  raise FileNotFoundError(f"File '{path}' does not exist!")
124
126
  # Open database
125
127
  if unlock:
126
- if not _sqlcipher_available:
127
- raise ImportError(
128
- "Could not unlock database: 'sqlcipher3' package not found"
129
- )
130
- if not key:
128
+ if not _sqlcipher_available: # pragma: no cover
129
+ raise ImportError("Could not unlock database: 'sqlcipher3' package not found")
130
+ if not key: # pragma: no cover
131
131
  try:
132
132
  key = rb_config["dp"]
133
133
  except KeyError:
@@ -404,17 +404,20 @@ class Rekordbox6Database:
404
404
  # Sync the updated_at values of the playlists in the DB and the XML file
405
405
  for pl in self.get_playlist():
406
406
  plxml = self.playlist_xml.get(pl.ID)
407
- if plxml is None:
408
- raise ValueError(
409
- f"Playlist {pl.ID} not found in masterPlaylists6.xml! "
410
- "Did you add it manually? "
411
- "Use the create_playlist method instead."
412
- )
413
- ts = plxml["Timestamp"]
414
- diff = pl.updated_at - ts
415
- if abs(diff.total_seconds()) > 1:
416
- logger.debug("Updating updated_at of playlist %s in XML", pl.ID)
417
- self.playlist_xml.update(pl.ID, updated_at=pl.updated_at)
407
+ if plxml is not None:
408
+ ts = plxml["Timestamp"]
409
+ diff = pl.updated_at - ts
410
+ if abs(diff.total_seconds()) > 1:
411
+ logger.debug("Updating updated_at of playlist %s in XML", pl.ID)
412
+ self.playlist_xml.update(pl.ID, updated_at=pl.updated_at)
413
+ else:
414
+ # Dont warn for special playlists
415
+ if pl.ID not in SPECIAL_PLAYLIST_IDS:
416
+ logger.warning(
417
+ f"Playlist {pl.ID} not found in masterPlaylists6.xml! "
418
+ "Did you add it manually? "
419
+ "Use the create_playlist method instead."
420
+ )
418
421
 
419
422
  # Save the XML file if it was modified
420
423
  if self.playlist_xml.modified:
@@ -726,9 +729,7 @@ class Rekordbox6Database:
726
729
 
727
730
  # -- Database updates --------------------------------------------------------------
728
731
 
729
- def generate_unused_id(
730
- self, table, is_28_bit: bool = True, id_field_name: str = "ID"
731
- ) -> int:
732
+ def generate_unused_id(self, table, is_28_bit: bool = True, id_field_name: str = "ID") -> int:
732
733
  """Generates an unused ID for the given table."""
733
734
  max_tries = 1000000
734
735
  for _ in range(max_tries):
@@ -803,20 +804,14 @@ class Rekordbox6Database:
803
804
  uuid = str(uuid4())
804
805
  id_ = str(uuid4())
805
806
  now = datetime.datetime.now()
806
- nsongs = (
807
- self.query(tables.DjmdSongPlaylist)
808
- .filter_by(PlaylistID=playlist.ID)
809
- .count()
810
- )
807
+ nsongs = self.query(tables.DjmdSongPlaylist).filter_by(PlaylistID=playlist.ID).count()
811
808
  if track_no is not None:
812
809
  insert_at_end = False
813
810
  track_no = int(track_no)
814
811
  if track_no < 1:
815
812
  raise ValueError("Track number must be greater than 0")
816
813
  if track_no > nsongs + 1:
817
- raise ValueError(
818
- f"Track number too high, parent contains {nsongs} items"
819
- )
814
+ raise ValueError(f"Track number too high, parent contains {nsongs} items")
820
815
  else:
821
816
  insert_at_end = True
822
817
  track_no = nsongs + 1
@@ -892,9 +887,7 @@ class Rekordbox6Database:
892
887
  playlist = self.get_playlist(ID=playlist)
893
888
  if isinstance(song, (int, str)):
894
889
  song = self.query(tables.DjmdSongPlaylist).filter_by(ID=song).one()
895
- logger.info(
896
- "Removing song with ID=%s from playlist with ID=%s", song.ID, playlist.ID
897
- )
890
+ logger.info("Removing song with ID=%s from playlist with ID=%s", song.ID, playlist.ID)
898
891
  now = datetime.datetime.now()
899
892
  # Remove track from playlist
900
893
  track_no = song.TrackNo
@@ -965,11 +958,7 @@ class Rekordbox6Database:
965
958
  playlist = self.get_playlist(ID=playlist)
966
959
  if isinstance(song, (int, str)):
967
960
  song = self.query(tables.DjmdSongPlaylist).filter_by(ID=song).one()
968
- nsongs = (
969
- self.query(tables.DjmdSongPlaylist)
970
- .filter_by(PlaylistID=playlist.ID)
971
- .count()
972
- )
961
+ nsongs = self.query(tables.DjmdSongPlaylist).filter_by(PlaylistID=playlist.ID).count()
973
962
  if new_track_no < 1:
974
963
  raise ValueError("Track number must be greater than 0")
975
964
  if new_track_no > nsongs + 1:
@@ -1019,9 +1008,7 @@ class Rekordbox6Database:
1019
1008
  self.registry.enable_tracking()
1020
1009
  self.registry.on_move(moved)
1021
1010
 
1022
- def _create_playlist(
1023
- self, name, seq, image_path, parent, smart_list=None, attribute=None
1024
- ):
1011
+ def _create_playlist(self, name, seq, image_path, parent, smart_list=None, attribute=None):
1025
1012
  """Creates a new playlist object."""
1026
1013
  table = tables.DjmdPlaylist
1027
1014
  id_ = str(self.generate_unused_id(table, is_28_bit=True))
@@ -1046,9 +1033,7 @@ class Rekordbox6Database:
1046
1033
  else:
1047
1034
  # Check if parent exists and is a folder
1048
1035
  parent_id = parent
1049
- query = self.query(table.ID).filter(
1050
- table.ID == parent_id, table.Attribute == 1
1051
- )
1036
+ query = self.query(table.ID).filter(table.ID == parent_id, table.Attribute == 1)
1052
1037
  if not self.query(query.exists()).scalar():
1053
1038
  raise ValueError("Parent does not exist or is not a folder")
1054
1039
 
@@ -1107,9 +1092,7 @@ class Rekordbox6Database:
1107
1092
 
1108
1093
  # Update masterPlaylists6.xml
1109
1094
  if self.playlist_xml is not None:
1110
- self.playlist_xml.add(
1111
- id_, parent_id, attribute, now, lib_type=0, check_type=0
1112
- )
1095
+ self.playlist_xml.add(id_, parent_id, attribute, now, lib_type=0, check_type=0)
1113
1096
 
1114
1097
  return playlist
1115
1098
 
@@ -1157,9 +1140,7 @@ class Rekordbox6Database:
1157
1140
  '123456'
1158
1141
  """
1159
1142
  logger.info("Creating playlist %s", name)
1160
- return self._create_playlist(
1161
- name, seq, image_path, parent, attribute=PlaylistType.PLAYLIST
1162
- )
1143
+ return self._create_playlist(name, seq, image_path, parent, attribute=PlaylistType.PLAYLIST)
1163
1144
 
1164
1145
  def create_playlist_folder(self, name, parent=None, seq=None, image_path=None):
1165
1146
  """Creates a new playlist folder in the database.
@@ -1199,9 +1180,7 @@ class Rekordbox6Database:
1199
1180
  '123456'
1200
1181
  """
1201
1182
  logger.info("Creating playlist folder %s", name)
1202
- return self._create_playlist(
1203
- name, seq, image_path, parent, attribute=PlaylistType.FOLDER
1204
- )
1183
+ return self._create_playlist(name, seq, image_path, parent, attribute=PlaylistType.FOLDER)
1205
1184
 
1206
1185
  def create_smart_playlist(
1207
1186
  self, name, smart_list: SmartList, parent=None, seq=None, image_path=None
@@ -1276,9 +1255,7 @@ class Rekordbox6Database:
1276
1255
  playlist = self.get_playlist(ID=playlist)
1277
1256
 
1278
1257
  if playlist.Attribute == 1:
1279
- logger.info(
1280
- "Deleting playlist folder '%s' with ID=%s", playlist.Name, playlist.ID
1281
- )
1258
+ logger.info("Deleting playlist folder '%s' with ID=%s", playlist.Name, playlist.ID)
1282
1259
  else:
1283
1260
  logger.info("Deleting playlist '%s' with ID=%s", playlist.Name, playlist.ID)
1284
1261
 
@@ -1391,9 +1368,7 @@ class Rekordbox6Database:
1391
1368
  else:
1392
1369
  # Check if parent exists and is a folder
1393
1370
  parent_id = str(parent)
1394
- query = self.query(table.ID).filter(
1395
- table.ID == parent_id, table.Attribute == 1
1396
- )
1371
+ query = self.query(table.ID).filter(table.ID == parent_id, table.Attribute == 1)
1397
1372
  if not self.query(query.exists()).scalar():
1398
1373
  raise ValueError("Parent does not exist or is not a folder")
1399
1374
 
@@ -1414,9 +1389,7 @@ class Rekordbox6Database:
1414
1389
  if seq < 1:
1415
1390
  raise ValueError("Sequence number must be greater than 0")
1416
1391
  elif seq > n + 1:
1417
- raise ValueError(
1418
- f"Sequence number too high, parent contains {n} items"
1419
- )
1392
+ raise ValueError(f"Sequence number too high, parent contains {n} items")
1420
1393
 
1421
1394
  if not insert_at_end:
1422
1395
  # Get all playlists with seq between old_seq and seq
@@ -1550,9 +1523,7 @@ class Rekordbox6Database:
1550
1523
  with self.registry.disabled():
1551
1524
  playlist.updated_at = now
1552
1525
 
1553
- def add_album(
1554
- self, name, artist=None, image_path=None, compilation=None, search_str=None
1555
- ):
1526
+ def add_album(self, name, artist=None, image_path=None, compilation=None, search_str=None):
1556
1527
  """Adds a new album to the database.
1557
1528
 
1558
1529
  Parameters
@@ -1684,9 +1655,7 @@ class Rekordbox6Database:
1684
1655
 
1685
1656
  id_ = self.generate_unused_id(tables.DjmdArtist)
1686
1657
  uuid = str(uuid4())
1687
- artist = tables.DjmdArtist.create(
1688
- ID=id_, Name=name, SearchStr=search_str, UUID=uuid
1689
- )
1658
+ artist = tables.DjmdArtist.create(ID=id_, Name=name, SearchStr=search_str, UUID=uuid)
1690
1659
  self.add(artist)
1691
1660
  self.flush()
1692
1661
  return artist
@@ -1828,9 +1797,7 @@ class Rekordbox6Database:
1828
1797
  raise ValueError(f"Track with path '{path}' already exists in database")
1829
1798
 
1830
1799
  id_ = self.generate_unused_id(tables.DjmdContent)
1831
- file_id = self.generate_unused_id(
1832
- tables.DjmdContent, id_field_name="rb_file_id"
1833
- )
1800
+ file_id = self.generate_unused_id(tables.DjmdContent, id_field_name="rb_file_id")
1834
1801
  uuid = str(uuid4())
1835
1802
  content_link = self.get_menu_items(Name="TRACK").one()
1836
1803
  date_created = datetime.date.today()
@@ -1987,9 +1954,7 @@ class Rekordbox6Database:
1987
1954
  return AnlzFile.parse_file(path)
1988
1955
  return None
1989
1956
 
1990
- def update_content_path(
1991
- self, content, path, save=True, check_path=True, commit=True
1992
- ):
1957
+ def update_content_path(self, content, path, save=True, check_path=True, commit=True):
1993
1958
  """Update the file path of a track in the Rekordbox v6 database.
1994
1959
 
1995
1960
  This changes the `FolderPath` entry in the ``DjmdContent`` table and the
@@ -2080,9 +2045,7 @@ class Rekordbox6Database:
2080
2045
  logger.debug("Committing changes to the database")
2081
2046
  self.commit()
2082
2047
 
2083
- def update_content_filename(
2084
- self, content, name, save=True, check_path=True, commit=True
2085
- ):
2048
+ def update_content_filename(self, content, name, save=True, check_path=True, commit=True):
2086
2049
  """Update the file name of a track in the Rekordbox v6 database.
2087
2050
 
2088
2051
  This changes the `FolderPath` entry in the ``DjmdContent`` table and the
@@ -179,8 +179,7 @@ class Condition:
179
179
  def __post_init__(self):
180
180
  if self.property not in PROPERTIES:
181
181
  raise ValueError(
182
- f"Invalid property: '{self.property}'! "
183
- f"Supported properties: {PROPERTIES}"
182
+ f"Invalid property: '{self.property}'! Supported properties: {PROPERTIES}"
184
183
  )
185
184
 
186
185
  valid_ops = VALID_OPS[self.property]
@@ -232,9 +231,7 @@ def _get_condition_values(cond):
232
231
  class SmartList:
233
232
  """Rekordbox smart playlist XML handler."""
234
233
 
235
- def __init__(
236
- self, logical_operator: int = LogicalOperator.ALL, auto_update: int = 0
237
- ):
234
+ def __init__(self, logical_operator: int = LogicalOperator.ALL, auto_update: int = 0):
238
235
  self.playlist_id: Union[int, str] = ""
239
236
  self.logical_operator: int = int(logical_operator)
240
237
  self.auto_update: int = auto_update
pyrekordbox/db6/tables.py CHANGED
@@ -115,6 +115,37 @@ TABLES = [
115
115
  ]
116
116
 
117
117
 
118
+ def datetime_to_str(value: datetime) -> str:
119
+ s = value.isoformat().replace("T", " ")
120
+ if value.tzinfo is not None:
121
+ # Get the timezone info (last 6 characters of the string)
122
+ tzinfo = s[-6:]
123
+ s = s[:-9] + " " + tzinfo
124
+ else:
125
+ s = s[:-3] + " +00:00"
126
+ return s
127
+
128
+
129
+ def string_to_datetime(value: str) -> datetime:
130
+ try:
131
+ dt = datetime.fromisoformat(value)
132
+ except ValueError:
133
+ if len(value.strip()) > 23:
134
+ # Assume the format
135
+ # "2025-04-12 19:11:29.274 -05:00" or
136
+ # "2025-04-12 19:11:29.274 -05:00 (Central Daylight Time)"
137
+ datestr, tzinfo = value[:23], value[23:30]
138
+ datestr = datestr.strip()
139
+ tzinfo = tzinfo.strip()
140
+ assert re.match(r"^[+-]?\d{1,2}:\d{2}", tzinfo)
141
+ datestr = datestr.strip() + tzinfo
142
+ else:
143
+ datestr, tzinfo = value, ""
144
+ dt = datetime.fromisoformat(datestr)
145
+ # Convert to local timezone and return without timezone
146
+ return dt.astimezone().replace(tzinfo=None)
147
+
148
+
118
149
  class DateTime(TypeDecorator):
119
150
  """Custom datetime column with timezone support.
120
151
 
@@ -125,23 +156,12 @@ class DateTime(TypeDecorator):
125
156
  impl = Text
126
157
  cache_ok = True
127
158
 
128
- def process_bind_param(self, value, dialect):
129
- return value.isoformat().replace("T", " ")[:-3] + " +00:00"
159
+ def process_bind_param(self, value: datetime, dialect) -> str:
160
+ return datetime_to_str(value)
130
161
 
131
- def process_result_value(self, value, dialect):
162
+ def process_result_value(self, value: str, dialect):
132
163
  if value:
133
- try:
134
- dt = datetime.fromisoformat(value)
135
- except ValueError:
136
- if len(value.strip()) > 23:
137
- datestr, tzinfo = value[:23], value[23:]
138
- datestr = datestr.strip()
139
- tzinfo = tzinfo.strip()
140
- assert re.match(r"^\+\d{2}:\d{2}$", tzinfo)
141
- else:
142
- datestr, tzinfo = value, ""
143
- dt = datetime.fromisoformat(datestr)
144
- return dt
164
+ return string_to_datetime(value)
145
165
  return None
146
166
 
147
167
 
@@ -187,14 +207,14 @@ class Base(DeclarativeBase):
187
207
  return [column.key for column in inspect(cls).relationships] # noqa
188
208
 
189
209
  @classmethod
190
- def __get_keys__(cls):
210
+ def __get_keys__(cls): # pragma: no cover
191
211
  """Get all attributes of the table."""
192
212
  items = cls.__dict__.items()
193
213
  keys = [k for k, v in items if not callable(v) and not k.startswith("_")]
194
214
  return keys
195
215
 
196
216
  @classmethod
197
- def keys(cls):
217
+ def keys(cls): # pragma: no cover
198
218
  """Returns a list of all column names including the relationships."""
199
219
  if not cls.__keys__: # Cache the keys
200
220
  cls.__keys__ = cls.__get_keys__()
@@ -228,7 +248,7 @@ class Base(DeclarativeBase):
228
248
  """Returns a dictionary of all column names and values."""
229
249
  return {key: self.__getitem__(key) for key in self.columns()}
230
250
 
231
- def pformat(self, indent=" "):
251
+ def pformat(self, indent=" "): # pragma: no cover
232
252
  lines = [f"{self.__tablename__}"]
233
253
  columns = self.columns()
234
254
  w = max(len(col) for col in columns)
@@ -240,9 +260,7 @@ class Base(DeclarativeBase):
240
260
  class StatsTime:
241
261
  """Mixin class for tables that only use time statistics columns."""
242
262
 
243
- created_at: Mapped[datetime] = mapped_column(
244
- DateTime, nullable=False, default=datetime.now
245
- )
263
+ created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.now)
246
264
  """The creation date of the table entry (from :class:`StatsTime`)."""
247
265
  updated_at: Mapped[datetime] = mapped_column(
248
266
  DateTime, nullable=False, default=datetime.now, onupdate=datetime.now
@@ -271,9 +289,7 @@ class StatsFull:
271
289
  rb_local_usn: Mapped[int] = mapped_column(BigInteger, default=None)
272
290
  """The local USN (unique sequence number) of the table entry
273
291
  (from :class:`StatsFull`)."""
274
- created_at: Mapped[datetime] = mapped_column(
275
- DateTime, nullable=False, default=datetime.now
276
- )
292
+ created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.now)
277
293
  """The creation date of the table entry (from :class:`StatsFull`)."""
278
294
  updated_at: Mapped[datetime] = mapped_column(
279
295
  DateTime, nullable=False, default=datetime.now, onupdate=datetime.now
@@ -371,9 +387,7 @@ class ContentActiveCensor(Base, StatsFull):
371
387
 
372
388
  ID: Mapped[str] = mapped_column(VARCHAR(255), primary_key=True)
373
389
  """The ID (primary key) of the table entry."""
374
- ContentID: Mapped[str] = mapped_column(
375
- VARCHAR(255), ForeignKey("djmdContent.ID"), default=None
376
- )
390
+ ContentID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdContent.ID"), default=None)
377
391
  """The ID of the :class:`DjmdContent` entry this censor belongs to."""
378
392
  ActiveCensors: Mapped[str] = mapped_column(Text, default=None)
379
393
  """The active censors of the table entry."""
@@ -396,9 +410,7 @@ class ContentCue(Base, StatsFull):
396
410
 
397
411
  ID: Mapped[str] = mapped_column(VARCHAR(255), primary_key=True)
398
412
  """The ID (primary key) of the table entry."""
399
- ContentID: Mapped[str] = mapped_column(
400
- VARCHAR(255), ForeignKey("djmdContent.ID"), default=None
401
- )
413
+ ContentID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdContent.ID"), default=None)
402
414
  """The ID of the :class:`DjmdContent` entry this cue belongs to."""
403
415
  Cues: Mapped[str] = mapped_column(Text, default=None)
404
416
  """The cues of the table entry."""
@@ -421,9 +433,7 @@ class ContentFile(Base, StatsFull):
421
433
 
422
434
  ID: Mapped[str] = mapped_column(VARCHAR(255), primary_key=True)
423
435
  """The ID (primary key) of the table entry."""
424
- ContentID: Mapped[str] = mapped_column(
425
- VARCHAR(255), ForeignKey("djmdContent.ID"), default=None
426
- )
436
+ ContentID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdContent.ID"), default=None)
427
437
  """The ID of the :class:`DjmdContent` entry this file belongs to."""
428
438
  Path: Mapped[str] = mapped_column(VARCHAR(255), default=None)
429
439
  """The path of the file."""
@@ -468,9 +478,7 @@ class DjmdActiveCensor(Base, StatsFull):
468
478
 
469
479
  ID: Mapped[str] = mapped_column(VARCHAR(255), primary_key=True)
470
480
  """The ID (primary key) of the table entry."""
471
- ContentID: Mapped[str] = mapped_column(
472
- VARCHAR(255), ForeignKey("djmdContent.ID"), default=None
473
- )
481
+ ContentID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdContent.ID"), default=None)
474
482
  """The ID of the :class:`DjmdContent` entry this censor belongs to."""
475
483
  InMsec: Mapped[int] = mapped_column(Integer, default=None)
476
484
  """The in time of the censor (in milliseconds)."""
@@ -483,9 +491,7 @@ class DjmdActiveCensor(Base, StatsFull):
483
491
  ContentUUID: Mapped[str] = mapped_column(VARCHAR(255), default=None)
484
492
  """The UUID of the :class:`DjmdContent` entry this censor belongs to."""
485
493
 
486
- Content = relationship(
487
- "DjmdContent", foreign_keys=ContentID, back_populates="ActiveCensors"
488
- )
494
+ Content = relationship("DjmdContent", foreign_keys=ContentID, back_populates="ActiveCensors")
489
495
  """The content entry this censor belongs to (links to :class:`DjmdContent`)."""
490
496
 
491
497
 
@@ -616,17 +622,11 @@ class DjmdContent(Base, StatsFull):
616
622
  """The short file name of the file corresponding to the content entry."""
617
623
  Title: Mapped[str] = mapped_column(VARCHAR(255), default=None)
618
624
  """The title of the track."""
619
- ArtistID: Mapped[str] = mapped_column(
620
- VARCHAR(255), ForeignKey("djmdArtist.ID"), default=None
621
- )
625
+ ArtistID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdArtist.ID"), default=None)
622
626
  """The ID of the :class:`DjmdArtist` entry of the artist of this track."""
623
- AlbumID: Mapped[str] = mapped_column(
624
- VARCHAR(255), ForeignKey("djmdAlbum.ID"), default=None
625
- )
627
+ AlbumID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdAlbum.ID"), default=None)
626
628
  """The ID of the :class:`DjmdAlbum` entry of the album of this track."""
627
- GenreID: Mapped[str] = mapped_column(
628
- VARCHAR(255), ForeignKey("djmdGenre.ID"), default=None
629
- )
629
+ GenreID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdGenre.ID"), default=None)
630
630
  """The ID of the :class:`DjmdGenre` entry of the genre of this track."""
631
631
  BPM: Mapped[int] = mapped_column(Integer, default=None)
632
632
  """The BPM (beats per minute) of the track."""
@@ -646,27 +646,19 @@ class DjmdContent(Base, StatsFull):
646
646
  """The rating of the track."""
647
647
  ReleaseYear: Mapped[int] = mapped_column(Integer, default=None)
648
648
  """The release year of the track."""
649
- RemixerID: Mapped[str] = mapped_column(
650
- VARCHAR(255), ForeignKey("djmdArtist.ID"), default=None
651
- )
649
+ RemixerID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdArtist.ID"), default=None)
652
650
  """The ID of the :class:`DjmdArtist` entry of the remixer of this track."""
653
- LabelID: Mapped[str] = mapped_column(
654
- VARCHAR(255), ForeignKey("djmdLabel.ID"), default=None
655
- )
651
+ LabelID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdLabel.ID"), default=None)
656
652
  """The ID of the :class:`DjmdLabel` entry of the label of this track."""
657
653
  OrgArtistID: Mapped[str] = mapped_column(
658
654
  VARCHAR(255), ForeignKey("djmdArtist.ID"), default=None
659
655
  )
660
656
  """The ID of the :class:`DjmdArtist` entry of the original artist of this track."""
661
- KeyID: Mapped[str] = mapped_column(
662
- VARCHAR(255), ForeignKey("djmdKey.ID"), default=None
663
- )
657
+ KeyID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdKey.ID"), default=None)
664
658
  """The ID of the :class:`DjmdKey` entry of the key of this track."""
665
659
  StockDate: Mapped[str] = mapped_column(VARCHAR(255), default=None)
666
660
  """The stock date of the track."""
667
- ColorID: Mapped[str] = mapped_column(
668
- VARCHAR(255), ForeignKey("djmdColor.ID"), default=None
669
- )
661
+ ColorID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdColor.ID"), default=None)
670
662
  """The ID of the :class:`DjmdColor` entry of the color of this track."""
671
663
  DJPlayCount: Mapped[str] = mapped_column(VARCHAR(255), default=None)
672
664
  """The play count of the track."""
@@ -684,9 +676,7 @@ class DjmdContent(Base, StatsFull):
684
676
  """The file size of the track."""
685
677
  DiscNo: Mapped[int] = mapped_column(Integer, default=None)
686
678
  """The number of the disc of the album of the track."""
687
- ComposerID: Mapped[str] = mapped_column(
688
- VARCHAR(255), ForeignKey("djmdArtist.ID"), default=None
689
- )
679
+ ComposerID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdArtist.ID"), default=None)
690
680
  """The ID of the :class:`DjmdArtist` entry of the composer of this track."""
691
681
  Subtitle: Mapped[str] = mapped_column(VARCHAR(255), default=None)
692
682
  """The subtitle of the track."""
@@ -718,9 +708,7 @@ class DjmdContent(Base, StatsFull):
718
708
  """The analysis updated status of the track."""
719
709
  TrackInfoUpdated: Mapped[str] = mapped_column(VARCHAR(255), default=None)
720
710
  """The track info updated status of the track."""
721
- Lyricist: Mapped[str] = mapped_column(
722
- VARCHAR(255), ForeignKey("djmdArtist.ID"), default=None
723
- )
711
+ Lyricist: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdArtist.ID"), default=None)
724
712
  """The ID of the :class:`DjmdArtist` entry of the lyricist of this track."""
725
713
  ISRC: Mapped[str] = mapped_column(VARCHAR(255), default=None)
726
714
  """The ISRC of the track."""
@@ -787,9 +775,7 @@ class DjmdContent(Base, StatsFull):
787
775
  """The album artist entry of the track (links to :class:`DjmdArtist`)."""
788
776
  MyTags = relationship("DjmdSongMyTag", back_populates="Content")
789
777
  """The my tags of the track (links to :class:`DjmdSongMyTag`)."""
790
- Cues = relationship(
791
- "DjmdCue", foreign_keys="DjmdCue.ContentID", back_populates="Content"
792
- )
778
+ Cues = relationship("DjmdCue", foreign_keys="DjmdCue.ContentID", back_populates="Content")
793
779
  """The cues of the track (links to :class:`DjmdCue`)."""
794
780
  ActiveCensors = relationship("DjmdActiveCensor", back_populates="Content")
795
781
  """The active censors of the track (links to :class:`DjmdActiveCensor`)."""
@@ -838,9 +824,7 @@ class DjmdCue(Base, StatsFull):
838
824
 
839
825
  ID: Mapped[str] = mapped_column(VARCHAR(255), primary_key=True)
840
826
  """The ID (primary key) of the table entry."""
841
- ContentID: Mapped[str] = mapped_column(
842
- VARCHAR(255), ForeignKey("djmdContent.ID"), default=None
843
- )
827
+ ContentID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdContent.ID"), default=None)
844
828
  """The ID of the content (:class:`DjmdContent`) containing the cue point."""
845
829
  InMsec: Mapped[int] = mapped_column(Integer, default=None)
846
830
  """The in point of the cue point in milliseconds."""
@@ -943,9 +927,7 @@ class DjmdHistory(Base, StatsFull):
943
927
  """The name of the history playlist."""
944
928
  Attribute: Mapped[int] = mapped_column(Integer, default=None)
945
929
  """The attributes of the history playlist"""
946
- ParentID: Mapped[str] = mapped_column(
947
- VARCHAR(255), ForeignKey("djmdHistory.ID"), default=None
948
- )
930
+ ParentID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdHistory.ID"), default=None)
949
931
  """The ID of the parent history playlist (:class:`DjmdHistory`)."""
950
932
  DateCreated: Mapped[str] = mapped_column(VARCHAR(255), default=None)
951
933
  """The date the history playlist was created."""
@@ -978,13 +960,9 @@ class DjmdSongHistory(Base, StatsFull):
978
960
 
979
961
  ID: Mapped[str] = mapped_column(VARCHAR(255), primary_key=True)
980
962
 
981
- HistoryID: Mapped[str] = mapped_column(
982
- VARCHAR(255), ForeignKey("djmdHistory.ID"), default=None
983
- )
963
+ HistoryID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdHistory.ID"), default=None)
984
964
  """The ID of the history playlist (:class:`DjmdHistory`)."""
985
- ContentID: Mapped[str] = mapped_column(
986
- VARCHAR(255), ForeignKey("djmdContent.ID"), default=None
987
- )
965
+ ContentID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdContent.ID"), default=None)
988
966
  """The ID of the content (:class:`DjmdContent`)."""
989
967
  TrackNo: Mapped[int] = mapped_column(Integer, default=None)
990
968
  """The track number of the song in the history playlist."""
@@ -1050,9 +1028,7 @@ class DjmdSongHotCueBanklist(Base, StatsFull):
1050
1028
  VARCHAR(255), ForeignKey("djmdHotCueBanklist.ID"), default=None
1051
1029
  )
1052
1030
  """The ID of the hot cue banklist (:class:`DjmdHotCueBanklist`)."""
1053
- ContentID: Mapped[str] = mapped_column(
1054
- VARCHAR(255), ForeignKey("djmdContent.ID"), default=None
1055
- )
1031
+ ContentID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdContent.ID"), default=None)
1056
1032
  """The ID of the content (:class:`DjmdContent`)."""
1057
1033
  TrackNo: Mapped[int] = mapped_column(Integer, default=None)
1058
1034
  """The track number of the hot-cue in the hot cue banklist."""
@@ -1160,9 +1136,7 @@ class DjmdMixerParam(Base, StatsFull):
1160
1136
 
1161
1137
  ID: Mapped[str] = mapped_column(VARCHAR(255), primary_key=True)
1162
1138
  """The ID (primary key) of the table entry."""
1163
- ContentID: Mapped[str] = mapped_column(
1164
- VARCHAR(255), ForeignKey("djmdContent.ID"), default=None
1165
- )
1139
+ ContentID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdContent.ID"), default=None)
1166
1140
  """The ID of the content (:class:`DjmdContent`)."""
1167
1141
  GainHigh: Mapped[int] = mapped_column(Integer, default=None)
1168
1142
  """The high gain of the mixer parameter."""
@@ -1243,9 +1217,7 @@ class DjmdMyTag(Base, StatsFull):
1243
1217
  """The name of the My-Tag list."""
1244
1218
  Attribute: Mapped[int] = mapped_column(Integer, default=None)
1245
1219
  """The attribute of the My-Tag list."""
1246
- ParentID: Mapped[str] = mapped_column(
1247
- VARCHAR(255), ForeignKey("djmdMyTag.ID"), default=None
1248
- )
1220
+ ParentID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdMyTag.ID"), default=None)
1249
1221
  """The ID of the parent My-Tag list (:class:`DjmdMyTag`)."""
1250
1222
 
1251
1223
  MyTags = relationship("DjmdSongMyTag", back_populates="MyTag")
@@ -1274,13 +1246,9 @@ class DjmdSongMyTag(Base, StatsFull):
1274
1246
 
1275
1247
  ID: Mapped[str] = mapped_column(VARCHAR(255), primary_key=True)
1276
1248
  """The ID (primary key) of the table entry."""
1277
- MyTagID: Mapped[str] = mapped_column(
1278
- VARCHAR(255), ForeignKey("djmdMyTag.ID"), default=None
1279
- )
1249
+ MyTagID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdMyTag.ID"), default=None)
1280
1250
  """The ID of the My-Tag list (links to :class:`DjmdMyTag`)."""
1281
- ContentID: Mapped[str] = mapped_column(
1282
- VARCHAR(255), ForeignKey("djmdContent.ID"), default=None
1283
- )
1251
+ ContentID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdContent.ID"), default=None)
1284
1252
  """The ID of the content this item belongs to (:class:`DjmdContent`)."""
1285
1253
  TrackNo: Mapped[int] = mapped_column(Integer, default=None)
1286
1254
  """The track number of the My-Tag item (for ordering)."""
@@ -1314,16 +1282,12 @@ class DjmdPlaylist(Base, StatsFull):
1314
1282
  """The path to the image of the playlist."""
1315
1283
  Attribute: Mapped[int] = mapped_column(Integer, default=None)
1316
1284
  """The attribute of the playlist."""
1317
- ParentID: Mapped[str] = mapped_column(
1318
- VARCHAR(255), ForeignKey("djmdPlaylist.ID"), default=None
1319
- )
1285
+ ParentID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdPlaylist.ID"), default=None)
1320
1286
  """The ID of the parent playlist (:class:`DjmdPlaylist`)."""
1321
1287
  SmartList: Mapped[str] = mapped_column(Text, default=None)
1322
1288
  """The smart list settings of the playlist."""
1323
1289
 
1324
- Songs = relationship(
1325
- "DjmdSongPlaylist", back_populates="Playlist", cascade="all, delete"
1326
- )
1290
+ Songs = relationship("DjmdSongPlaylist", back_populates="Playlist", cascade="all, delete")
1327
1291
  """The contents of the playlist (links to :class:`DjmdSongPlaylist`)."""
1328
1292
  Children = relationship(
1329
1293
  "DjmdPlaylist",
@@ -1368,9 +1332,7 @@ class DjmdSongPlaylist(Base, StatsFull):
1368
1332
  VARCHAR(255), ForeignKey("djmdPlaylist.ID"), default=None
1369
1333
  )
1370
1334
  """The ID of the playlist this item is in (:class:`DjmdPlaylist`)."""
1371
- ContentID: Mapped[str] = mapped_column(
1372
- VARCHAR(255), ForeignKey("djmdContent.ID"), default=None
1373
- )
1335
+ ContentID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdContent.ID"), default=None)
1374
1336
  """The ID of the content this item belongs to (:class:`DjmdContent`)."""
1375
1337
  TrackNo: Mapped[int] = mapped_column(Integer, default=None)
1376
1338
  """The track number of the playlist item (for ordering)."""
@@ -1441,9 +1403,7 @@ class DjmdSongRelatedTracks(Base, StatsFull):
1441
1403
  )
1442
1404
  """The ID of the related tracks list this item is in
1443
1405
  (:class:`DjmdRelatedTracks`)."""
1444
- ContentID: Mapped[str] = mapped_column(
1445
- VARCHAR(255), ForeignKey("djmdContent.ID"), default=None
1446
- )
1406
+ ContentID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdContent.ID"), default=None)
1447
1407
  """The ID of the content this item belongs to (:class:`DjmdContent`)."""
1448
1408
  TrackNo: Mapped[int] = mapped_column(Integer, default=None)
1449
1409
  """The track number of the related tracks list item (for ordering)."""
@@ -1472,9 +1432,7 @@ class DjmdSampler(Base, StatsFull):
1472
1432
  """The name of the sampler list."""
1473
1433
  Attribute: Mapped[int] = mapped_column(Integer, default=None)
1474
1434
  """The attribute of the sampler list."""
1475
- ParentID: Mapped[str] = mapped_column(
1476
- VARCHAR(255), ForeignKey("djmdSampler.ID"), default=None
1477
- )
1435
+ ParentID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdSampler.ID"), default=None)
1478
1436
  """The ID of the parent sampler list (:class:`DjmdSampler`)."""
1479
1437
 
1480
1438
  Songs = relationship("DjmdSongSampler", back_populates="Sampler")
@@ -1505,13 +1463,9 @@ class DjmdSongSampler(Base, StatsFull):
1505
1463
 
1506
1464
  ID: Mapped[str] = mapped_column(VARCHAR(255), primary_key=True)
1507
1465
  """The ID (primary key) of the table entry."""
1508
- SamplerID: Mapped[str] = mapped_column(
1509
- VARCHAR(255), ForeignKey("djmdSampler.ID"), default=None
1510
- )
1466
+ SamplerID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdSampler.ID"), default=None)
1511
1467
  """The ID of the sampler list this item is in (:class:`DjmdSampler`)."""
1512
- ContentID: Mapped[str] = mapped_column(
1513
- VARCHAR(255), ForeignKey("djmdContent.ID"), default=None
1514
- )
1468
+ ContentID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdContent.ID"), default=None)
1515
1469
  """The ID of the content this item belongs to (:class:`DjmdContent`)."""
1516
1470
  TrackNo: Mapped[int] = mapped_column(Integer, default=None)
1517
1471
  """The track number of the sampler list item (for ordering)."""
@@ -1529,9 +1483,7 @@ class DjmdSongTagList(Base, StatsFull):
1529
1483
 
1530
1484
  ID: Mapped[str] = mapped_column(VARCHAR(255), primary_key=True)
1531
1485
  """The ID (primary key) of the table entry."""
1532
- ContentID: Mapped[str] = mapped_column(
1533
- VARCHAR(255), ForeignKey("djmdContent.ID"), default=None
1534
- )
1486
+ ContentID: Mapped[str] = mapped_column(VARCHAR(255), ForeignKey("djmdContent.ID"), default=None)
1535
1487
  """The ID of the content this item belongs to (:class:`DjmdContent`)."""
1536
1488
  TrackNo: Mapped[int] = mapped_column(Integer, default=None)
1537
1489
  """The track number of the tag list item (for ordering)."""
pyrekordbox/rbxml.py CHANGED
@@ -27,9 +27,7 @@ POSMARK_TYPE_MAPPING = bidict.bidict(
27
27
  "4": "loop",
28
28
  }
29
29
  )
30
- RATING_MAPPING = bidict.bidict(
31
- {"0": 0, "51": 1, "102": 2, "153": 3, "204": 4, "255": 5}
32
- )
30
+ RATING_MAPPING = bidict.bidict({"0": 0, "51": 1, "102": 2, "153": 3, "204": 4, "255": 5})
33
31
  NODE_KEYTYPE_MAPPING = bidict.bidict({"0": "TrackID", "1": "Location"})
34
32
 
35
33
 
@@ -273,9 +271,7 @@ class Tempo(AbstractElement):
273
271
  ATTRIBS = ["Inizio", "Bpm", "Metro", "Battito"]
274
272
  GETTERS = {"Inizio": float, "Bpm": float, "Battito": int}
275
273
 
276
- def __init__(
277
- self, parent=None, Inizio=0.0, Bpm=0.0, Metro="4/4", Battito=1, element=None
278
- ):
274
+ def __init__(self, parent=None, Inizio=0.0, Bpm=0.0, Metro="4/4", Battito=1, element=None):
279
275
  super().__init__(element, parent, Inizio, Bpm, Metro, Battito)
280
276
 
281
277
  def _init(self, parent, inizio, bpm, metro, battito):
@@ -1046,13 +1042,14 @@ class RekordboxXml:
1046
1042
  >>> track = file.get_track(TrackID=1)
1047
1043
 
1048
1044
  """
1049
- if index is None and TrackID is None:
1050
- raise ValueError("Either index or TrackID has to be specified!")
1045
+ if index is None and TrackID is None and Location is None:
1046
+ raise ValueError("Either index, TrackID or Location has to be specified!")
1051
1047
 
1052
1048
  if TrackID is not None:
1053
1049
  el = self._collection.find(f'.//{Track.TAG}[@TrackID="{TrackID}"]')
1054
1050
  elif Location is not None:
1055
- el = self._collection.find(f'.//{Track.TAG}[@Location="{Location}"]')
1051
+ encoded = encode_path(Location)
1052
+ el = self._collection.find(f'.//{Track.TAG}[@Location="{encoded}"]')
1056
1053
  else:
1057
1054
  el = self._collection.find(f".//{Track.TAG}[{index + 1}]")
1058
1055
  return Track(element=el)
@@ -1095,10 +1092,10 @@ class RekordboxXml:
1095
1092
  node = node.get_playlist(name)
1096
1093
  return node
1097
1094
 
1098
- def _update_track_count(self):
1099
- """Updates the track count element."""
1100
- num_tracks = len(self._collection.findall(f".//{Track.TAG}"))
1101
- self._collection.attrib["Entries"] = str(num_tracks)
1095
+ # def _update_track_count(self):
1096
+ # """Updates the track count element."""
1097
+ # num_tracks = len(self._collection.findall(f".//{Track.TAG}"))
1098
+ # self._collection.attrib["Entries"] = str(num_tracks)
1102
1099
 
1103
1100
  def _increment_track_count(self):
1104
1101
  """Increment the track count element."""
@@ -1264,9 +1261,7 @@ class RekordboxXml:
1264
1261
  num_tracks = len(self._collection.findall(f".//{Track.TAG}"))
1265
1262
  n = int(self._collection.attrib["Entries"])
1266
1263
  if n != num_tracks:
1267
- raise ValueError(
1268
- f"Track count {num_tracks} does not match number of elements {n}"
1269
- )
1264
+ raise ValueError(f"Track count {num_tracks} does not match number of elements {n}")
1270
1265
  # Generate XML string
1271
1266
  return pretty_xml(self._root, indent, encoding="utf-8")
1272
1267
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: pyrekordbox
3
- Version: 0.4.0
3
+ Version: 0.4.2
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
@@ -61,6 +61,7 @@ Provides-Extra: test
61
61
  Requires-Dist: hypothesis>=6.0.0; extra == "test"
62
62
  Requires-Dist: pytest>=6.2.0; extra == "test"
63
63
  Requires-Dist: pytest-cov; extra == "test"
64
+ Dynamic: license-file
64
65
 
65
66
 
66
67
  <p align="center">
@@ -88,9 +89,9 @@ Pioneers Rekordbox DJ Software. It currently supports
88
89
 
89
90
  Tested Rekordbox versions: ``5.8.6 | 6.7.7 | 7.0.9``
90
91
 
91
-
92
- |⚠️| This project is still under development and might contain bugs or have breaking API changes in the future. Check the [changelog][CHANGELOG] for recent changes! |
93
- |----|:----------------------------------------------------------------------------------------------------------------------------------------------------------------|
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!
94
95
 
95
96
 
96
97
  ## 🔧 Installation
@@ -118,8 +119,10 @@ If this fails, it can be installed manually following the [installation guide][i
118
119
 
119
120
  [Read the full documentation on ReadTheDocs!][documentation]
120
121
 
121
- | ❗ | Please make sure to back up your Rekordbox collection before making changes with pyrekordbox or developing/testing new features. The backup dialog can be found under "File" > "Library" > "Backup Library" |
122
- |----|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
122
+ > [!CAUTION]
123
+ > Please make sure to back up your Rekordbox collection before making changes with pyrekordbox
124
+ > or developing/testing new features.
125
+ > The backup dialog can be found under "File" > "Library" > "Backup Library"
123
126
 
124
127
 
125
128
  ### Configuration
@@ -174,9 +177,13 @@ can be changed via the corresponding property of the object:
174
177
  ````python
175
178
  content = db.get_content()[0]
176
179
  content.Title = "New Title"
180
+ db.commit()
177
181
  ````
178
- Some fields are stored as references to other tables, for example the artist of a track.
179
- Check the [documentation][db6-doc] of the corresponding object for more information.
182
+
183
+ > [!NOTE]
184
+ > Some fields are stored as references to other tables, for example the artist of a track.
185
+ > Check the [documentation][db6-doc] of the corresponding object for more information.
186
+
180
187
  So far only a few tables support adding or deleting entries:
181
188
  - ``DjmdContent``: Tracks
182
189
  - ``DjmdPlaylist``: Playlists/Playlist Folders
@@ -0,0 +1,25 @@
1
+ pyrekordbox/__init__.py,sha256=hvg2dB7qhgGB6SmcRg8AION6mTGdlsThdHhsDZ9WYfs,622
2
+ pyrekordbox/__main__.py,sha256=ogn1wEOue1RUjGA0BxmgVIphcSCkoM1jZKRND0cAVLA,6016
3
+ pyrekordbox/_version.py,sha256=_F8vLxUxrAtC2alXNPGVa9l3P6_vLpQAzemS6QlnPGQ,511
4
+ pyrekordbox/config.py,sha256=yKRE0N_YU68kek-7bz0vouTgoQOVMXGKLZA21ydePd0,28333
5
+ pyrekordbox/logger.py,sha256=dq1BtXBGavuAjuc45mvjF6mOWaeZqZFzo2aBOJdJ0Ik,483
6
+ pyrekordbox/rbxml.py,sha256=4qx7YX2UegsYQ2ETfqR-Qyus-8jQ89mer1YjEvq1WtQ,38422
7
+ pyrekordbox/utils.py,sha256=hkYIgG5U4rzl2tjN9ESzLnf8OysEFybRQgmr6J7xq-k,4363
8
+ pyrekordbox/anlz/__init__.py,sha256=SEVY0oPX9ohCVViUbsoOLTrBrFewTh-61qJxwXgAJKg,3155
9
+ pyrekordbox/anlz/file.py,sha256=F6axHmprnp0j3pZkqmmp5iiJBUpqtWiAhSzlAJp2H6Y,6951
10
+ pyrekordbox/anlz/structs.py,sha256=Lt4fkb3SAE8w146eWeWGnpgRoP6jhLMWrSMoMwPjG04,7925
11
+ pyrekordbox/anlz/tags.py,sha256=nlPBKyRB8Z9J69bX2K8ZPQk_g1tMazKeVf2ViAqMX-c,13947
12
+ pyrekordbox/db6/__init__.py,sha256=TZX_BPGZIkc4zSTULIc8yd_bf91MAezGtZevKNh3kZ0,856
13
+ pyrekordbox/db6/aux_files.py,sha256=MehdQSc4iryiHvH8RfE9_9xMnD5qjRRDhTo0o0KRLw0,7592
14
+ pyrekordbox/db6/database.py,sha256=IHEEg8X3BiQF2MMfvYg36BvL6yqYY_jpCO-ysP8tU3Q,80738
15
+ pyrekordbox/db6/registry.py,sha256=zYq_B7INM97de7vUTxqmA4N_P53loDkBkYdzdoeSnvY,9725
16
+ pyrekordbox/db6/smartlist.py,sha256=GG6jE0BHMtx0RHgiVRpWYuL-Ex5edTNSLSDQNiCGzK0,12170
17
+ pyrekordbox/db6/tables.py,sha256=rQZ1qtJBtxiSrvmqGQEG61um5sf6UL5GeppFKO2p_zo,68539
18
+ pyrekordbox/mysettings/__init__.py,sha256=6iLTQ1KIjuoq8Zt3thmkjqJSxrRVIi7BrQpxNcsQK04,706
19
+ pyrekordbox/mysettings/file.py,sha256=JBfVe3jsmah_mGJjyC20_EqJZyJ7ftcOcCkRDKcWgv0,12671
20
+ pyrekordbox/mysettings/structs.py,sha256=5Y1F3qTmsP1fRB39_BEHpQVxKx2DO9BytEuJUG_RNcY,8472
21
+ pyrekordbox-0.4.2.dist-info/licenses/LICENSE,sha256=VwG9ZgC2UZnI0gTezGz1qkcAZ7sknBUQ1M62Z2nht54,1074
22
+ pyrekordbox-0.4.2.dist-info/METADATA,sha256=XhI0TjehrWsupNOK1AEQcomb29sE2ydlb2wuU9WEXQ0,15480
23
+ pyrekordbox-0.4.2.dist-info/WHEEL,sha256=pxyMxgL8-pra_rKaQ4drOZAegBVuX-G_4nRHjjgWbmo,91
24
+ pyrekordbox-0.4.2.dist-info/top_level.txt,sha256=bUHkyxIHZDgSB6zhYnF1o4Yf1EQlTGGIkVRq9uEtsa4,12
25
+ pyrekordbox-0.4.2.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.8.0)
2
+ Generator: setuptools (79.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,25 +0,0 @@
1
- pyrekordbox/__init__.py,sha256=hvg2dB7qhgGB6SmcRg8AION6mTGdlsThdHhsDZ9WYfs,622
2
- pyrekordbox/__main__.py,sha256=BtyDwVSGyoURY9Cy004vRL5Tgkwt4F7eXX_GD_vVYNQ,5976
3
- pyrekordbox/_version.py,sha256=j90u3VVU4UrJf1fgMUhaZarHK_Do2XGYXr-vZvOFzVo,411
4
- pyrekordbox/config.py,sha256=_mbzXaykU184XB2hxIBkFu0jlfmen1FKjufMrsZW7o8,27314
5
- pyrekordbox/logger.py,sha256=dq1BtXBGavuAjuc45mvjF6mOWaeZqZFzo2aBOJdJ0Ik,483
6
- pyrekordbox/rbxml.py,sha256=IfFjpuzcuX5pMs7eZEuC5GDA1dXE-BgkPz9EIQmQRhU,38390
7
- pyrekordbox/utils.py,sha256=hkYIgG5U4rzl2tjN9ESzLnf8OysEFybRQgmr6J7xq-k,4363
8
- pyrekordbox/anlz/__init__.py,sha256=SEVY0oPX9ohCVViUbsoOLTrBrFewTh-61qJxwXgAJKg,3155
9
- pyrekordbox/anlz/file.py,sha256=F6axHmprnp0j3pZkqmmp5iiJBUpqtWiAhSzlAJp2H6Y,6951
10
- pyrekordbox/anlz/structs.py,sha256=Lt4fkb3SAE8w146eWeWGnpgRoP6jhLMWrSMoMwPjG04,7925
11
- pyrekordbox/anlz/tags.py,sha256=2JfideIkjzAnxXwSPWCi-Nb3t6waUszv_nGd6AJQZYM,14097
12
- pyrekordbox/db6/__init__.py,sha256=TZX_BPGZIkc4zSTULIc8yd_bf91MAezGtZevKNh3kZ0,856
13
- pyrekordbox/db6/aux_files.py,sha256=MehdQSc4iryiHvH8RfE9_9xMnD5qjRRDhTo0o0KRLw0,7592
14
- pyrekordbox/db6/database.py,sha256=Qx_prD3wF8laH7gMoJpG-iLM6l3Y_1JMkW1CSrw34Og,80960
15
- pyrekordbox/db6/registry.py,sha256=zYq_B7INM97de7vUTxqmA4N_P53loDkBkYdzdoeSnvY,9725
16
- pyrekordbox/db6/smartlist.py,sha256=gmD8koOIrzAHEiqQ90EHmx7WWZMuhrx4FO8k3r-YXeA,12204
17
- pyrekordbox/db6/tables.py,sha256=KDEqPVZ63VAyqUNBfBTqLp-xZ0RYLBcovs7iIUczt4M,68362
18
- pyrekordbox/mysettings/__init__.py,sha256=6iLTQ1KIjuoq8Zt3thmkjqJSxrRVIi7BrQpxNcsQK04,706
19
- pyrekordbox/mysettings/file.py,sha256=JBfVe3jsmah_mGJjyC20_EqJZyJ7ftcOcCkRDKcWgv0,12671
20
- pyrekordbox/mysettings/structs.py,sha256=5Y1F3qTmsP1fRB39_BEHpQVxKx2DO9BytEuJUG_RNcY,8472
21
- pyrekordbox-0.4.0.dist-info/LICENSE,sha256=VwG9ZgC2UZnI0gTezGz1qkcAZ7sknBUQ1M62Z2nht54,1074
22
- pyrekordbox-0.4.0.dist-info/METADATA,sha256=u6RWvAqSJpF0rD2Rq7inisM4YKlhFG40stdSZuzAigk,15799
23
- pyrekordbox-0.4.0.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
24
- pyrekordbox-0.4.0.dist-info/top_level.txt,sha256=bUHkyxIHZDgSB6zhYnF1o4Yf1EQlTGGIkVRq9uEtsa4,12
25
- pyrekordbox-0.4.0.dist-info/RECORD,,