pyrekordbox 0.3.1__py3-none-any.whl → 0.4.0__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.
Files changed (84) hide show
  1. pyrekordbox/__init__.py +8 -8
  2. pyrekordbox/__main__.py +3 -2
  3. pyrekordbox/_version.py +2 -2
  4. pyrekordbox/anlz/__init__.py +3 -2
  5. pyrekordbox/anlz/file.py +4 -2
  6. pyrekordbox/anlz/tags.py +3 -1
  7. pyrekordbox/config.py +79 -23
  8. pyrekordbox/db6/__init__.py +2 -2
  9. pyrekordbox/db6/aux_files.py +3 -2
  10. pyrekordbox/db6/database.py +227 -143
  11. pyrekordbox/db6/registry.py +1 -0
  12. pyrekordbox/db6/smartlist.py +375 -0
  13. pyrekordbox/db6/tables.py +81 -20
  14. pyrekordbox/logger.py +0 -1
  15. pyrekordbox/mysettings/__init__.py +5 -4
  16. pyrekordbox/mysettings/file.py +3 -1
  17. pyrekordbox/rbxml.py +5 -3
  18. pyrekordbox/utils.py +4 -3
  19. {pyrekordbox-0.3.1.dist-info → pyrekordbox-0.4.0.dist-info}/LICENSE +1 -1
  20. {pyrekordbox-0.3.1.dist-info → pyrekordbox-0.4.0.dist-info}/METADATA +26 -42
  21. pyrekordbox-0.4.0.dist-info/RECORD +25 -0
  22. {pyrekordbox-0.3.1.dist-info → pyrekordbox-0.4.0.dist-info}/WHEEL +1 -1
  23. {pyrekordbox-0.3.1.dist-info → pyrekordbox-0.4.0.dist-info}/top_level.txt +0 -2
  24. docs/Makefile +0 -20
  25. docs/make.bat +0 -35
  26. docs/source/_static/images/anlz_beat.svg +0 -53
  27. docs/source/_static/images/anlz_file.svg +0 -204
  28. docs/source/_static/images/anlz_pco2.svg +0 -138
  29. docs/source/_static/images/anlz_pcob.svg +0 -148
  30. docs/source/_static/images/anlz_pcp2.svg +0 -398
  31. docs/source/_static/images/anlz_pcpt.svg +0 -263
  32. docs/source/_static/images/anlz_ppth.svg +0 -123
  33. docs/source/_static/images/anlz_pqt2.svg +0 -324
  34. docs/source/_static/images/anlz_pqt2_2.svg +0 -253
  35. docs/source/_static/images/anlz_pqtz.svg +0 -140
  36. docs/source/_static/images/anlz_pssi.svg +0 -192
  37. docs/source/_static/images/anlz_pssi_entry.svg +0 -191
  38. docs/source/_static/images/anlz_pvbr.svg +0 -125
  39. docs/source/_static/images/anlz_pwav.svg +0 -130
  40. docs/source/_static/images/anlz_pwv3.svg +0 -139
  41. docs/source/_static/images/anlz_pwv4.svg +0 -139
  42. docs/source/_static/images/anlz_pwv5.svg +0 -139
  43. docs/source/_static/images/anlz_pwv5_entry.svg +0 -100
  44. docs/source/_static/images/anlz_pwv6.svg +0 -130
  45. docs/source/_static/images/anlz_pwv7.svg +0 -139
  46. docs/source/_static/images/anlz_pwvc.svg +0 -125
  47. docs/source/_static/images/anlz_tag.svg +0 -110
  48. docs/source/_static/images/x64dbg_rb_key.png +0 -0
  49. docs/source/_static/logos/dark/logo_primary.svg +0 -75
  50. docs/source/_static/logos/light/logo_primary.svg +0 -75
  51. docs/source/_static/logos/mid/logo_primary.svg +0 -75
  52. docs/source/_templates/apidoc/module.rst_t +0 -8
  53. docs/source/_templates/apidoc/package.rst_t +0 -57
  54. docs/source/_templates/apidoc/toc.rst_t +0 -7
  55. docs/source/_templates/autosummary/class.rst +0 -32
  56. docs/source/_templates/autosummary/module.rst +0 -55
  57. docs/source/api.md +0 -18
  58. docs/source/conf.py +0 -178
  59. docs/source/development/changes.md +0 -3
  60. docs/source/development/contributing.md +0 -3
  61. docs/source/formats/anlz.md +0 -634
  62. docs/source/formats/db6.md +0 -1233
  63. docs/source/formats/mysetting.md +0 -392
  64. docs/source/formats/xml.md +0 -376
  65. docs/source/index.md +0 -103
  66. docs/source/installation.md +0 -271
  67. docs/source/key.md +0 -103
  68. docs/source/quickstart.md +0 -185
  69. docs/source/requirements.txt +0 -7
  70. docs/source/tutorial/anlz.md +0 -7
  71. docs/source/tutorial/configuration.md +0 -66
  72. docs/source/tutorial/db6.md +0 -178
  73. docs/source/tutorial/index.md +0 -20
  74. docs/source/tutorial/mysetting.md +0 -124
  75. docs/source/tutorial/xml.md +0 -140
  76. pyrekordbox/db6/smart_playlist.py +0 -333
  77. pyrekordbox/xml.py +0 -8
  78. pyrekordbox-0.3.1.dist-info/RECORD +0 -84
  79. tests/__init__.py +0 -3
  80. tests/test_anlz.py +0 -206
  81. tests/test_config.py +0 -175
  82. tests/test_db6.py +0 -1115
  83. tests/test_mysetting.py +0 -203
  84. tests/test_xml.py +0 -629
