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.
- pyrekordbox/__init__.py +8 -8
- pyrekordbox/__main__.py +3 -2
- pyrekordbox/_version.py +2 -2
- pyrekordbox/anlz/__init__.py +3 -2
- pyrekordbox/anlz/file.py +4 -2
- pyrekordbox/anlz/tags.py +3 -1
- pyrekordbox/config.py +79 -23
- pyrekordbox/db6/__init__.py +2 -2
- pyrekordbox/db6/aux_files.py +3 -2
- pyrekordbox/db6/database.py +227 -143
- pyrekordbox/db6/registry.py +1 -0
- pyrekordbox/db6/smartlist.py +375 -0
- pyrekordbox/db6/tables.py +81 -20
- pyrekordbox/logger.py +0 -1
- pyrekordbox/mysettings/__init__.py +5 -4
- pyrekordbox/mysettings/file.py +3 -1
- pyrekordbox/rbxml.py +5 -3
- pyrekordbox/utils.py +4 -3
- {pyrekordbox-0.3.1.dist-info → pyrekordbox-0.4.0.dist-info}/LICENSE +1 -1
- {pyrekordbox-0.3.1.dist-info → pyrekordbox-0.4.0.dist-info}/METADATA +26 -42
- pyrekordbox-0.4.0.dist-info/RECORD +25 -0
- {pyrekordbox-0.3.1.dist-info → pyrekordbox-0.4.0.dist-info}/WHEEL +1 -1
- {pyrekordbox-0.3.1.dist-info → pyrekordbox-0.4.0.dist-info}/top_level.txt +0 -2
- docs/Makefile +0 -20
- docs/make.bat +0 -35
- docs/source/_static/images/anlz_beat.svg +0 -53
- docs/source/_static/images/anlz_file.svg +0 -204
- docs/source/_static/images/anlz_pco2.svg +0 -138
- docs/source/_static/images/anlz_pcob.svg +0 -148
- docs/source/_static/images/anlz_pcp2.svg +0 -398
- docs/source/_static/images/anlz_pcpt.svg +0 -263
- docs/source/_static/images/anlz_ppth.svg +0 -123
- docs/source/_static/images/anlz_pqt2.svg +0 -324
- docs/source/_static/images/anlz_pqt2_2.svg +0 -253
- docs/source/_static/images/anlz_pqtz.svg +0 -140
- docs/source/_static/images/anlz_pssi.svg +0 -192
- docs/source/_static/images/anlz_pssi_entry.svg +0 -191
- docs/source/_static/images/anlz_pvbr.svg +0 -125
- docs/source/_static/images/anlz_pwav.svg +0 -130
- docs/source/_static/images/anlz_pwv3.svg +0 -139
- docs/source/_static/images/anlz_pwv4.svg +0 -139
- docs/source/_static/images/anlz_pwv5.svg +0 -139
- docs/source/_static/images/anlz_pwv5_entry.svg +0 -100
- docs/source/_static/images/anlz_pwv6.svg +0 -130
- docs/source/_static/images/anlz_pwv7.svg +0 -139
- docs/source/_static/images/anlz_pwvc.svg +0 -125
- docs/source/_static/images/anlz_tag.svg +0 -110
- docs/source/_static/images/x64dbg_rb_key.png +0 -0
- docs/source/_static/logos/dark/logo_primary.svg +0 -75
- docs/source/_static/logos/light/logo_primary.svg +0 -75
- docs/source/_static/logos/mid/logo_primary.svg +0 -75
- docs/source/_templates/apidoc/module.rst_t +0 -8
- docs/source/_templates/apidoc/package.rst_t +0 -57
- docs/source/_templates/apidoc/toc.rst_t +0 -7
- docs/source/_templates/autosummary/class.rst +0 -32
- docs/source/_templates/autosummary/module.rst +0 -55
- docs/source/api.md +0 -18
- docs/source/conf.py +0 -178
- docs/source/development/changes.md +0 -3
- docs/source/development/contributing.md +0 -3
- docs/source/formats/anlz.md +0 -634
- docs/source/formats/db6.md +0 -1233
- docs/source/formats/mysetting.md +0 -392
- docs/source/formats/xml.md +0 -376
- docs/source/index.md +0 -103
- docs/source/installation.md +0 -271
- docs/source/key.md +0 -103
- docs/source/quickstart.md +0 -185
- docs/source/requirements.txt +0 -7
- docs/source/tutorial/anlz.md +0 -7
- docs/source/tutorial/configuration.md +0 -66
- docs/source/tutorial/db6.md +0 -178
- docs/source/tutorial/index.md +0 -20
- docs/source/tutorial/mysetting.md +0 -124
- docs/source/tutorial/xml.md +0 -140
- pyrekordbox/db6/smart_playlist.py +0 -333
- pyrekordbox/xml.py +0 -8
- pyrekordbox-0.3.1.dist-info/RECORD +0 -84
- tests/__init__.py +0 -3
- tests/test_anlz.py +0 -206
- tests/test_config.py +0 -175
- tests/test_db6.py +0 -1115
- tests/test_mysetting.py +0 -203
- 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
|
13
|
-
from
|
14
|
-
|
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
|
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
|
-
|
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(
|
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
@@ -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$")
|
pyrekordbox/mysettings/file.py
CHANGED
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
|
-
|
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
|
----------
|