@@ -0,0 +1,375 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Author: Dylan Jones
3
+ # Date: 2023-12-13
4
+
5
+ import logging
6
+ import xml.etree.cElementTree as xml
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from enum import Enum, IntEnum
10
+ from typing import List, Union
11
+
12
+ from dateutil.relativedelta import relativedelta # noqa
13
+ from sqlalchemy import and_, not_, or_
14
+ from sqlalchemy.sql.elements import BooleanClauseList
15
+
16
+ from .tables import DjmdContent
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ __all__ = [
21
+ "LogicalOperator",
22
+ "Property",
23
+ "Operator",
24
+ "Condition",
25
+ "SmartList",
26
+ ]
27
+
28
+
29
+ class LogicalOperator(IntEnum):
30
+ ALL = 1
31
+ ANY = 2
32
+
33
+
34
+ class Operator(IntEnum):
35
+ EQUAL = 1
36
+ NOT_EQUAL = 2
37
+ GREATER = 3
38
+ LESS = 4
39
+ IN_RANGE = 5
40
+ IN_LAST = 6
41
+ NOT_IN_LAST = 7
42
+ CONTAINS = 8
43
+ NOT_CONTAINS = 9
44
+ STARTS_WITH = 10
45
+ ENDS_WITH = 11
46
+
47
+
48
+ class Property(str, Enum):
49
+ ARTIST = "artist"
50
+ ALBUM = "album"
51
+ ALBUM_ARTIST = "albumArtist"
52
+ ORIGINAL_ARTIST = "originalArtist"
53
+ BPM = "bpm"
54
+ GROUPING = "grouping"
55
+ COMMENTS = "comments"
56
+ PRODUCER = "producer"
57
+ STOCK_DATE = "stockDate"
58
+ DATE_CREATED = "dateCreated"
59
+ COUNTER = "counter"
60
+ FILENAME = "fileName"
61
+ GENRE = "genre"
62
+ KEY = "key"
63
+ LABEL = "label"
64
+ MIX_NAME = "mixName"
65
+ MYTAG = "myTag"
66
+ RATING = "rating"
67
+ DATE_RELEASED = "dateReleased"
68
+ REMIXED_BY = "remixedBy"
69
+ DURATION = "duration"
70
+ NAME = "name"
71
+ YEAR = "year"
72
+
73
+
74
+ _STR_OPS = [
75
+ Operator.EQUAL,
76
+ Operator.NOT_EQUAL,
77
+ Operator.CONTAINS,
78
+ Operator.NOT_CONTAINS,
79
+ Operator.STARTS_WITH,
80
+ Operator.ENDS_WITH,
81
+ ]
82
+
83
+ _NUM_OPS = [
84
+ Operator.EQUAL,
85
+ Operator.NOT_EQUAL,
86
+ Operator.GREATER,
87
+ Operator.LESS,
88
+ Operator.IN_RANGE,
89
+ ]
90
+
91
+ _DATE_OPS = [
92
+ Operator.EQUAL,
93
+ Operator.NOT_EQUAL,
94
+ Operator.GREATER,
95
+ Operator.LESS,
96
+ Operator.IN_RANGE,
97
+ Operator.IN_LAST,
98
+ Operator.NOT_IN_LAST,
99
+ ]
100
+
101
+ # Defines the valid operators for each property
102
+ VALID_OPS = {
103
+ Property.ARTIST: _STR_OPS,
104
+ Property.ALBUM: _STR_OPS,
105
+ Property.ALBUM_ARTIST: _STR_OPS,
106
+ Property.ORIGINAL_ARTIST: _STR_OPS,
107
+ Property.BPM: _NUM_OPS,
108
+ Property.GROUPING: [Operator.EQUAL, Operator.NOT_EQUAL],
109
+ Property.COMMENTS: _STR_OPS,
110
+ Property.PRODUCER: _STR_OPS,
111
+ Property.STOCK_DATE: _DATE_OPS,
112
+ Property.DATE_CREATED: _DATE_OPS,
113
+ Property.COUNTER: _NUM_OPS,
114
+ Property.FILENAME: _STR_OPS,
115
+ Property.GENRE: _STR_OPS,
116
+ Property.KEY: _STR_OPS,
117
+ Property.LABEL: _STR_OPS,
118
+ Property.MIX_NAME: _STR_OPS,
119
+ Property.MYTAG: [Operator.CONTAINS, Operator.NOT_CONTAINS],
120
+ Property.RATING: _NUM_OPS,
121
+ Property.DATE_RELEASED: _DATE_OPS,
122
+ Property.REMIXED_BY: _STR_OPS,
123
+ Property.DURATION: _NUM_OPS,
124
+ Property.NAME: _STR_OPS,
125
+ Property.YEAR: _NUM_OPS,
126
+ }
127
+
128
+ # Defines the column names in the DB for properties that are directly mapped
129
+ PROPERTY_COLUMN_MAP = {
130
+ Property.ARTIST: "ArtistName",
131
+ Property.ALBUM: "AlbumName",
132
+ Property.ALBUM_ARTIST: "AlbumArtistName",
133
+ Property.ORIGINAL_ARTIST: "OrgArtistName",
134
+ Property.BPM: "BPM",
135
+ Property.GROUPING: "ColorID",
136
+ Property.COMMENTS: "Commnt",
137
+ Property.PRODUCER: "ComposerName",
138
+ Property.STOCK_DATE: "StockDate",
139
+ Property.DATE_CREATED: "created_at",
140
+ Property.COUNTER: "DJPlayCount",
141
+ Property.FILENAME: "FileNameL",
142
+ Property.GENRE: "GenreName",
143
+ Property.KEY: "KeyName",
144
+ Property.LABEL: "LabelName",
145
+ # Property.MIX_NAME don't know what this maps to
146
+ Property.MYTAG: "MyTagIDs",
147
+ Property.RATING: "Rating",
148
+ Property.DATE_RELEASED: "ReleaseDate",
149
+ Property.REMIXED_BY: "RemixerName",
150
+ Property.DURATION: "Length",
151
+ Property.NAME: "Title",
152
+ Property.YEAR: "ReleaseYear",
153
+ }
154
+
155
+ TYPE_CONVERSION = {
156
+ Property.BPM: int,
157
+ Property.STOCK_DATE: lambda x: datetime.strptime(x, "%Y-%m-%d"),
158
+ Property.DATE_CREATED: lambda x: datetime.strptime(x, "%Y-%m-%d"),
159
+ Property.COUNTER: int,
160
+ Property.RATING: int,
161
+ Property.DATE_RELEASED: lambda x: datetime.strptime(x, "%Y-%m-%d"),
162
+ Property.DURATION: int,
163
+ Property.YEAR: int,
164
+ }
165
+
166
+ PROPERTIES = [str(p.value) for p in list(Property)] # noqa
167
+
168
+
169
+ @dataclass
170
+ class Condition:
171
+ """Dataclass for a smart playlist condition."""
172
+
173
+ property: str
174
+ operator: int
175
+ unit: str
176
+ value_left: Union[str, int]
177
+ value_right: Union[str, int]
178
+
179
+ def __post_init__(self):
180
+ if self.property not in PROPERTIES:
181
+ raise ValueError(
182
+ f"Invalid property: '{self.property}'! "
183
+ f"Supported properties: {PROPERTIES}"
184
+ )
185
+
186
+ valid_ops = VALID_OPS[self.property]
187
+ if self.operator not in valid_ops:
188
+ raise ValueError(
189
+ f"Invalid operator '{self.operator}' for '{self.property}', "
190
+ f"must be one of {valid_ops}"
191
+ )
192
+
193
+ if self.operator == Operator.IN_RANGE:
194
+ if not self.value_right:
195
+ raise ValueError(f"Operator '{self.operator}' requires `value_right`")
196
+
197
+
198
+ def left_bitshift(x: int, nbit: int = 32) -> int:
199
+ """Left shifts an N bit integer with sign change."""
200
+ return x - 2**nbit
201
+
202
+
203
+ def right_bitshift(x: int, nbit: int = 32) -> int:
204
+ """Right shifts an N bit integer with sign change."""
205
+ return x + 2**nbit
206
+
207
+
208
+ def _get_condition_values(cond):
209
+ val_left = cond.value_left
210
+ val_right = cond.value_right
211
+ func = None
212
+ if cond.operator in (Operator.IN_LAST, Operator.NOT_IN_LAST):
213
+ func = int
214
+ elif cond.property in TYPE_CONVERSION:
215
+ func = TYPE_CONVERSION[cond.property]
216
+
217
+ if func is not None:
218
+ if val_left != "":
219
+ val_left = func(val_left)
220
+ if val_right != "":
221
+ try:
222
+ val_right = func(val_right)
223
+ except ValueError:
224
+ pass
225
+
226
+ if val_left == "":
227
+ val_left = None
228
+
229
+ return val_left, val_right
230
+
231
+
232
+ class SmartList:
233
+ """Rekordbox smart playlist XML handler."""
234
+
235
+ def __init__(
236
+ self, logical_operator: int = LogicalOperator.ALL, auto_update: int = 0
237
+ ):
238
+ self.playlist_id: Union[int, str] = ""
239
+ self.logical_operator: int = int(logical_operator)
240
+ self.auto_update: int = auto_update
241
+ self.conditions: List[Condition] = list()
242
+
243
+ def parse(self, source: str) -> None:
244
+ """Parse the XML source of a smart playlist."""
245
+ tree = xml.ElementTree(xml.fromstring(source))
246
+ root = tree.getroot()
247
+ conditions = list()
248
+ for child in root.findall("CONDITION"):
249
+ condition = Condition(
250
+ property=child.attrib["PropertyName"],
251
+ operator=int(child.attrib["Operator"]),
252
+ unit=child.attrib["ValueUnit"],
253
+ value_left=child.attrib["ValueLeft"],
254
+ value_right=child.attrib["ValueRight"],
255
+ )
256
+ conditions.append(condition)
257
+
258
+ self.playlist_id = str(right_bitshift(int(root.attrib["Id"])))
259
+ self.logical_operator = int(root.attrib["LogicalOperator"])
260
+ self.auto_update = int(root.attrib["AutomaticUpdate"])
261
+ self.conditions = conditions
262
+
263
+ def to_xml(self) -> str:
264
+ """Convert the smart playlist conditions to XML."""
265
+ attrib = {
266
+ "Id": str(left_bitshift(int(self.playlist_id))),
267
+ "LogicalOperator": str(self.logical_operator),
268
+ "AutomaticUpdate": str(self.auto_update),
269
+ }
270
+ root = xml.Element("NODE", attrib=attrib)
271
+ for cond in self.conditions:
272
+ attrib = {
273
+ "PropertyName": str(cond.property),
274
+ "Operator": str(cond.operator),
275
+ "ValueUnit": str(cond.unit),
276
+ "ValueLeft": str(cond.value_left),
277
+ "ValueRight": str(cond.value_right),
278
+ }
279
+ xml.SubElement(root, "CONDITION", attrib=attrib)
280
+ return xml.tostring(root).decode("utf-8").replace(" /", "/")
281
+
282
+ def add_condition(
283
+ self,
284
+ prop: str,
285
+ operator: int,
286
+ value_left: str,
287
+ value_right: str = "",
288
+ unit: str = "",
289
+ ) -> None:
290
+ """Add a condition to the smart playlist.
291
+
292
+ Parameters
293
+ ----------
294
+ prop : str
295
+ The property to filter on.
296
+ operator : {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11} int
297
+ The operator to use. Must be in the range of 1-11
298
+ value_left : str
299
+ The left value to use.
300
+ value_right : str, optional
301
+ The right value to use, by default "".
302
+ unit : str, optional
303
+ The unit to use, by default "".
304
+ """
305
+ if isinstance(prop, Property):
306
+ prop = str(prop.value)
307
+ cond = Condition(prop, int(operator), unit, value_left, value_right)
308
+ self.conditions.append(cond)
309
+
310
+ def filter_clause(self) -> BooleanClauseList:
311
+ """Return a SQLAlchemy filter clause matching the content of the smart playlist.
312
+
313
+ Returns
314
+ -------
315
+ BooleanClauseList
316
+ A filter list macthing the contents of the smart playlist.
317
+ """
318
+ logical_op = and_ if self.logical_operator == LogicalOperator.ALL else or_
319
+
320
+ comps = list()
321
+ for cond in self.conditions:
322
+ val_left, val_right = _get_condition_values(cond)
323
+ # val_left = str(-abs(int(val_left))) if val_left is not None else ""
324
+ if cond.property in PROPERTY_COLUMN_MAP:
325
+ colum_name = PROPERTY_COLUMN_MAP[cond.property]
326
+ if cond.property == Property.MYTAG:
327
+ if int(val_left) < 0:
328
+ val_left = str(right_bitshift(int(val_left)))
329
+
330
+ if cond.operator == Operator.EQUAL:
331
+ comp = getattr(DjmdContent, colum_name) == val_left
332
+ elif cond.operator == Operator.NOT_EQUAL:
333
+ comp = getattr(DjmdContent, colum_name) != val_left
334
+ elif cond.operator == Operator.GREATER:
335
+ comp = getattr(DjmdContent, colum_name) > val_left
336
+ elif cond.operator == Operator.LESS:
337
+ comp = getattr(DjmdContent, colum_name) < val_left
338
+ elif cond.operator == Operator.IN_RANGE:
339
+ comp = getattr(DjmdContent, colum_name).between(val_left, val_right)
340
+ elif cond.operator == Operator.CONTAINS:
341
+ comp = getattr(DjmdContent, colum_name).contains(val_left)
342
+ elif cond.operator == Operator.NOT_CONTAINS:
343
+ comp = not_(getattr(DjmdContent, colum_name).contains(val_left))
344
+ elif cond.operator == Operator.STARTS_WITH:
345
+ comp = getattr(DjmdContent, colum_name).startswith(val_left)
346
+ elif cond.operator == Operator.ENDS_WITH:
347
+ comp = getattr(DjmdContent, colum_name).endswith(val_left)
348
+ elif cond.operator == Operator.IN_LAST:
349
+ now = datetime.now()
350
+ if cond.unit == "day":
351
+ t0 = now - relativedelta(days=val_left)
352
+ comp = getattr(DjmdContent, colum_name) > t0
353
+ elif cond.unit == "month":
354
+ t0 = now - relativedelta(months=val_left)
355
+ comp = getattr(DjmdContent, colum_name).month > t0
356
+ else:
357
+ raise ValueError(f"Unknown unit '{cond.unit}'")
358
+ elif cond.operator == Operator.NOT_IN_LAST:
359
+ now = datetime.now()
360
+ if cond.unit == "day":
361
+ t0 = now - relativedelta(days=val_left)
362
+ comp = getattr(DjmdContent, colum_name) < t0
363
+ elif cond.unit == "month":
364
+ t0 = now - relativedelta(months=val_left)
365
+ comp = getattr(DjmdContent, colum_name).month < t0
366
+ else:
367
+ raise ValueError(f"Unknown unit '{cond.unit}'")
368
+ else:
369
+ raise ValueError(f"Unknown operator '{cond.operator}'")
370
+ comps.append(comp)
371
+
372
+ else:
373
+ logger.warning(f"Unsupported property '{cond.property}'")
374
+
375
+ return logical_op(*comps)
pyrekordbox/db6/tables.py CHANGED
@@ -5,15 +5,28 @@
5
5
  """Rekordbox 6 `master.db` SQLAlchemy table declarations."""
6
6
 
7
7
  import math
8
+ import re
8
9
  import struct
9
- import numpy as np
10
- from enum import IntEnum
11
10
  from datetime import datetime
12
- from sqlalchemy import Column, Integer, VARCHAR, BigInteger, SmallInteger, Text, Float
13
- from sqlalchemy import ForeignKey, TypeDecorator
14
- from sqlalchemy.orm import DeclarativeBase, relationship, backref, mapped_column, Mapped
11
+ from enum import IntEnum
12
+ from typing import List
13
+
14
+ import numpy as np
15
+ from sqlalchemy import (
16
+ VARCHAR,
17
+ BigInteger,
18
+ Column,
19
+ Float,
20
+ ForeignKey,
21
+ Integer,
22
+ SmallInteger,
23
+ Text,
24
+ TypeDecorator,
25
+ )
15
26
  from sqlalchemy.ext.associationproxy import association_proxy
16
27
  from sqlalchemy.inspection import inspect
28
+ from sqlalchemy.orm import DeclarativeBase, Mapped, backref, mapped_column, relationship
29
+
17
30
  from .registry import RekordboxAgentRegistry
18
31
 
19
32
  __all__ = [
@@ -106,7 +119,6 @@ class DateTime(TypeDecorator):
106
119
  """Custom datetime column with timezone support.
107
120
 
108
121
  The datetime format in the database is `YYYY-MM-DD HH:MM:SS.SSS +00:00`.
109
- The timezone seems to always be `+00:00` (UTC).
110
122
  This format is not supported by the `DateTime` column of SQLAlchemy 2.
111
123
  """
112
124
 
@@ -125,7 +137,7 @@ class DateTime(TypeDecorator):
125
137
  datestr, tzinfo = value[:23], value[23:]
126
138
  datestr = datestr.strip()
127
139
  tzinfo = tzinfo.strip()
128
- assert tzinfo == "+00:00", tzinfo
140
+ assert re.match(r"^\+\d{2}:\d{2}$", tzinfo)
129
141
  else:
130
142
  datestr, tzinfo = value, ""
131
143
  dt = datetime.fromisoformat(datestr)
@@ -139,6 +151,15 @@ class PlaylistType(IntEnum):
139
151
  SMART_PLAYLIST = 4
140
152
 
141
153
 
154
+ class FileType(IntEnum):
155
+ MP3 = 1
156
+ M4A = 4
157
+ FLAC = 5
158
+ WAV = 11
159
+ AIFF = 12
160
+ AIF = 12
161
+
162
+
142
163
  # -- Base- and Mixin classes -----------------------------------------------------------
143
164
 
144
165
 
@@ -146,6 +167,7 @@ class Base(DeclarativeBase):
146
167
  """Base class used to initialize the declarative base for all tables."""
147
168
 
148
169
  __tablename__: str
170
+ __keys__: List[str] = []
149
171
 
150
172
  @classmethod
151
173
  def create(cls, **kwargs):
@@ -164,13 +186,23 @@ class Base(DeclarativeBase):
164
186
  """Returns a list of all relationship names."""
165
187
  return [column.key for column in inspect(cls).relationships] # noqa
166
188
 
189
+ @classmethod
190
+ def __get_keys__(cls):
191
+ """Get all attributes of the table."""
192
+ items = cls.__dict__.items()
193
+ keys = [k for k, v in items if not callable(v) and not k.startswith("_")]
194
+ return keys
195
+
196
+ @classmethod
197
+ def keys(cls):
198
+ """Returns a list of all column names including the relationships."""
199
+ if not cls.__keys__: # Cache the keys
200
+ cls.__keys__ = cls.__get_keys__()
201
+ return cls.__keys__
202
+
167
203
  def __iter__(self):
168
204
  """Iterates over all columns and relationship names."""
169
- insp = inspect(self.__class__)
170
- for column in insp.c:
171
- yield column.name
172
- for column in insp.relationships: # noqa
173
- yield column.key
205
+ return iter(self.keys())
174
206
 
175
207
  def __len__(self):
176
208
  return sum(1 for _ in self.__iter__())
@@ -184,10 +216,6 @@ class Base(DeclarativeBase):
184
216
  RekordboxAgentRegistry.on_update(self, key, value)
185
217
  super().__setattr__(key, value)
186
218
 
187
- def keys(self):
188
- """Returns a list of all column names including the relationships."""
189
- return list(self.__iter__())
190
-
191
219
  def values(self):
192
220
  """Returns a list of all column values including the relationships."""
193
221
  return [self.__getitem__(key) for key in self.keys()]
@@ -455,7 +483,9 @@ class DjmdActiveCensor(Base, StatsFull):
455
483
  ContentUUID: Mapped[str] = mapped_column(VARCHAR(255), default=None)
456
484
  """The UUID of the :class:`DjmdContent` entry this censor belongs to."""
457
485
 
458
- Content = relationship("DjmdContent")
486
+ Content = relationship(
487
+ "DjmdContent", foreign_keys=ContentID, back_populates="ActiveCensors"
488
+ )
459
489
  """The content entry this censor belongs to (links to :class:`DjmdContent`)."""
460
490
 
461
491
 
@@ -486,6 +516,8 @@ class DjmdAlbum(Base, StatsFull):
486
516
 
487
517
  AlbumArtist = relationship("DjmdArtist")
488
518
  """The artist entry of the artist of this album (links to :class:`DjmdArtist`)."""
519
+ AlbumArtistName = association_proxy("AlbumArtist", "Name")
520
+ """The name of the album artist (:class:`DjmdArtist`) of the track."""
489
521
 
490
522
  def __repr__(self):
491
523
  s = f"{self.ID: <10} Name={self.Name}"
@@ -751,6 +783,18 @@ class DjmdContent(Base, StatsFull):
751
783
  """The color entry of the track (links to :class:`DjmdColor`)."""
752
784
  Composer = relationship("DjmdArtist", foreign_keys=ComposerID)
753
785
  """The composer entry of the track (links to :class:`DjmdArtist`)."""
786
+ AlbumArtist = association_proxy("Album", "AlbumArtist")
787
+ """The album artist entry of the track (links to :class:`DjmdArtist`)."""
788
+ MyTags = relationship("DjmdSongMyTag", back_populates="Content")
789
+ """The my tags of the track (links to :class:`DjmdSongMyTag`)."""
790
+ Cues = relationship(
791
+ "DjmdCue", foreign_keys="DjmdCue.ContentID", back_populates="Content"
792
+ )
793
+ """The cues of the track (links to :class:`DjmdCue`)."""
794
+ ActiveCensors = relationship("DjmdActiveCensor", back_populates="Content")
795
+ """The active censors of the track (links to :class:`DjmdActiveCensor`)."""
796
+ MixerParams = relationship("DjmdMixerParam", back_populates="Content")
797
+ """The mixer parameters of the track (links to :class:`DjmdMixerParam`)."""
754
798
 
755
799
  ArtistName = association_proxy("Artist", "Name")
756
800
  """The name of the artist (:class:`DjmdArtist`) of the track."""
@@ -770,6 +814,12 @@ class DjmdContent(Base, StatsFull):
770
814
  """The name of the color (:class:`DjmdColor`) of the track."""
771
815
  ComposerName = association_proxy("Composer", "Name")
772
816
  """The name of the composer (:class:`DjmdArtist`) of the track."""
817
+ AlbumArtistName = association_proxy("Album", "AlbumArtistName")
818
+ """The name of the album artist (:class:`DjmdArtist`) of the track."""
819
+ MyTagNames = association_proxy("MyTags", "MyTagName")
820
+ """The names of the my tags (:class:`DjmdSongMyTag`) of the track."""
821
+ MyTagIDs = association_proxy("MyTags", "MyTagID")
822
+ """The IDs of the my tags (:class:`DjmdSongMyTag`) of the track."""
773
823
 
774
824
  def __repr__(self):
775
825
  s = f"{self.ID: <10} Title={self.Title}"
@@ -831,9 +881,17 @@ class DjmdCue(Base, StatsFull):
831
881
  )
832
882
  """The UUID of the content (:class:`DjmdContent`) containing the cue point."""
833
883
 
834
- Content = relationship("DjmdContent", foreign_keys=ContentID)
884
+ Content = relationship("DjmdContent", foreign_keys=ContentID, back_populates="Cues")
835
885
  """The content entry of the cue point (links to :class:`DjmdContent`)."""
836
886
 
887
+ @property
888
+ def is_memory_cue(self):
889
+ return self.Kind == 0
890
+
891
+ @property
892
+ def is_hot_cue(self):
893
+ return self.Kind > 0
894
+
837
895
 
838
896
  class DjmdDevice(Base, StatsFull):
839
897
  """Table for storing the device data of the Rekordbox library contents."""
@@ -1115,7 +1173,7 @@ class DjmdMixerParam(Base, StatsFull):
1115
1173
  PeakLow: Mapped[int] = mapped_column(Integer, default=None)
1116
1174
  """The low peak of the mixer parameter."""
1117
1175
 
1118
- Content = relationship("DjmdContent")
1176
+ Content = relationship("DjmdContent", back_populates="MixerParams")
1119
1177
  """The content this mixer parameters belong to (links to :class:`DjmdContent`)."""
1120
1178
 
1121
1179
  @staticmethod
@@ -1229,9 +1287,12 @@ class DjmdSongMyTag(Base, StatsFull):
1229
1287
 
1230
1288
  MyTag = relationship("DjmdMyTag", back_populates="MyTags")
1231
1289
  """The My-Tag list this item belongs to (links to :class:`DjmdMyTag`)."""
1232
- Content = relationship("DjmdContent")
1290
+ Content = relationship("DjmdContent", back_populates="MyTags")
1233
1291
  """The content this item belongs to (links to :class:`DjmdContent`)."""
1234
1292
 
1293
+ MyTagName = association_proxy("MyTag", "Name")
1294
+ """The name of the My-Tag item (:class:`DjmdMyTag`)."""
1295
+
1235
1296
 
1236
1297
  class DjmdPlaylist(Base, StatsFull):
1237
1298
  """Table for storing playlists in the Rekordbox library.
pyrekordbox/logger.py CHANGED
@@ -20,4 +20,3 @@ logger.addHandler(sh)
20
20
 
21
21
  # Set logging level
22
22
  logger.setLevel(logging.WARNING)
23
- logging.root.setLevel(logging.NOTSET)
@@ -4,14 +4,15 @@
4
4
 
5
5
  import re
6
6
  from pathlib import Path
7
+
7
8
  from . import structs
8
9
  from .file import (
9
10
  FILES,
10
- SettingsFile,
11
- MySettingFile,
12
- MySetting2File,
13
- DjmMySettingFile,
14
11
  DevSettingFile,
12
+ DjmMySettingFile,
13
+ MySetting2File,
14
+ MySettingFile,
15
+ SettingsFile,
15
16
  )
16
17
 
17
18
  RE_MYSETTING = re.compile(".*SETTING[0-9]?.DAT$")
@@ -5,8 +5,10 @@
5
5
  """Rekordbox My-Setting file handlers."""
6
6
 
7
7
  import re
8
- from construct import Struct
9
8
  from collections.abc import MutableMapping
9
+
10
+ from construct import Struct
11
+
10
12
  from . import structs
11
13
 
12
14
  # fmt: off
pyrekordbox/rbxml.py CHANGED
@@ -7,10 +7,12 @@ r"""Rekordbox XML database file handler."""
7
7
  import logging
8
8
  import os.path
9
9
  import urllib.parse
10
+ import xml.etree.cElementTree as xml
10
11
  from abc import abstractmethod
11
12
  from collections import abc
12
- import xml.etree.cElementTree as xml
13
+
13
14
  import bidict
15
+
14
16
  from .utils import pretty_xml
15
17
 
16
18
  logger = logging.getLogger(__name__)
@@ -658,7 +660,7 @@ class Node:
658
660
 
659
661
  @property
660
662
  def key_type(self):
661
- """str: The type of key used by the playlist node"""
663
+ """str: The type of key used by the playlist node."""
662
664
  return NODE_KEYTYPE_MAPPING.get(self._element.attrib.get("KeyType"))
663
665
 
664
666
  @property
@@ -1280,7 +1282,7 @@ class RekordboxXml:
1280
1282
  The default is 3 spaces.
1281
1283
  """
1282
1284
  string = self.tostring(indent)
1283
- with open(path, "w") as fh:
1285
+ with open(path, "w", encoding="utf-8") as fh:
1284
1286
  fh.write(string)
1285
1287
 
1286
1288
  def __repr__(self):
pyrekordbox/utils.py CHANGED
@@ -6,9 +6,10 @@
6
6
 
7
7
  import os
8
8
  import warnings
9
- import psutil
10
- from xml.dom import minidom
11
9
  import xml.etree.cElementTree as xml
10
+ from xml.dom import minidom
11
+
12
+ import psutil
12
13
 
13
14
  warnings.simplefilter("always", DeprecationWarning)
14
15
 
@@ -122,7 +123,7 @@ def get_rekordbox_agent_pid(raise_exec=False):
122
123
 
123
124
 
124
125
  def pretty_xml(element, indent=None, encoding="utf-8"):
125
- """Generates a formatted string of an XML element.
126
+ r"""Generates a formatted string of an XML element.
126
127
 
127
128
  Parameters
128
129
  ----------
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2022-2023, Dylan Jones
3
+ Copyright (c) 2022-2024, 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