pyrekordbox 0.2.1__py3-none-any.whl → 0.2.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.
- docs/source/formats/anlz.md +178 -7
- docs/source/formats/db6.md +1 -1
- docs/source/index.md +2 -6
- docs/source/quickstart.md +68 -45
- docs/source/tutorial/index.md +1 -1
- pyrekordbox/__init__.py +1 -1
- pyrekordbox/_version.py +2 -2
- pyrekordbox/anlz/file.py +39 -0
- pyrekordbox/anlz/structs.py +3 -5
- pyrekordbox/config.py +71 -27
- pyrekordbox/db6/database.py +260 -33
- pyrekordbox/db6/registry.py +22 -0
- pyrekordbox/db6/tables.py +3 -4
- {pyrekordbox-0.2.1.dist-info → pyrekordbox-0.2.2.dist-info}/METADATA +12 -11
- pyrekordbox-0.2.2.dist-info/RECORD +80 -0
- {pyrekordbox-0.2.1.dist-info → pyrekordbox-0.2.2.dist-info}/top_level.txt +0 -2
- tests/test_config.py +175 -0
- tests/test_db6.py +78 -0
- build/lib/build/lib/docs/source/conf.py +0 -178
- build/lib/build/lib/pyrekordbox/__init__.py +0 -22
- build/lib/build/lib/pyrekordbox/__main__.py +0 -204
- build/lib/build/lib/pyrekordbox/_version.py +0 -16
- build/lib/build/lib/pyrekordbox/anlz/__init__.py +0 -127
- build/lib/build/lib/pyrekordbox/anlz/file.py +0 -186
- build/lib/build/lib/pyrekordbox/anlz/structs.py +0 -299
- build/lib/build/lib/pyrekordbox/anlz/tags.py +0 -508
- build/lib/build/lib/pyrekordbox/config.py +0 -596
- build/lib/build/lib/pyrekordbox/db6/__init__.py +0 -45
- build/lib/build/lib/pyrekordbox/db6/aux_files.py +0 -213
- build/lib/build/lib/pyrekordbox/db6/database.py +0 -1808
- build/lib/build/lib/pyrekordbox/db6/registry.py +0 -304
- build/lib/build/lib/pyrekordbox/db6/tables.py +0 -1618
- build/lib/build/lib/pyrekordbox/logger.py +0 -23
- build/lib/build/lib/pyrekordbox/mysettings/__init__.py +0 -32
- build/lib/build/lib/pyrekordbox/mysettings/file.py +0 -369
- build/lib/build/lib/pyrekordbox/mysettings/structs.py +0 -282
- build/lib/build/lib/pyrekordbox/utils.py +0 -162
- build/lib/build/lib/pyrekordbox/xml.py +0 -1294
- build/lib/build/lib/tests/__init__.py +0 -3
- build/lib/build/lib/tests/test_anlz.py +0 -206
- build/lib/build/lib/tests/test_db6.py +0 -1039
- build/lib/build/lib/tests/test_mysetting.py +0 -203
- build/lib/build/lib/tests/test_xml.py +0 -629
- build/lib/docs/source/conf.py +0 -178
- build/lib/pyrekordbox/__init__.py +0 -22
- build/lib/pyrekordbox/__main__.py +0 -204
- build/lib/pyrekordbox/_version.py +0 -16
- build/lib/pyrekordbox/anlz/__init__.py +0 -127
- build/lib/pyrekordbox/anlz/file.py +0 -186
- build/lib/pyrekordbox/anlz/structs.py +0 -299
- build/lib/pyrekordbox/anlz/tags.py +0 -508
- build/lib/pyrekordbox/config.py +0 -596
- build/lib/pyrekordbox/db6/__init__.py +0 -45
- build/lib/pyrekordbox/db6/aux_files.py +0 -213
- build/lib/pyrekordbox/db6/database.py +0 -1808
- build/lib/pyrekordbox/db6/registry.py +0 -304
- build/lib/pyrekordbox/db6/tables.py +0 -1618
- build/lib/pyrekordbox/logger.py +0 -23
- build/lib/pyrekordbox/mysettings/__init__.py +0 -32
- build/lib/pyrekordbox/mysettings/file.py +0 -369
- build/lib/pyrekordbox/mysettings/structs.py +0 -282
- build/lib/pyrekordbox/utils.py +0 -162
- build/lib/pyrekordbox/xml.py +0 -1294
- build/lib/tests/__init__.py +0 -3
- build/lib/tests/test_anlz.py +0 -206
- build/lib/tests/test_db6.py +0 -1039
- build/lib/tests/test_mysetting.py +0 -203
- build/lib/tests/test_xml.py +0 -629
- pyrekordbox-0.2.1.dist-info/RECORD +0 -129
- {pyrekordbox-0.2.1.dist-info → pyrekordbox-0.2.2.dist-info}/LICENSE +0 -0
- {pyrekordbox-0.2.1.dist-info → pyrekordbox-0.2.2.dist-info}/WHEEL +0 -0
@@ -1,1808 +0,0 @@
|
|
1
|
-
# -*- coding: utf-8 -*-
|
2
|
-
# Author: Dylan Jones
|
3
|
-
# Date: 2023-08-13
|
4
|
-
|
5
|
-
import logging
|
6
|
-
import datetime
|
7
|
-
import secrets
|
8
|
-
from uuid import uuid4
|
9
|
-
from pathlib import Path
|
10
|
-
from typing import Optional
|
11
|
-
from sqlalchemy import create_engine, or_, event, MetaData
|
12
|
-
from sqlalchemy.orm import Session
|
13
|
-
from sqlalchemy.exc import NoResultFound
|
14
|
-
from sqlalchemy.sql.sqltypes import DateTime, String
|
15
|
-
from packaging import version
|
16
|
-
from ..utils import get_rekordbox_pid
|
17
|
-
from ..config import get_config
|
18
|
-
from ..anlz import get_anlz_paths, read_anlz_files
|
19
|
-
from .registry import RekordboxAgentRegistry
|
20
|
-
from .aux_files import MasterPlaylistXml
|
21
|
-
from .tables import DjmdContent
|
22
|
-
from . import tables
|
23
|
-
|
24
|
-
try:
|
25
|
-
from sqlcipher3 import dbapi2 as sqlite3 # noqa
|
26
|
-
except ImportError:
|
27
|
-
import sqlite3
|
28
|
-
|
29
|
-
MAX_VERSION = version.parse("6.6.5")
|
30
|
-
|
31
|
-
logger = logging.getLogger(__name__)
|
32
|
-
|
33
|
-
rb6_config = get_config("rekordbox6")
|
34
|
-
|
35
|
-
|
36
|
-
class IncompatibleVersionError(Exception):
|
37
|
-
def __init__(self, rb_version):
|
38
|
-
super().__init__(
|
39
|
-
f"Incompatible rekordbox 6 version\n"
|
40
|
-
f"Your are using rekordbox {rb_version} but the key extraction only works "
|
41
|
-
f"for versions lower than {MAX_VERSION}.\n"
|
42
|
-
"Please use the `key` parameter to manually provide the database key."
|
43
|
-
)
|
44
|
-
|
45
|
-
|
46
|
-
def open_rekordbox_database(path=None, key="", unlock=True, sql_driver=None):
|
47
|
-
"""Opens a connection to the Rekordbox v6 master.db SQLite3 database.
|
48
|
-
|
49
|
-
Parameters
|
50
|
-
----------
|
51
|
-
path : str or Path, optional
|
52
|
-
The path of the Rekordbox v6 database file. By default, pyrekordbox
|
53
|
-
automatically finds the Rekordbox v6 master.db database file.
|
54
|
-
This parameter is only required for opening other databases or if the
|
55
|
-
configuration fails.
|
56
|
-
key : str, optional
|
57
|
-
The database key. By default, pyrekordbox automatically reads the database
|
58
|
-
key from the Rekordbox v6 configuration file. This parameter is only required
|
59
|
-
if the key extraction fails.
|
60
|
-
unlock: bool, optional
|
61
|
-
Flag if the database needs to be decrypted. Set to False if you are opening
|
62
|
-
an unencrypted test database.
|
63
|
-
sql_driver : Callable, optional
|
64
|
-
The SQLite driver to used for opening the database. The standard ``sqlite3``
|
65
|
-
package is used as default driver.
|
66
|
-
|
67
|
-
Returns
|
68
|
-
-------
|
69
|
-
con : sql_driver.Connection
|
70
|
-
The opened Rekordbox v6 database connection.
|
71
|
-
|
72
|
-
Examples
|
73
|
-
--------
|
74
|
-
Open the Rekordbox v6 master.db database:
|
75
|
-
|
76
|
-
>>> db = open_rekordbox_database()
|
77
|
-
|
78
|
-
Open a copy of the database:
|
79
|
-
|
80
|
-
>>> db = open_rekordbox_database("path/to/master_copy.db")
|
81
|
-
|
82
|
-
Open a decrypted copy of the database:
|
83
|
-
|
84
|
-
>>> db = open_rekordbox_database("path/to/master_unlocked.db", unlock=False)
|
85
|
-
|
86
|
-
To use the ``pysqlcipher3`` package as SQLite driver, either import it as
|
87
|
-
|
88
|
-
>>> from sqlcipher3 import dbapi2 as sqlite3 # noqa
|
89
|
-
>>> db = open_rekordbox_database("path/to/master_copy.db")
|
90
|
-
|
91
|
-
or supply the package as driver:
|
92
|
-
|
93
|
-
>>> from sqlcipher3 import dbapi2 # noqa
|
94
|
-
>>> db = open_rekordbox_database("path/to/master_copy.db", sql_driver=dbapi2)
|
95
|
-
"""
|
96
|
-
if not path:
|
97
|
-
path = rb6_config["db_path"]
|
98
|
-
path = Path(path)
|
99
|
-
if not path.exists():
|
100
|
-
raise FileNotFoundError(f"File '{path}' does not exist!")
|
101
|
-
logger.info("Opening %s", path)
|
102
|
-
|
103
|
-
# Open database
|
104
|
-
if sql_driver is None:
|
105
|
-
# Use default sqlite3 package
|
106
|
-
# This requires that the 'sqlite3.dll' was replaced by the 'sqlcipher.dll'
|
107
|
-
sql_driver = sqlite3
|
108
|
-
con = sql_driver.connect(str(path))
|
109
|
-
|
110
|
-
if unlock:
|
111
|
-
if not key:
|
112
|
-
ver = version.parse(rb6_config["version"])
|
113
|
-
if ver >= MAX_VERSION:
|
114
|
-
raise IncompatibleVersionError(rb6_config["version"])
|
115
|
-
try:
|
116
|
-
key = rb6_config["dp"]
|
117
|
-
except KeyError:
|
118
|
-
raise ValueError("Could not unlock database: No key found")
|
119
|
-
logger.info("Key: %s", key)
|
120
|
-
# Unlock database
|
121
|
-
con.execute(f"PRAGMA key='{key}'")
|
122
|
-
|
123
|
-
# Check connection
|
124
|
-
try:
|
125
|
-
con.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
126
|
-
except sqlite3.DatabaseError as e:
|
127
|
-
msg = f"Opening database failed: '{e}'. Check if the database key is correct!"
|
128
|
-
raise sqlite3.DatabaseError(msg)
|
129
|
-
else:
|
130
|
-
logger.info("Database unlocked!")
|
131
|
-
|
132
|
-
return con
|
133
|
-
|
134
|
-
|
135
|
-
def _parse_query_result(query, kwargs):
|
136
|
-
if "ID" in kwargs or "registry_id" in kwargs:
|
137
|
-
try:
|
138
|
-
query = query.one()
|
139
|
-
except NoResultFound:
|
140
|
-
return None
|
141
|
-
return query
|
142
|
-
|
143
|
-
|
144
|
-
class Rekordbox6Database:
|
145
|
-
"""Rekordbox v6 master.db database handler.
|
146
|
-
|
147
|
-
Parameters
|
148
|
-
----------
|
149
|
-
path : str or Path, optional
|
150
|
-
The path of the Rekordbox v6 database file. By default, pyrekordbox
|
151
|
-
automatically finds the Rekordbox v6 master.db database file.
|
152
|
-
This parameter is only required for opening other databases or if the
|
153
|
-
configuration fails.
|
154
|
-
db_dir: str, optional
|
155
|
-
The path of the Rekordbox v6 database directory. By default, pyrekordbox
|
156
|
-
automatically finds the Rekordbox v6 database directory. Usually this is also
|
157
|
-
the root directory of the analysis files. This parameter is only required for
|
158
|
-
finding the analysis root directory if you are opening a database, that is
|
159
|
-
stored somewhere else.
|
160
|
-
key : str, optional
|
161
|
-
The database key. By default, pyrekordbox automatically reads the database
|
162
|
-
key from the Rekordbox v6 configuration file. This parameter is only required
|
163
|
-
if the key extraction fails.
|
164
|
-
unlock: bool, optional
|
165
|
-
Flag if the database needs to be decrypted. Set to False if you are opening
|
166
|
-
an unencrypted test database.
|
167
|
-
|
168
|
-
Attributes
|
169
|
-
----------
|
170
|
-
engine : sqlalchemy.engine.Engine
|
171
|
-
The SQLAlchemy engine instance for the Rekordbox v6 database.
|
172
|
-
session : sqlalchemy.orm.Session
|
173
|
-
The SQLAlchemy session instance bound to the engine.
|
174
|
-
|
175
|
-
See Also
|
176
|
-
--------
|
177
|
-
pyrekordbox.db6.tables: Rekordbox v6 database table definitions
|
178
|
-
create_rekordbox_engine: Creates the SQLAlchemy engine for the Rekordbox v6 database
|
179
|
-
|
180
|
-
Examples
|
181
|
-
--------
|
182
|
-
Pyrekordbox automatically finds the Rekordbox v6 master.db database file and
|
183
|
-
opens it when initializing the object:
|
184
|
-
|
185
|
-
>>> db = Rekordbox6Database()
|
186
|
-
|
187
|
-
Use the included getters for querying the database:
|
188
|
-
|
189
|
-
>>> db.get_content()[0]
|
190
|
-
<DjmdContent(40110712 Title=NOISE)>
|
191
|
-
"""
|
192
|
-
|
193
|
-
def __init__(self, path=None, db_dir="", key="", unlock=True):
|
194
|
-
pid = get_rekordbox_pid()
|
195
|
-
if pid:
|
196
|
-
logger.warning("Rekordbox is running!")
|
197
|
-
|
198
|
-
if not path:
|
199
|
-
# Get path from the RB config
|
200
|
-
path = rb6_config.get("db_path", "")
|
201
|
-
if not path:
|
202
|
-
pdir = get_config("pioneer", "install_dir")
|
203
|
-
raise FileNotFoundError(f"No Rekordbox v6 directory found in '{pdir}'")
|
204
|
-
path = Path(path)
|
205
|
-
# make sure file exists
|
206
|
-
if not path.exists():
|
207
|
-
raise FileNotFoundError(f"File '{path}' does not exist!")
|
208
|
-
# Open database
|
209
|
-
if unlock:
|
210
|
-
if not key:
|
211
|
-
ver = version.parse(rb6_config["version"])
|
212
|
-
if ver >= MAX_VERSION:
|
213
|
-
raise IncompatibleVersionError(rb6_config["version"])
|
214
|
-
try:
|
215
|
-
key = rb6_config["dp"]
|
216
|
-
except KeyError:
|
217
|
-
raise ValueError("Could not unlock database: No key found")
|
218
|
-
logger.info("Key: %s", key)
|
219
|
-
# Unlock database and create engine
|
220
|
-
url = f"sqlite+pysqlcipher://:{key}@/{path}?"
|
221
|
-
engine = create_engine(url, module=sqlite3)
|
222
|
-
else:
|
223
|
-
engine = create_engine(f"sqlite:///{path}")
|
224
|
-
|
225
|
-
if not db_dir:
|
226
|
-
db_dir = path.parent
|
227
|
-
db_dir = Path(db_dir)
|
228
|
-
if not db_dir.exists():
|
229
|
-
raise FileNotFoundError(f"Database directory '{db_dir}' does not exist!")
|
230
|
-
|
231
|
-
self.engine = engine
|
232
|
-
self.session: Optional[Session] = None
|
233
|
-
|
234
|
-
self.registry = RekordboxAgentRegistry(self)
|
235
|
-
self._events = dict()
|
236
|
-
try:
|
237
|
-
self.playlist_xml = MasterPlaylistXml(db_dir=db_dir)
|
238
|
-
except FileNotFoundError:
|
239
|
-
logger.warning(f"No masterPlaylists6.xml found in {db_dir}")
|
240
|
-
self.playlist_xml = None
|
241
|
-
|
242
|
-
self._db_dir = db_dir
|
243
|
-
self._share_dir = db_dir / "share"
|
244
|
-
|
245
|
-
self.open()
|
246
|
-
|
247
|
-
@property
|
248
|
-
def no_autoflush(self):
|
249
|
-
"""Creates a no-autoflush context."""
|
250
|
-
return self.session.no_autoflush
|
251
|
-
|
252
|
-
@property
|
253
|
-
def db_directory(self):
|
254
|
-
return self._db_dir
|
255
|
-
|
256
|
-
@property
|
257
|
-
def share_directory(self):
|
258
|
-
return self._share_dir
|
259
|
-
|
260
|
-
def open(self):
|
261
|
-
"""Open the database by instantiating a new session using the SQLAchemy engine.
|
262
|
-
|
263
|
-
A new session instance is only created if the session was closed previously.
|
264
|
-
|
265
|
-
Examples
|
266
|
-
--------
|
267
|
-
>>> db = Rekordbox6Database()
|
268
|
-
>>> db.close()
|
269
|
-
>>> db.open()
|
270
|
-
"""
|
271
|
-
if self.session is None:
|
272
|
-
self.session = Session(bind=self.engine)
|
273
|
-
self.registry.clear_buffer()
|
274
|
-
|
275
|
-
def close(self):
|
276
|
-
"""Close the currently active session."""
|
277
|
-
for key in self._events:
|
278
|
-
self.unregister_event(key)
|
279
|
-
self.registry.clear_buffer()
|
280
|
-
self.session.close()
|
281
|
-
self.session = None
|
282
|
-
|
283
|
-
def __enter__(self):
|
284
|
-
return self
|
285
|
-
|
286
|
-
def __exit__(self, exc_type, exc_val, exc_tb):
|
287
|
-
self.close()
|
288
|
-
|
289
|
-
def register_event(self, identifier, fn):
|
290
|
-
"""Registers a session event callback.
|
291
|
-
|
292
|
-
Parameters
|
293
|
-
----------
|
294
|
-
identifier : str
|
295
|
-
The identifier of the event, for example 'before_flush', 'after_commit', ...
|
296
|
-
See the SQLAlchemy documentation for a list of valid event identifiers.
|
297
|
-
fn : callable
|
298
|
-
The event callback method.
|
299
|
-
"""
|
300
|
-
event.listen(self.session, identifier, fn)
|
301
|
-
self._events[identifier] = fn
|
302
|
-
|
303
|
-
def unregister_event(self, identifier):
|
304
|
-
"""Removes an existing session event callback.
|
305
|
-
|
306
|
-
Parameters
|
307
|
-
----------
|
308
|
-
identifier : str
|
309
|
-
The identifier of the event
|
310
|
-
"""
|
311
|
-
fn = self._events[identifier]
|
312
|
-
event.remove(self.session, identifier, fn)
|
313
|
-
|
314
|
-
def query(self, *entities, **kwargs):
|
315
|
-
"""Creates a new SQL query for the given entities.
|
316
|
-
|
317
|
-
Parameters
|
318
|
-
----------
|
319
|
-
*entities : Base
|
320
|
-
The table objects for which the query is created.
|
321
|
-
**kwargs
|
322
|
-
Arbitrary keyword arguments used for creating the query.
|
323
|
-
|
324
|
-
Returns
|
325
|
-
-------
|
326
|
-
query : sqlalchemy.orm.query.Query
|
327
|
-
The SQLAlchemy ``Query`` object.
|
328
|
-
|
329
|
-
Examples
|
330
|
-
--------
|
331
|
-
Query the ``DjmdContent`` table
|
332
|
-
|
333
|
-
>>> db = Rekordbox6Database()
|
334
|
-
>>> query = db.query(DjmdContent)
|
335
|
-
|
336
|
-
Query the `Title` attribute of the ``DjmdContent`` table
|
337
|
-
|
338
|
-
>>> db = Rekordbox6Database()
|
339
|
-
>>> query = db.query(DjmdContent.Title)
|
340
|
-
"""
|
341
|
-
return self.session.query(*entities, **kwargs)
|
342
|
-
|
343
|
-
def add(self, instance):
|
344
|
-
"""Add an element to the Rekordbox database.
|
345
|
-
|
346
|
-
Parameters
|
347
|
-
----------
|
348
|
-
instance : tables.Base
|
349
|
-
The table entry to add.
|
350
|
-
"""
|
351
|
-
self.session.add(instance)
|
352
|
-
self.registry.on_create(instance)
|
353
|
-
|
354
|
-
def delete(self, instance):
|
355
|
-
"""Delete an element from the Rekordbox database.
|
356
|
-
|
357
|
-
Parameters
|
358
|
-
----------
|
359
|
-
instance : tables.Base
|
360
|
-
The table entry to delte.
|
361
|
-
"""
|
362
|
-
self.session.delete(instance)
|
363
|
-
self.registry.on_delete(instance)
|
364
|
-
|
365
|
-
def get_local_usn(self):
|
366
|
-
"""Returns the local sequence number (update count) of Rekordbox.
|
367
|
-
|
368
|
-
Any changes made to the `Djmd...` tables increments the local update count of
|
369
|
-
Rekordbox. The ``usn`` entry of the changed row is set to the corresponding
|
370
|
-
update count.
|
371
|
-
|
372
|
-
Returns
|
373
|
-
-------
|
374
|
-
usn : int
|
375
|
-
The value of the local update count.
|
376
|
-
"""
|
377
|
-
return self.registry.get_local_update_count()
|
378
|
-
|
379
|
-
def set_local_usn(self, usn):
|
380
|
-
"""Sets the local sequence number (update count) of Rekordbox.
|
381
|
-
|
382
|
-
Parameters
|
383
|
-
----------
|
384
|
-
usn : int or str
|
385
|
-
The new update sequence number.
|
386
|
-
"""
|
387
|
-
self.registry.set_local_update_count(usn)
|
388
|
-
|
389
|
-
def increment_local_usn(self, num=1):
|
390
|
-
"""Increments the local update sequence number (update count) of Rekordbox.
|
391
|
-
|
392
|
-
Parameters
|
393
|
-
----------
|
394
|
-
num : int, optional
|
395
|
-
The number of times to increment the update counter. By default, the counter
|
396
|
-
is incremented by 1.
|
397
|
-
|
398
|
-
Returns
|
399
|
-
-------
|
400
|
-
usn : int
|
401
|
-
The value of the incremented local update count.
|
402
|
-
|
403
|
-
Examples
|
404
|
-
--------
|
405
|
-
>>> db = Rekordbox6Database()
|
406
|
-
>>> db.get_local_usn()
|
407
|
-
70500
|
408
|
-
|
409
|
-
>>> db.increment_local_usn()
|
410
|
-
70501
|
411
|
-
|
412
|
-
>>> db.get_local_usn()
|
413
|
-
70501
|
414
|
-
"""
|
415
|
-
return self.registry.increment_local_update_count(num)
|
416
|
-
|
417
|
-
def autoincrement_usn(self, set_row_usn=True):
|
418
|
-
"""Auto-increments the local USN for all uncommited changes.
|
419
|
-
|
420
|
-
Parameters
|
421
|
-
----------
|
422
|
-
set_row_usn : bool, optional
|
423
|
-
If True, set the ``rb_local_usn`` value of updated or added rows according
|
424
|
-
to the uncommited update sequence.
|
425
|
-
|
426
|
-
Returns
|
427
|
-
-------
|
428
|
-
new_usn : int
|
429
|
-
The new local update sequence number after applying all updates.
|
430
|
-
|
431
|
-
Examples
|
432
|
-
--------
|
433
|
-
>>> db = Rekordbox6Database()
|
434
|
-
>>> db.get_local_usn()
|
435
|
-
70500
|
436
|
-
|
437
|
-
>>> content = db.get_content().first()
|
438
|
-
>>> playlist = db.get_playlist().first()
|
439
|
-
>>> content.Title = "New Title"
|
440
|
-
>>> playlist.Name = "New Name"
|
441
|
-
>>> db.autoincrement_usn(set_row_usn=True)
|
442
|
-
>>> db.get_local_usn()
|
443
|
-
70502
|
444
|
-
"""
|
445
|
-
return self.registry.autoincrement_local_update_count(set_row_usn)
|
446
|
-
|
447
|
-
def flush(self):
|
448
|
-
"""Flushes the buffer of the SQLAlchemy session instance."""
|
449
|
-
self.session.flush()
|
450
|
-
|
451
|
-
def commit(self, autoinc=True):
|
452
|
-
"""Commit the changes made to the database.
|
453
|
-
|
454
|
-
Parameters
|
455
|
-
----------
|
456
|
-
autoinc : bool, optional
|
457
|
-
If True, auto-increment the local and row USN's before commiting the
|
458
|
-
changes made to the database.
|
459
|
-
|
460
|
-
See Also
|
461
|
-
--------
|
462
|
-
autoincrement_usn : Auto-increments the local Rekordbox USN's.
|
463
|
-
"""
|
464
|
-
pid = get_rekordbox_pid()
|
465
|
-
if pid:
|
466
|
-
raise RuntimeError(
|
467
|
-
"Rekordbox is running. Please close Rekordbox before commiting changes."
|
468
|
-
)
|
469
|
-
if autoinc:
|
470
|
-
self.registry.autoincrement_local_update_count(set_row_usn=True)
|
471
|
-
self.session.commit()
|
472
|
-
self.registry.clear_buffer()
|
473
|
-
|
474
|
-
# Update the masterPlaylists6.xml file
|
475
|
-
if self.playlist_xml is not None:
|
476
|
-
# Sync the updated_at values of the playlists in the DB and the XML file
|
477
|
-
for pl in self.get_playlist():
|
478
|
-
plxml = self.playlist_xml.get(pl.ID)
|
479
|
-
if plxml is None:
|
480
|
-
raise ValueError(
|
481
|
-
f"Playlist {pl.ID} not found in masterPlaylists6.xml! "
|
482
|
-
"Did you add it manually? "
|
483
|
-
"Use the create_playlist method instead."
|
484
|
-
)
|
485
|
-
ts = plxml["Timestamp"]
|
486
|
-
diff = pl.updated_at - ts
|
487
|
-
if abs(diff.total_seconds()) > 1:
|
488
|
-
logger.debug("Updating updated_at of playlist %s in XML", pl.ID)
|
489
|
-
self.playlist_xml.update(pl.ID, updated_at=pl.updated_at)
|
490
|
-
|
491
|
-
# Save the XML file if it was modified
|
492
|
-
if self.playlist_xml.modified:
|
493
|
-
self.playlist_xml.save()
|
494
|
-
|
495
|
-
def rollback(self):
|
496
|
-
"""Rolls back the uncommited changes to the database."""
|
497
|
-
self.session.rollback()
|
498
|
-
self.registry.clear_buffer()
|
499
|
-
|
500
|
-
# -- Table queries -----------------------------------------------------------------
|
501
|
-
|
502
|
-
def get_active_censor(self, **kwargs):
|
503
|
-
"""Creates a filtered query for the ``DjmdActiveCensor`` table."""
|
504
|
-
query = self.query(tables.DjmdActiveCensor).filter_by(**kwargs)
|
505
|
-
return _parse_query_result(query, kwargs)
|
506
|
-
|
507
|
-
def get_album(self, **kwargs):
|
508
|
-
"""Creates a filtered query for the ``DjmdAlbum`` table."""
|
509
|
-
query = self.query(tables.DjmdAlbum).filter_by(**kwargs)
|
510
|
-
return _parse_query_result(query, kwargs)
|
511
|
-
|
512
|
-
def get_artist(self, **kwargs):
|
513
|
-
"""Creates a filtered query for the ``DjmdArtist`` table."""
|
514
|
-
query = self.query(tables.DjmdArtist).filter_by(**kwargs)
|
515
|
-
return _parse_query_result(query, kwargs)
|
516
|
-
|
517
|
-
def get_category(self, **kwargs):
|
518
|
-
"""Creates a filtered query for the ``DjmdCategory`` table."""
|
519
|
-
query = self.query(tables.DjmdCategory).filter_by(**kwargs)
|
520
|
-
return _parse_query_result(query, kwargs)
|
521
|
-
|
522
|
-
def get_color(self, **kwargs):
|
523
|
-
"""Creates a filtered query for the ``DjmdColor`` table."""
|
524
|
-
query = self.query(tables.DjmdColor).filter_by(**kwargs)
|
525
|
-
return _parse_query_result(query, kwargs)
|
526
|
-
|
527
|
-
def get_content(self, **kwargs):
|
528
|
-
"""Creates a filtered query for the ``DjmdContent`` table."""
|
529
|
-
query = self.query(tables.DjmdContent).filter_by(**kwargs)
|
530
|
-
return _parse_query_result(query, kwargs)
|
531
|
-
|
532
|
-
# noinspection PyUnresolvedReferences
|
533
|
-
def search_content(self, text):
|
534
|
-
"""Searches the contents of the ``DjmdContent`` table.
|
535
|
-
|
536
|
-
The search is case-insensitive and includes the following collumns of the
|
537
|
-
``DjmdContent`` table:
|
538
|
-
|
539
|
-
- `Album`
|
540
|
-
- `Artist`
|
541
|
-
- `Commnt`
|
542
|
-
- `Composer`
|
543
|
-
- `Genre`
|
544
|
-
- `Key`
|
545
|
-
- `OrgArtist`
|
546
|
-
- `Remixer`
|
547
|
-
|
548
|
-
Parameters
|
549
|
-
----------
|
550
|
-
text : str
|
551
|
-
The search text.
|
552
|
-
|
553
|
-
Returns
|
554
|
-
-------
|
555
|
-
results : list[DjmdContent]
|
556
|
-
The resulting content elements.
|
557
|
-
"""
|
558
|
-
# Search standard columns
|
559
|
-
query = self.query(tables.DjmdContent).filter(
|
560
|
-
or_(
|
561
|
-
DjmdContent.Title.contains(text),
|
562
|
-
DjmdContent.Commnt.contains(text),
|
563
|
-
DjmdContent.SearchStr.contains(text),
|
564
|
-
)
|
565
|
-
)
|
566
|
-
results = set(query.all())
|
567
|
-
|
568
|
-
# Search artist (Artist, OrgArtist, Composer and Remixer)
|
569
|
-
artist_attrs = ["Artist", "OrgArtist", "Composer", "Remixer"]
|
570
|
-
for attr in artist_attrs:
|
571
|
-
query = self.query(DjmdContent).join(getattr(DjmdContent, attr))
|
572
|
-
results.update(query.filter(tables.DjmdArtist.Name.contains(text)).all())
|
573
|
-
|
574
|
-
# Search album
|
575
|
-
query = self.query(DjmdContent).join(DjmdContent.Album)
|
576
|
-
results.update(query.filter(tables.DjmdAlbum.Name.contains(text)).all())
|
577
|
-
|
578
|
-
# Search Genre
|
579
|
-
query = self.query(DjmdContent).join(DjmdContent.Genre)
|
580
|
-
results.update(query.filter(tables.DjmdGenre.Name.contains(text)).all())
|
581
|
-
|
582
|
-
# Search Key
|
583
|
-
query = self.query(DjmdContent).join(DjmdContent.Key)
|
584
|
-
results.update(query.filter(tables.DjmdKey.ScaleName.contains(text)).all())
|
585
|
-
|
586
|
-
results = list(results)
|
587
|
-
results.sort(key=lambda x: x.ID)
|
588
|
-
return results
|
589
|
-
|
590
|
-
def get_cue(self, **kwargs):
|
591
|
-
"""Creates a filtered query for the ``DjmdCue`` table."""
|
592
|
-
query = self.query(tables.DjmdCue).filter_by(**kwargs)
|
593
|
-
return _parse_query_result(query, kwargs)
|
594
|
-
|
595
|
-
def get_device(self, **kwargs):
|
596
|
-
"""Creates a filtered query for the ``DjmdDevice`` table."""
|
597
|
-
query = self.query(tables.DjmdDevice).filter_by(**kwargs)
|
598
|
-
return _parse_query_result(query, kwargs)
|
599
|
-
|
600
|
-
def get_genre(self, **kwargs):
|
601
|
-
"""Creates a filtered query for the ``DjmdGenre`` table."""
|
602
|
-
query = self.query(tables.DjmdGenre).filter_by(**kwargs)
|
603
|
-
return _parse_query_result(query, kwargs)
|
604
|
-
|
605
|
-
def get_history(self, **kwargs):
|
606
|
-
"""Creates a filtered query for the ``DjmdHistory`` table."""
|
607
|
-
query = self.query(tables.DjmdHistory).filter_by(**kwargs)
|
608
|
-
return _parse_query_result(query, kwargs)
|
609
|
-
|
610
|
-
def get_history_songs(self, **kwargs):
|
611
|
-
"""Creates a filtered query for the ``DjmdSongHistory`` table."""
|
612
|
-
query = self.query(tables.DjmdSongHistory).filter_by(**kwargs)
|
613
|
-
return _parse_query_result(query, kwargs)
|
614
|
-
|
615
|
-
def get_hot_cue_banklist(self, **kwargs):
|
616
|
-
"""Creates a filtered query for the ``DjmdHotCueBanklist`` table."""
|
617
|
-
query = self.query(tables.DjmdHotCueBanklist).filter_by(**kwargs)
|
618
|
-
return _parse_query_result(query, kwargs)
|
619
|
-
|
620
|
-
def get_hot_cue_banklist_songs(self, **kwargs):
|
621
|
-
"""Creates a filtered query for the ``DjmdSongHotCueBanklist`` table."""
|
622
|
-
query = self.query(tables.DjmdSongHotCueBanklist).filter_by(**kwargs)
|
623
|
-
return _parse_query_result(query, kwargs)
|
624
|
-
|
625
|
-
def get_key(self, **kwargs):
|
626
|
-
"""Creates a filtered query for the ``DjmdKey`` table."""
|
627
|
-
query = self.query(tables.DjmdKey).filter_by(**kwargs)
|
628
|
-
return _parse_query_result(query, kwargs)
|
629
|
-
|
630
|
-
def get_label(self, **kwargs):
|
631
|
-
"""Creates a filtered query for the ``DjmdLabel`` table."""
|
632
|
-
query = self.query(tables.DjmdLabel).filter_by(**kwargs)
|
633
|
-
return _parse_query_result(query, kwargs)
|
634
|
-
|
635
|
-
def get_menu_items(self, **kwargs):
|
636
|
-
"""Creates a filtered query for the ``DjmdMenuItems`` table."""
|
637
|
-
query = self.query(tables.DjmdMenuItems).filter_by(**kwargs)
|
638
|
-
return _parse_query_result(query, kwargs)
|
639
|
-
|
640
|
-
def get_mixer_param(self, **kwargs):
|
641
|
-
"""Creates a filtered query for the ``DjmdMixerParam`` table."""
|
642
|
-
query = self.query(tables.DjmdMixerParam).filter_by(**kwargs)
|
643
|
-
return _parse_query_result(query, kwargs)
|
644
|
-
|
645
|
-
def get_my_tag(self, **kwargs):
|
646
|
-
"""Creates a filtered query for the ``DjmdMyTag`` table."""
|
647
|
-
query = self.query(tables.DjmdMyTag).filter_by(**kwargs)
|
648
|
-
return _parse_query_result(query, kwargs)
|
649
|
-
|
650
|
-
def get_my_tag_songs(self, **kwargs):
|
651
|
-
"""Creates a filtered query for the ``DjmdSongMyTag`` table."""
|
652
|
-
query = self.query(tables.DjmdSongMyTag).filter_by(**kwargs)
|
653
|
-
return _parse_query_result(query, kwargs)
|
654
|
-
|
655
|
-
def get_playlist(self, **kwargs):
|
656
|
-
"""Creates a filtered query for the ``DjmdPlaylist`` table."""
|
657
|
-
query = self.query(tables.DjmdPlaylist).filter_by(**kwargs)
|
658
|
-
return _parse_query_result(query, kwargs)
|
659
|
-
|
660
|
-
def get_playlist_songs(self, **kwargs):
|
661
|
-
"""Creates a filtered query for the ``DjmdSongPlaylist`` table."""
|
662
|
-
query = self.query(tables.DjmdSongPlaylist).filter_by(**kwargs)
|
663
|
-
return _parse_query_result(query, kwargs)
|
664
|
-
|
665
|
-
def get_property(self, **kwargs):
|
666
|
-
"""Creates a filtered query for the ``DjmdProperty`` table."""
|
667
|
-
query = self.query(tables.DjmdProperty).filter_by(**kwargs)
|
668
|
-
return _parse_query_result(query, kwargs)
|
669
|
-
|
670
|
-
def get_related_tracks(self, **kwargs):
|
671
|
-
"""Creates a filtered query for the ``DjmdRelatedTracks`` table."""
|
672
|
-
query = self.query(tables.DjmdRelatedTracks).filter_by(**kwargs)
|
673
|
-
return _parse_query_result(query, kwargs)
|
674
|
-
|
675
|
-
def get_related_tracks_songs(self, **kwargs):
|
676
|
-
"""Creates a filtered query for the ``DjmdSongRelatedTracks`` table."""
|
677
|
-
query = self.query(tables.DjmdSongRelatedTracks).filter_by(**kwargs)
|
678
|
-
return _parse_query_result(query, kwargs)
|
679
|
-
|
680
|
-
def get_sampler(self, **kwargs):
|
681
|
-
"""Creates a filtered query for the ``DjmdSampler`` table."""
|
682
|
-
query = self.query(tables.DjmdSampler).filter_by(**kwargs)
|
683
|
-
return _parse_query_result(query, kwargs)
|
684
|
-
|
685
|
-
def get_sampler_songs(self, **kwargs):
|
686
|
-
"""Creates a filtered query for the ``DjmdSongSampler`` table."""
|
687
|
-
query = self.query(tables.DjmdSongSampler).filter_by(**kwargs)
|
688
|
-
return _parse_query_result(query, kwargs)
|
689
|
-
|
690
|
-
def get_tag_list_songs(self, **kwargs):
|
691
|
-
"""Creates a filtered query for the ``DjmdSongTagList`` table."""
|
692
|
-
query = self.query(tables.DjmdSongTagList).filter_by(**kwargs)
|
693
|
-
return _parse_query_result(query, kwargs)
|
694
|
-
|
695
|
-
def get_sort(self, **kwargs):
|
696
|
-
"""Creates a filtered query for the ``DjmdSort`` table."""
|
697
|
-
query = self.query(tables.DjmdSort).filter_by(**kwargs)
|
698
|
-
return _parse_query_result(query, kwargs)
|
699
|
-
|
700
|
-
def get_agent_registry(self, **kwargs):
|
701
|
-
"""Creates a filtered query for the ``AgentRegistry`` table."""
|
702
|
-
query = self.query(tables.AgentRegistry).filter_by(**kwargs)
|
703
|
-
return _parse_query_result(query, kwargs)
|
704
|
-
|
705
|
-
def get_cloud_agent_registry(self, **kwargs):
|
706
|
-
"""Creates a filtered query for the ``CloudAgentRegistry`` table."""
|
707
|
-
query = self.query(tables.CloudAgentRegistry).filter_by(**kwargs)
|
708
|
-
return _parse_query_result(query, kwargs)
|
709
|
-
|
710
|
-
def get_content_active_censor(self, **kwargs):
|
711
|
-
"""Creates a filtered query for the ``ContentActiveCensor`` table."""
|
712
|
-
query = self.query(tables.ContentActiveCensor).filter_by(**kwargs)
|
713
|
-
return _parse_query_result(query, kwargs)
|
714
|
-
|
715
|
-
def get_content_cue(self, **kwargs):
|
716
|
-
"""Creates a filtered query for the ``ContentCue`` table."""
|
717
|
-
query = self.query(tables.ContentCue).filter_by(**kwargs)
|
718
|
-
return _parse_query_result(query, kwargs)
|
719
|
-
|
720
|
-
def get_content_file(self, **kwargs):
|
721
|
-
"""Creates a filtered query for the ``ContentFile`` table."""
|
722
|
-
query = self.query(tables.ContentFile).filter_by(**kwargs)
|
723
|
-
return _parse_query_result(query, kwargs)
|
724
|
-
|
725
|
-
def get_hot_cue_banklist_cue(self, **kwargs):
|
726
|
-
"""Creates a filtered query for the ``HotCueBanklistCue`` table."""
|
727
|
-
query = self.query(tables.HotCueBanklistCue).filter_by(**kwargs)
|
728
|
-
return _parse_query_result(query, kwargs)
|
729
|
-
|
730
|
-
def get_image_file(self, **kwargs):
|
731
|
-
"""Creates a filtered query for the ``ImageFile`` table."""
|
732
|
-
query = self.query(tables.ImageFile).filter_by(**kwargs)
|
733
|
-
return _parse_query_result(query, kwargs)
|
734
|
-
|
735
|
-
def get_setting_file(self, **kwargs):
|
736
|
-
"""Creates a filtered query for the ``SettingFile`` table."""
|
737
|
-
query = self.query(tables.SettingFile).filter_by(**kwargs)
|
738
|
-
return _parse_query_result(query, kwargs)
|
739
|
-
|
740
|
-
def get_uuid_map(self, **kwargs):
|
741
|
-
"""Creates a filtered query for the ``UuidIDMap`` table."""
|
742
|
-
query = self.query(tables.UuidIDMap).filter_by(**kwargs)
|
743
|
-
return _parse_query_result(query, kwargs)
|
744
|
-
|
745
|
-
# -- Database updates --------------------------------------------------------------
|
746
|
-
|
747
|
-
def generate_unused_id(self, table, is_28_bit: bool = True) -> int:
|
748
|
-
"""Generates an unused ID for the given table."""
|
749
|
-
max_tries = 1000000
|
750
|
-
for _ in range(max_tries):
|
751
|
-
# Generate random ID
|
752
|
-
buf = secrets.token_bytes(4)
|
753
|
-
id_ = (buf[0] << 24) + (buf[1] << 16) + (buf[2] << 8) + buf[3] >> 0
|
754
|
-
if is_28_bit:
|
755
|
-
id_ = id_ >> 4
|
756
|
-
if id_ < 100:
|
757
|
-
continue
|
758
|
-
# Check if ID is already used
|
759
|
-
query = self.query(table.ID).filter(table.ID == id_)
|
760
|
-
used = self.query(query.exists()).scalar()
|
761
|
-
if not used:
|
762
|
-
return id_
|
763
|
-
|
764
|
-
raise ValueError("Could not generate unused ID")
|
765
|
-
|
766
|
-
def add_to_playlist(self, playlist, content, track_no=None):
|
767
|
-
"""Adds a track to a playlist.
|
768
|
-
|
769
|
-
Creates a new :class:`DjmdSongPlaylist` object corresponding to the given
|
770
|
-
content and adds it to the playlist.
|
771
|
-
|
772
|
-
Parameters
|
773
|
-
----------
|
774
|
-
playlist : DjmdPlaylist or int or str
|
775
|
-
The playlist to add the track to. Can either be a :class:`DjmdPlaylist`
|
776
|
-
object or a playlist ID.
|
777
|
-
content : DjmdContent or int or str
|
778
|
-
The content to add to the playlist. Can either be a :class:`DjmdContent`
|
779
|
-
object or a content ID.
|
780
|
-
track_no : int, optional
|
781
|
-
The track number to add the content to. If not specified, the track
|
782
|
-
will be added to the end of the playlist.
|
783
|
-
|
784
|
-
Returns
|
785
|
-
-------
|
786
|
-
song: DjmdSongPlaylist
|
787
|
-
The song playlist object that was created from the content.
|
788
|
-
|
789
|
-
Raises
|
790
|
-
------
|
791
|
-
ValueError : If the playlist is a folder or smart playlist.
|
792
|
-
ValueError : If the track number is less than 1 or to large.
|
793
|
-
|
794
|
-
Examples
|
795
|
-
--------
|
796
|
-
Add a track to the end of a playlist:
|
797
|
-
|
798
|
-
>>> db = Rekordbox6Database()
|
799
|
-
>>> cid = 12345 # Content ID
|
800
|
-
>>> pid = 56789 # Playlist ID
|
801
|
-
>>> db.add_to_playlist(pid, cid)
|
802
|
-
<DjmdSongPlaylist(c803dfde-2236-4659-b3d7-e57221663375)>
|
803
|
-
|
804
|
-
Add a track to the beginning of a playlist:
|
805
|
-
|
806
|
-
>>> new_song = db.add_to_playlist(pid, cid, track_no=1)
|
807
|
-
>>> new_song.TrackNo
|
808
|
-
1
|
809
|
-
"""
|
810
|
-
if isinstance(playlist, (int, str)):
|
811
|
-
playlist = self.get_playlist(ID=playlist)
|
812
|
-
if isinstance(content, (int, str)):
|
813
|
-
content = self.get_content(ID=content)
|
814
|
-
# Check playlist attribute (can't be folder or smart playlist)
|
815
|
-
if playlist.Attribute != 0:
|
816
|
-
raise ValueError("Playlist must be a normal playlist")
|
817
|
-
|
818
|
-
uuid = str(uuid4())
|
819
|
-
id_ = str(uuid4())
|
820
|
-
now = datetime.datetime.now()
|
821
|
-
nsongs = (
|
822
|
-
self.query(tables.DjmdSongPlaylist)
|
823
|
-
.filter_by(PlaylistID=playlist.ID)
|
824
|
-
.count()
|
825
|
-
)
|
826
|
-
if track_no is not None:
|
827
|
-
insert_at_end = False
|
828
|
-
track_no = int(track_no)
|
829
|
-
if track_no < 1:
|
830
|
-
raise ValueError("Track number must be greater than 0")
|
831
|
-
if track_no > nsongs + 1:
|
832
|
-
raise ValueError(
|
833
|
-
f"Track number too high, parent contains {nsongs} items"
|
834
|
-
)
|
835
|
-
else:
|
836
|
-
insert_at_end = True
|
837
|
-
track_no = nsongs + 1
|
838
|
-
|
839
|
-
cid = content.ID
|
840
|
-
pid = playlist.ID
|
841
|
-
|
842
|
-
logger.info("Adding content with ID=%s to playlist with ID=%s:", cid, pid)
|
843
|
-
logger.debug("Content ID: %s", cid)
|
844
|
-
logger.debug("Playlist ID: %s", pid)
|
845
|
-
logger.debug("ID: %s", id_)
|
846
|
-
logger.debug("UUID: %s", uuid)
|
847
|
-
logger.debug("TrackNo: %s", track_no)
|
848
|
-
|
849
|
-
moved = list()
|
850
|
-
if not insert_at_end:
|
851
|
-
self.registry.disable_tracking()
|
852
|
-
# Update track numbers higher than the removed track
|
853
|
-
query = (
|
854
|
-
self.query(tables.DjmdSongPlaylist)
|
855
|
-
.filter(
|
856
|
-
tables.DjmdSongPlaylist.PlaylistID == playlist.ID,
|
857
|
-
tables.DjmdSongPlaylist.TrackNo >= track_no,
|
858
|
-
)
|
859
|
-
.order_by(tables.DjmdSongPlaylist.TrackNo)
|
860
|
-
)
|
861
|
-
for song in query:
|
862
|
-
song.TrackNo += 1
|
863
|
-
song.updated_at = now
|
864
|
-
moved.append(song)
|
865
|
-
self.registry.enable_tracking()
|
866
|
-
|
867
|
-
# Add song to playlist
|
868
|
-
song = tables.DjmdSongPlaylist.create(
|
869
|
-
ID=id_,
|
870
|
-
PlaylistID=str(pid),
|
871
|
-
ContentID=str(cid),
|
872
|
-
TrackNo=track_no,
|
873
|
-
UUID=uuid,
|
874
|
-
created_at=now,
|
875
|
-
updated_at=now,
|
876
|
-
)
|
877
|
-
self.add(song)
|
878
|
-
if not insert_at_end:
|
879
|
-
moved.append(song)
|
880
|
-
self.registry.on_move(moved)
|
881
|
-
|
882
|
-
return song
|
883
|
-
|
884
|
-
def remove_from_playlist(self, playlist, song):
|
885
|
-
"""Removes a track from a playlist.
|
886
|
-
|
887
|
-
Parameters
|
888
|
-
----------
|
889
|
-
playlist : DjmdPlaylist or int or str
|
890
|
-
The playlist to remove the track from. Can either be a :class:`DjmdPlaylist`
|
891
|
-
object or a playlist ID.
|
892
|
-
song : DjmdSongPlaylist or int or str
|
893
|
-
The song to remove from the playlist. Can either be a
|
894
|
-
:class:`DjmdSongPlaylist` object or a song ID.
|
895
|
-
|
896
|
-
Examples
|
897
|
-
--------
|
898
|
-
Remove a track from a playlist:
|
899
|
-
|
900
|
-
>>> db = Rekordbox6Database()
|
901
|
-
>>> pid = 56789
|
902
|
-
>>> pl = db.get_playlist(ID=pid)
|
903
|
-
>>> song = pl.Songs[0]
|
904
|
-
>>> db.remove_from_playlist(pl, song)
|
905
|
-
"""
|
906
|
-
if isinstance(playlist, (int, str)):
|
907
|
-
playlist = self.get_playlist(ID=playlist)
|
908
|
-
if isinstance(song, (int, str)):
|
909
|
-
song = self.query(tables.DjmdSongPlaylist).filter_by(ID=song).one()
|
910
|
-
logger.info(
|
911
|
-
"Removing song with ID=%s from playlist with ID=%s", song.ID, playlist.ID
|
912
|
-
)
|
913
|
-
now = datetime.datetime.now()
|
914
|
-
# Remove track from playlist
|
915
|
-
track_no = song.TrackNo
|
916
|
-
self.delete(song)
|
917
|
-
self.commit()
|
918
|
-
# Update track numbers higher than the removed track
|
919
|
-
query = (
|
920
|
-
self.query(tables.DjmdSongPlaylist)
|
921
|
-
.filter(
|
922
|
-
tables.DjmdSongPlaylist.PlaylistID == playlist.ID,
|
923
|
-
tables.DjmdSongPlaylist.TrackNo > track_no,
|
924
|
-
)
|
925
|
-
.order_by(tables.DjmdSongPlaylist.TrackNo)
|
926
|
-
)
|
927
|
-
moved = list()
|
928
|
-
self.registry.disable_tracking()
|
929
|
-
for song in query:
|
930
|
-
song.TrackNo -= 1
|
931
|
-
song.updated_at = now
|
932
|
-
moved.append(song)
|
933
|
-
self.registry.enable_tracking()
|
934
|
-
if moved:
|
935
|
-
self.registry.on_move(moved)
|
936
|
-
|
937
|
-
def move_song_in_playlist(self, playlist, song, new_track_no):
|
938
|
-
"""Sets a new track number of a song.
|
939
|
-
|
940
|
-
Also updates the track numbers of the other songs in the playlist.
|
941
|
-
|
942
|
-
Parameters
|
943
|
-
----------
|
944
|
-
playlist : DjmdPlaylist or int or str
|
945
|
-
The playlist the track is in. Can either be a :class:`DjmdPlaylist`
|
946
|
-
object or a playlist ID.
|
947
|
-
song : DjmdSongPlaylist or int or str
|
948
|
-
The song to move inside the playlist. Can either be a
|
949
|
-
:class:`DjmdSongPlaylist` object or a song ID.
|
950
|
-
new_track_no : int
|
951
|
-
The new track number of the song. Must be greater than 0 and less than
|
952
|
-
the number of songs in the playlist.
|
953
|
-
|
954
|
-
Examples
|
955
|
-
--------
|
956
|
-
Take a playlist containing a few tracks:
|
957
|
-
|
958
|
-
>>> db = Rekordbox6Database()
|
959
|
-
>>> pid = 56789
|
960
|
-
>>> pl = db.get_playlist(ID=pid)
|
961
|
-
>>> songs = sorted(pl.Songs, key=lambda x: x.TrackNo)
|
962
|
-
>>> [s.Content.Title for s in songs] # noqa
|
963
|
-
['Demo Track 1', 'Demo Track 2', 'HORN', 'NOISE']
|
964
|
-
|
965
|
-
Move a track forward in a playlist:
|
966
|
-
|
967
|
-
>>> song = songs[2]
|
968
|
-
>>> db.move_song_in_playlist(pl, song, new_track_no=1)
|
969
|
-
>>> [s.Content.Title for s in sorted(pl.Songs, key=lambda x: x.TrackNo)] # noqa
|
970
|
-
['HORN', 'Demo Track 1', 'Demo Track 2', 'NOISE']
|
971
|
-
|
972
|
-
Move a track backward in a playlist:
|
973
|
-
|
974
|
-
>>> song = songs[1]
|
975
|
-
>>> db.move_song_in_playlist(pl, song, new_track_no=4)
|
976
|
-
>>> [s.Content.Title for s in sorted(pl.Songs, key=lambda x: x.TrackNo)] # noqa
|
977
|
-
['Demo Track 1', 'HORN', 'NOISE', 'Demo Track 2']
|
978
|
-
"""
|
979
|
-
if isinstance(playlist, (int, str)):
|
980
|
-
playlist = self.get_playlist(ID=playlist)
|
981
|
-
if isinstance(song, (int, str)):
|
982
|
-
song = self.query(tables.DjmdSongPlaylist).filter_by(ID=song).one()
|
983
|
-
nsongs = (
|
984
|
-
self.query(tables.DjmdSongPlaylist)
|
985
|
-
.filter_by(PlaylistID=playlist.ID)
|
986
|
-
.count()
|
987
|
-
)
|
988
|
-
if new_track_no < 1:
|
989
|
-
raise ValueError("Track number must be greater than 0")
|
990
|
-
if new_track_no > nsongs + 1:
|
991
|
-
raise ValueError(f"Track number too high, parent contains {nsongs} items")
|
992
|
-
logger.info(
|
993
|
-
"Moving song with ID=%s in playlist with ID=%s to %s",
|
994
|
-
song.ID,
|
995
|
-
playlist.ID,
|
996
|
-
new_track_no,
|
997
|
-
)
|
998
|
-
now = datetime.datetime.now()
|
999
|
-
old_track_no = song.TrackNo
|
1000
|
-
|
1001
|
-
self.registry.disable_tracking()
|
1002
|
-
moved = list()
|
1003
|
-
if new_track_no > old_track_no:
|
1004
|
-
query = (
|
1005
|
-
self.query(tables.DjmdSongPlaylist)
|
1006
|
-
.filter(
|
1007
|
-
tables.DjmdSongPlaylist.PlaylistID == playlist.ID,
|
1008
|
-
old_track_no < tables.DjmdSongPlaylist.TrackNo,
|
1009
|
-
tables.DjmdSongPlaylist.TrackNo <= new_track_no,
|
1010
|
-
)
|
1011
|
-
.order_by(tables.DjmdSongPlaylist.TrackNo)
|
1012
|
-
)
|
1013
|
-
for other_song in query:
|
1014
|
-
other_song.TrackNo -= 1
|
1015
|
-
other_song.updated_at = now
|
1016
|
-
moved.append(other_song)
|
1017
|
-
elif new_track_no < old_track_no:
|
1018
|
-
query = self.query(tables.DjmdSongPlaylist).filter(
|
1019
|
-
tables.DjmdSongPlaylist.PlaylistID == playlist.ID,
|
1020
|
-
new_track_no <= tables.DjmdSongPlaylist.TrackNo,
|
1021
|
-
tables.DjmdSongPlaylist.TrackNo < old_track_no,
|
1022
|
-
)
|
1023
|
-
for other_song in query:
|
1024
|
-
other_song.TrackNo += 1
|
1025
|
-
other_song.updated_at = now
|
1026
|
-
moved.append(other_song)
|
1027
|
-
else:
|
1028
|
-
return
|
1029
|
-
|
1030
|
-
song.TrackNo = new_track_no
|
1031
|
-
song.updated_at = now
|
1032
|
-
moved.append(song)
|
1033
|
-
|
1034
|
-
self.registry.enable_tracking()
|
1035
|
-
self.registry.on_move(moved)
|
1036
|
-
|
1037
|
-
def _create_playlist(
|
1038
|
-
self, name, seq, image_path, parent, smart_list=None, attribute=None
|
1039
|
-
):
|
1040
|
-
"""Creates a new playlist object."""
|
1041
|
-
table = tables.DjmdPlaylist
|
1042
|
-
id_ = str(self.generate_unused_id(table, is_28_bit=True))
|
1043
|
-
uuid = str(uuid4())
|
1044
|
-
now = datetime.datetime.now()
|
1045
|
-
|
1046
|
-
if parent is None:
|
1047
|
-
# If no parent is given, use root playlist
|
1048
|
-
parent_id = "root"
|
1049
|
-
elif isinstance(parent, tables.DjmdPlaylist):
|
1050
|
-
# Check if parent is a folder
|
1051
|
-
parent_id = parent.ID
|
1052
|
-
if parent.Attribute != 1:
|
1053
|
-
raise ValueError("Parent is not a folder")
|
1054
|
-
else:
|
1055
|
-
# Check if parent exists and is a folder
|
1056
|
-
parent_id = parent
|
1057
|
-
query = self.query(table.ID).filter(
|
1058
|
-
table.ID == parent_id, table.Attribute == 1
|
1059
|
-
)
|
1060
|
-
if not self.query(query.exists()).scalar():
|
1061
|
-
raise ValueError("Parent does not exist or is not a folder")
|
1062
|
-
|
1063
|
-
n = self.get_playlist(ParentID=parent_id).count()
|
1064
|
-
logger.debug("Parent playlist with ID=%s contains %s items", parent_id, n)
|
1065
|
-
|
1066
|
-
if seq is None:
|
1067
|
-
# New playlist is last in parents
|
1068
|
-
seq = n + 1
|
1069
|
-
insert_at_end = True
|
1070
|
-
else:
|
1071
|
-
# Check if sequence number is valid
|
1072
|
-
insert_at_end = False
|
1073
|
-
if seq < 1:
|
1074
|
-
raise ValueError("Sequence number must be greater than 0")
|
1075
|
-
elif seq > n + 1:
|
1076
|
-
raise ValueError(f"Sequence number too high, parent contains {n} items")
|
1077
|
-
|
1078
|
-
logger.debug("ID: %s", id_)
|
1079
|
-
logger.debug("UUID: %s", uuid)
|
1080
|
-
logger.debug("Name: %s", name)
|
1081
|
-
logger.debug("Parent ID: %s", parent_id)
|
1082
|
-
logger.debug("Seq: %s", seq)
|
1083
|
-
logger.debug("Attribute: %s", attribute)
|
1084
|
-
logger.debug("Smart List: %s", smart_list)
|
1085
|
-
logger.debug("Image Path: %s", image_path)
|
1086
|
-
|
1087
|
-
# Update seq numbers higher than the new seq number
|
1088
|
-
if not insert_at_end:
|
1089
|
-
query = self.query(tables.DjmdPlaylist).filter(
|
1090
|
-
tables.DjmdPlaylist.ParentID == parent_id,
|
1091
|
-
tables.DjmdPlaylist.Seq >= seq,
|
1092
|
-
)
|
1093
|
-
for pl in query:
|
1094
|
-
pl.Seq += 1
|
1095
|
-
self.registry.disable_tracking()
|
1096
|
-
pl.updated_at = now
|
1097
|
-
self.registry.enable_tracking()
|
1098
|
-
|
1099
|
-
# Add new playlist to database
|
1100
|
-
# First create with name 'New playlist'
|
1101
|
-
playlist = table.create(
|
1102
|
-
ID=id_,
|
1103
|
-
Seq=seq,
|
1104
|
-
Name="New playlist",
|
1105
|
-
ImagePath=image_path,
|
1106
|
-
Attribute=attribute,
|
1107
|
-
ParentID=parent_id,
|
1108
|
-
SmartList=smart_list,
|
1109
|
-
UUID=uuid,
|
1110
|
-
created_at=now,
|
1111
|
-
updated_at=now,
|
1112
|
-
)
|
1113
|
-
self.add(playlist)
|
1114
|
-
# Then update with correct name for correct USN
|
1115
|
-
playlist.Name = name
|
1116
|
-
|
1117
|
-
# Update masterPlaylists6.xml
|
1118
|
-
if self.playlist_xml is not None:
|
1119
|
-
self.playlist_xml.add(
|
1120
|
-
id_, parent_id, attribute, now, lib_type=0, check_type=0
|
1121
|
-
)
|
1122
|
-
|
1123
|
-
return playlist
|
1124
|
-
|
1125
|
-
def create_playlist(self, name, parent=None, seq=None, image_path=None):
|
1126
|
-
"""Creates a new playlist in the database.
|
1127
|
-
|
1128
|
-
Parameters
|
1129
|
-
----------
|
1130
|
-
name : str
|
1131
|
-
The name of the new playlist.
|
1132
|
-
parent : DjmdPlaylist or int or str, optional
|
1133
|
-
The parent playlist of the new playlist. If not given, the playlist will be
|
1134
|
-
added to the root playlist. Can either be a :class:`DjmdPlaylist` object or
|
1135
|
-
a playlist ID.
|
1136
|
-
seq : int, optional
|
1137
|
-
The sequence number of the new playlist. If not given, the playlist will be
|
1138
|
-
added at the end of the parent playlist.
|
1139
|
-
image_path : str, optional
|
1140
|
-
The path to the image file of the new playlist.
|
1141
|
-
|
1142
|
-
Returns
|
1143
|
-
-------
|
1144
|
-
playlist : DjmdPlaylist
|
1145
|
-
The newly created playlist.
|
1146
|
-
|
1147
|
-
Raises
|
1148
|
-
------
|
1149
|
-
ValueError : If the parent playlist is not a folder.
|
1150
|
-
ValueError : If the sequence number is less than 1 or to large.
|
1151
|
-
|
1152
|
-
Examples
|
1153
|
-
--------
|
1154
|
-
Create a new playlist in the root playlist:
|
1155
|
-
|
1156
|
-
>>> db = Rekordbox6Database()
|
1157
|
-
>>> pl = db.create_playlist("My Playlist")
|
1158
|
-
>>> pl.ParentID
|
1159
|
-
'root'
|
1160
|
-
|
1161
|
-
Create a new playlist in a folder:
|
1162
|
-
|
1163
|
-
>>> folder = db.get_playlist(Name="My Folder").one()
|
1164
|
-
>>> pl = db.create_playlist("My Playlist", parent=folder)
|
1165
|
-
>>> pl.ParentID
|
1166
|
-
'123456'
|
1167
|
-
"""
|
1168
|
-
logger.info("Creating playlist %s", name)
|
1169
|
-
return self._create_playlist(name, seq, image_path, parent, attribute=0)
|
1170
|
-
|
1171
|
-
def create_playlist_folder(self, name, parent=None, seq=None, image_path=None):
|
1172
|
-
"""Creates a new playlist folder in the database.
|
1173
|
-
|
1174
|
-
Parameters
|
1175
|
-
----------
|
1176
|
-
name : str
|
1177
|
-
The name of the new playlist folder.
|
1178
|
-
parent : DjmdPlaylist or int or str, optional
|
1179
|
-
The parent playlist of the new folder. If not given, the playlist will be
|
1180
|
-
added to the root playlist. Can either be a :class:`DjmdPlaylist` object or
|
1181
|
-
a playlist ID.
|
1182
|
-
seq : int, optional
|
1183
|
-
The sequence number of the new folder. If not given, the playlist will be
|
1184
|
-
added at the end of the parent playlist.
|
1185
|
-
image_path : str, optional
|
1186
|
-
The path to the image file of the new playlist.
|
1187
|
-
|
1188
|
-
Returns
|
1189
|
-
-------
|
1190
|
-
playlist_folder : DjmdPlaylist
|
1191
|
-
The newly created playlist folder.
|
1192
|
-
|
1193
|
-
Examples
|
1194
|
-
--------
|
1195
|
-
Create a new playlist folder in the root playlist:
|
1196
|
-
|
1197
|
-
>>> db = Rekordbox6Database()
|
1198
|
-
>>> folder1 = db.create_playlist_folder("My Playlist Folder")
|
1199
|
-
>>> folder1.ParentID
|
1200
|
-
'root'
|
1201
|
-
|
1202
|
-
Create a new playlist folder in the other folder:
|
1203
|
-
|
1204
|
-
>>> folder2 = db.create_playlist("My Playlist Folder2", parent=folder1)
|
1205
|
-
>>> folder2.ParentID
|
1206
|
-
'123456'
|
1207
|
-
"""
|
1208
|
-
logger.info("Creating playlist folder %s", name)
|
1209
|
-
return self._create_playlist(name, seq, image_path, parent, attribute=1)
|
1210
|
-
|
1211
|
-
def delete_playlist(self, playlist):
|
1212
|
-
"""Deletes a playlist or playlist folder from the database.
|
1213
|
-
|
1214
|
-
Parameters
|
1215
|
-
----------
|
1216
|
-
playlist : DjmdPlaylist or int or str
|
1217
|
-
The playlist or playlist folder to delete. Can either be a
|
1218
|
-
:class:`DjmdPlaylist` object or a playlist ID.
|
1219
|
-
|
1220
|
-
Examples
|
1221
|
-
--------
|
1222
|
-
Delete a playlist:
|
1223
|
-
|
1224
|
-
>>> db = Rekordbox6Database()
|
1225
|
-
>>> pl = db.get_playlist(Name="My Playlist").one()
|
1226
|
-
>>> db.delete_playlist(pl)
|
1227
|
-
|
1228
|
-
Delete a playlist folder:
|
1229
|
-
|
1230
|
-
>>> folder = db.get_playlist(Name="My Folder").one()
|
1231
|
-
>>> db.delete_playlist(folder)
|
1232
|
-
"""
|
1233
|
-
if isinstance(playlist, (int, str)):
|
1234
|
-
playlist = self.get_playlist(ID=playlist)
|
1235
|
-
|
1236
|
-
if playlist.Attribute == 1:
|
1237
|
-
logger.info(
|
1238
|
-
"Deleting playlist folder '%s' with ID=%s", playlist.Name, playlist.ID
|
1239
|
-
)
|
1240
|
-
else:
|
1241
|
-
logger.info("Deleting playlist '%s' with ID=%s", playlist.Name, playlist.ID)
|
1242
|
-
|
1243
|
-
now = datetime.datetime.now()
|
1244
|
-
seq = playlist.Seq
|
1245
|
-
parent_id = playlist.ParentID
|
1246
|
-
|
1247
|
-
self.registry.disable_tracking()
|
1248
|
-
# Update seq numbers higher than the deleted seq number
|
1249
|
-
query = (
|
1250
|
-
self.query(tables.DjmdPlaylist)
|
1251
|
-
.filter(
|
1252
|
-
tables.DjmdPlaylist.ParentID == parent_id,
|
1253
|
-
tables.DjmdPlaylist.Seq > seq,
|
1254
|
-
)
|
1255
|
-
.order_by(tables.DjmdPlaylist.Seq)
|
1256
|
-
)
|
1257
|
-
moved = list()
|
1258
|
-
for pl in query:
|
1259
|
-
pl.Seq -= 1
|
1260
|
-
pl.updated_at = now
|
1261
|
-
moved.append(pl)
|
1262
|
-
moved.append(playlist)
|
1263
|
-
|
1264
|
-
children = [playlist]
|
1265
|
-
# Get all child playlist IDs
|
1266
|
-
child_ids = list()
|
1267
|
-
while len(children):
|
1268
|
-
new_children = list()
|
1269
|
-
for child in children:
|
1270
|
-
child_ids.append(child.ID)
|
1271
|
-
new_children.extend(list(child.Children))
|
1272
|
-
children = new_children
|
1273
|
-
|
1274
|
-
# First ID in 'child_ids' is always the deleted playlist, others are children
|
1275
|
-
|
1276
|
-
# Remove playlist from masterPlaylists6.xml
|
1277
|
-
if self.playlist_xml is not None:
|
1278
|
-
for pid in child_ids:
|
1279
|
-
self.playlist_xml.remove(pid)
|
1280
|
-
|
1281
|
-
# Remove playlist from database
|
1282
|
-
self.delete(playlist)
|
1283
|
-
self.registry.enable_tracking()
|
1284
|
-
if len(child_ids) > 1:
|
1285
|
-
# The playlist folder had children: on extra USN increment
|
1286
|
-
self.registry.on_delete(child_ids[1:])
|
1287
|
-
self.registry.on_delete(moved)
|
1288
|
-
|
1289
|
-
def move_playlist(self, playlist, parent=None, seq=None):
|
1290
|
-
"""Moves a playlist (folder) in the current parent folder or to a new one.
|
1291
|
-
|
1292
|
-
Parameters
|
1293
|
-
----------
|
1294
|
-
playlist : DjmdPlaylist or int or str
|
1295
|
-
The playlist or playlist folder to move. Can either be a
|
1296
|
-
:class:`DjmdPlaylist` object or a playlist ID.
|
1297
|
-
parent : DjmdPlaylist or int or str, optional
|
1298
|
-
The new parent playlist of the playlist. If not given, the playlist will
|
1299
|
-
be moved to `seq` in the current parent playlist. Can either be a
|
1300
|
-
:class:`DjmdPlaylist` object or a playlist ID.
|
1301
|
-
seq : int, optional
|
1302
|
-
The new sequence number of the playlist. If the `parent` argument is given,
|
1303
|
-
the playlist will be moved to `seq` in the new parent playlist or to
|
1304
|
-
the end of the new parent folder if `seq=None`. If the `parent` argument is
|
1305
|
-
not given, the playlist will be moved to `seq` in the current parent.
|
1306
|
-
|
1307
|
-
Examples
|
1308
|
-
--------
|
1309
|
-
Take the following playlist tree:
|
1310
|
-
|
1311
|
-
>>> db = Rekordbox6Database()
|
1312
|
-
>>> playlists = db.get_playlist().order_by(tables.DjmdPlaylist.Seq)
|
1313
|
-
>>> [pl.Name for pl in playlists] # noqa
|
1314
|
-
['Folder 1', 'Folder 2', 'Playlist 1', 'Playlist 2', 'Playlist 3']
|
1315
|
-
|
1316
|
-
The playlists and folders above are all in the `root` plalyist folder.
|
1317
|
-
Move a playlist in the current parent folder:
|
1318
|
-
|
1319
|
-
>>> pl = db.get_playlist(Name="Playlist 2").one() # noqa
|
1320
|
-
>>> db.move_playlist(pl, seq=2)
|
1321
|
-
>>> playlists = db.get_playlist().order_by(tables.DjmdPlaylist.Seq)
|
1322
|
-
>>> [pl.Name for pl in playlists] # noqa
|
1323
|
-
['Folder 1', 'Playlist 2', 'Folder 2', 'Playlist 1', 'Playlist 3']
|
1324
|
-
|
1325
|
-
Move a playlist to a new parent folder:
|
1326
|
-
|
1327
|
-
>>> pl = db.get_playlist(Name="Playlist 1").one() # noqa
|
1328
|
-
>>> parent = db.get_playlist(Name="Folder 1").one() # noqa
|
1329
|
-
>>> db.move_playlist(pl, parent=parent)
|
1330
|
-
>>> db.get_playlist(ParentID=parent.ID).all()
|
1331
|
-
['Playlist 1']
|
1332
|
-
"""
|
1333
|
-
if parent is None and seq is None:
|
1334
|
-
raise ValueError("Either parent or seq must be given")
|
1335
|
-
if isinstance(playlist, (int, str)):
|
1336
|
-
playlist = self.get_playlist(ID=playlist)
|
1337
|
-
|
1338
|
-
now = datetime.datetime.now()
|
1339
|
-
table = tables.DjmdPlaylist
|
1340
|
-
|
1341
|
-
if parent is None:
|
1342
|
-
# If no parent is given, keep the current parent
|
1343
|
-
parent_id = playlist.ParentID
|
1344
|
-
elif isinstance(parent, tables.DjmdPlaylist):
|
1345
|
-
# Check if parent is a folder
|
1346
|
-
parent_id = parent.ID
|
1347
|
-
if parent.Attribute != 1:
|
1348
|
-
raise ValueError("Parent is not a folder")
|
1349
|
-
else:
|
1350
|
-
# Check if parent exists and is a folder
|
1351
|
-
parent_id = str(parent)
|
1352
|
-
query = self.query(table.ID).filter(
|
1353
|
-
table.ID == parent_id, table.Attribute == 1
|
1354
|
-
)
|
1355
|
-
if not self.query(query.exists()).scalar():
|
1356
|
-
raise ValueError("Parent does not exist or is not a folder")
|
1357
|
-
|
1358
|
-
n = self.get_playlist(ParentID=parent_id).count()
|
1359
|
-
old_seq = playlist.Seq
|
1360
|
-
|
1361
|
-
if parent_id != playlist.ParentID:
|
1362
|
-
# Move to new parent
|
1363
|
-
|
1364
|
-
old_parent_id = playlist.ParentID
|
1365
|
-
if seq is None:
|
1366
|
-
# New playlist is last in parents
|
1367
|
-
seq = n + 1
|
1368
|
-
insert_at_end = True
|
1369
|
-
else:
|
1370
|
-
# Check if sequence number is valid
|
1371
|
-
insert_at_end = False
|
1372
|
-
if seq < 1:
|
1373
|
-
raise ValueError("Sequence number must be greater than 0")
|
1374
|
-
elif seq > n + 1:
|
1375
|
-
raise ValueError(
|
1376
|
-
f"Sequence number too high, parent contains {n} items"
|
1377
|
-
)
|
1378
|
-
|
1379
|
-
if not insert_at_end:
|
1380
|
-
# Get all playlists with seq between old_seq and seq
|
1381
|
-
query = (
|
1382
|
-
self.query(tables.DjmdPlaylist)
|
1383
|
-
.filter(
|
1384
|
-
tables.DjmdPlaylist.ParentID == parent_id,
|
1385
|
-
tables.DjmdPlaylist.Seq >= seq,
|
1386
|
-
)
|
1387
|
-
.order_by(tables.DjmdPlaylist.Seq)
|
1388
|
-
)
|
1389
|
-
other_playlists = query.all()
|
1390
|
-
# Set seq number and update time *before* other playlists to ensure
|
1391
|
-
# right USN increment order
|
1392
|
-
playlist.ParentID = parent_id
|
1393
|
-
self.registry.disable_tracking()
|
1394
|
-
playlist.Seq = seq
|
1395
|
-
playlist.updated_at = now
|
1396
|
-
self.registry.enable_tracking()
|
1397
|
-
|
1398
|
-
if not insert_at_end:
|
1399
|
-
# Update seq numbers higher than the new seq number in *new* parent
|
1400
|
-
# noinspection PyUnboundLocalVariable
|
1401
|
-
for pl in other_playlists:
|
1402
|
-
# Update time of other playlists are left unchanged
|
1403
|
-
pl.Seq += 1
|
1404
|
-
# Each move counts as one USN increment, so disable for update time
|
1405
|
-
self.registry.disable_tracking()
|
1406
|
-
pl.updated_at = now
|
1407
|
-
self.registry.enable_tracking()
|
1408
|
-
|
1409
|
-
# Update seq numbers higher than the old seq number in *old* parent
|
1410
|
-
# USN is not updated here
|
1411
|
-
self.registry.disable_tracking()
|
1412
|
-
query = (
|
1413
|
-
self.query(tables.DjmdPlaylist)
|
1414
|
-
.filter(
|
1415
|
-
tables.DjmdPlaylist.ParentID == old_parent_id,
|
1416
|
-
tables.DjmdPlaylist.Seq > old_seq,
|
1417
|
-
)
|
1418
|
-
.order_by(tables.DjmdPlaylist.Seq)
|
1419
|
-
)
|
1420
|
-
for pl in query:
|
1421
|
-
# Update time of other playlists are left unchanged
|
1422
|
-
pl.Seq -= 1
|
1423
|
-
pl.updated_at = now
|
1424
|
-
self.registry.enable_tracking()
|
1425
|
-
|
1426
|
-
else:
|
1427
|
-
# Keep parent, only change seq number
|
1428
|
-
|
1429
|
-
if seq < 1:
|
1430
|
-
raise ValueError("Sequence number must be greater than 0")
|
1431
|
-
elif seq > n + 1:
|
1432
|
-
raise ValueError(f"Sequence number too high, parent contains {n} items")
|
1433
|
-
|
1434
|
-
if seq > old_seq:
|
1435
|
-
# Get all playlists with seq between old_seq and seq
|
1436
|
-
query = (
|
1437
|
-
self.query(tables.DjmdPlaylist)
|
1438
|
-
.filter(
|
1439
|
-
tables.DjmdPlaylist.ParentID == playlist.ParentID,
|
1440
|
-
old_seq < tables.DjmdPlaylist.Seq,
|
1441
|
-
tables.DjmdPlaylist.Seq <= seq,
|
1442
|
-
)
|
1443
|
-
.order_by(tables.DjmdPlaylist.Seq)
|
1444
|
-
)
|
1445
|
-
other_playlists = query.all()
|
1446
|
-
delta_seq = -1
|
1447
|
-
elif seq < old_seq:
|
1448
|
-
query = (
|
1449
|
-
self.query(tables.DjmdPlaylist)
|
1450
|
-
.filter(
|
1451
|
-
tables.DjmdPlaylist.ParentID == playlist.ParentID,
|
1452
|
-
seq <= tables.DjmdPlaylist.Seq,
|
1453
|
-
tables.DjmdPlaylist.Seq < old_seq,
|
1454
|
-
)
|
1455
|
-
.order_by(tables.DjmdPlaylist.Seq)
|
1456
|
-
)
|
1457
|
-
other_playlists = query.all()
|
1458
|
-
delta_seq = +1
|
1459
|
-
else:
|
1460
|
-
return
|
1461
|
-
|
1462
|
-
# Set seq number and update time *before* other playlists to ensure
|
1463
|
-
# right USN increment order
|
1464
|
-
playlist.Seq = seq
|
1465
|
-
# Each move counts as one USN increment, so disable for update time
|
1466
|
-
self.registry.disable_tracking()
|
1467
|
-
playlist.updated_at = now
|
1468
|
-
self.registry.enable_tracking()
|
1469
|
-
|
1470
|
-
# Set seq number and update time for playlists between old_seq and seq
|
1471
|
-
for pl in other_playlists:
|
1472
|
-
pl.Seq += delta_seq
|
1473
|
-
# Each move counts as one USN increment, so disable for update time
|
1474
|
-
self.registry.disable_tracking()
|
1475
|
-
pl.updated_at = now
|
1476
|
-
self.registry.enable_tracking()
|
1477
|
-
|
1478
|
-
def rename_playlist(self, playlist, name):
|
1479
|
-
"""Renames a playlist or playlist folder.
|
1480
|
-
|
1481
|
-
Parameters
|
1482
|
-
----------
|
1483
|
-
playlist : DjmdPlaylist or int or str
|
1484
|
-
The playlist or playlist folder to move. Can either be a
|
1485
|
-
:class:`DjmdPlaylist` object or a playlist ID.
|
1486
|
-
name : str
|
1487
|
-
The new name of the playlist or playlist folder.
|
1488
|
-
|
1489
|
-
Examples
|
1490
|
-
--------
|
1491
|
-
Take the following playlist tree:
|
1492
|
-
|
1493
|
-
>>> db = Rekordbox6Database()
|
1494
|
-
>>> playlists = db.get_playlist().order_by(tables.DjmdPlaylist.Seq)
|
1495
|
-
>>> [pl.Name for pl in playlists] # noqa
|
1496
|
-
['Playlist 1', 'Playlist 2']
|
1497
|
-
|
1498
|
-
Rename a playlist:
|
1499
|
-
|
1500
|
-
>>> pl = db.get_playlist(Name="Playlist 1").one() # noqa
|
1501
|
-
>>> db.rename_playlist(pl, name="Playlist new")
|
1502
|
-
>>> playlists = db.get_playlist().order_by(tables.DjmdPlaylist.Seq)
|
1503
|
-
>>> [pl.Name for pl in playlists] # noqa
|
1504
|
-
['Playlist new', 'Playlist 2']
|
1505
|
-
"""
|
1506
|
-
if isinstance(playlist, (int, str)):
|
1507
|
-
playlist = self.get_playlist(ID=playlist)
|
1508
|
-
now = datetime.datetime.now()
|
1509
|
-
# Update name of playlist
|
1510
|
-
playlist.Name = name
|
1511
|
-
# Update update time: USN not incremented
|
1512
|
-
self.registry.disable_tracking()
|
1513
|
-
playlist.updated_at = now
|
1514
|
-
self.registry.enable_tracking()
|
1515
|
-
|
1516
|
-
# ----------------------------------------------------------------------------------
|
1517
|
-
|
1518
|
-
def get_mysetting_paths(self):
|
1519
|
-
"""Returns the file paths of the local Rekordbox MySetting files.
|
1520
|
-
|
1521
|
-
Returns
|
1522
|
-
-------
|
1523
|
-
paths : list[str]
|
1524
|
-
the file paths of the local MySetting files.
|
1525
|
-
"""
|
1526
|
-
paths = list()
|
1527
|
-
for item in self.get_setting_file():
|
1528
|
-
paths.append(self._db_dir / item.Path.lstrip("/\\"))
|
1529
|
-
return paths
|
1530
|
-
|
1531
|
-
def get_anlz_dir(self, content):
|
1532
|
-
"""Returns the directory path containing the ANLZ analysis files of a track.
|
1533
|
-
|
1534
|
-
Parameters
|
1535
|
-
----------
|
1536
|
-
content : DjmdContent or int or str
|
1537
|
-
The content corresponding to a track in the Rekordbox v6 database.
|
1538
|
-
If an integer is passed the database is queried for the ``DjmdContent``
|
1539
|
-
entry.
|
1540
|
-
|
1541
|
-
Returns
|
1542
|
-
-------
|
1543
|
-
anlz_dir : Path
|
1544
|
-
The path of the directory containing the analysis files for the content.
|
1545
|
-
"""
|
1546
|
-
if isinstance(content, (int, str)):
|
1547
|
-
content = self.get_content(ID=content)
|
1548
|
-
|
1549
|
-
dat_path = Path(content.AnalysisDataPath.strip("\\/"))
|
1550
|
-
path = self._share_dir / dat_path.parent
|
1551
|
-
return path
|
1552
|
-
|
1553
|
-
def get_anlz_paths(self, content):
|
1554
|
-
"""Returns all existing ANLZ analysis file paths of a track.
|
1555
|
-
|
1556
|
-
Parameters
|
1557
|
-
----------
|
1558
|
-
content : DjmdContent or int or str
|
1559
|
-
The content corresponding to a track in the Rekordbox v6 database.
|
1560
|
-
If an integer is passed the database is queried for the ``DjmdContent``
|
1561
|
-
entry.
|
1562
|
-
|
1563
|
-
Returns
|
1564
|
-
-------
|
1565
|
-
anlz_paths : dict[str, Path]
|
1566
|
-
The analysis file paths for the content as dictionary. The keys of the
|
1567
|
-
dictionary are the file types ("DAT", "EXT" or "EX2").
|
1568
|
-
"""
|
1569
|
-
root = self.get_anlz_dir(content)
|
1570
|
-
return get_anlz_paths(root)
|
1571
|
-
|
1572
|
-
def read_anlz_files(self, content):
|
1573
|
-
"""Reads all existing ANLZ analysis files of a track.
|
1574
|
-
|
1575
|
-
Parameters
|
1576
|
-
----------
|
1577
|
-
content : DjmdContent or int or str
|
1578
|
-
The content corresponding to a track in the Rekordbox v6 database.
|
1579
|
-
If an integer is passed the database is queried for the ``DjmdContent``
|
1580
|
-
entry.
|
1581
|
-
|
1582
|
-
Returns
|
1583
|
-
-------
|
1584
|
-
anlz_files : dict[str, AnlzFile]
|
1585
|
-
The analysis files for the content as dictionary. The keys of the
|
1586
|
-
dictionary are the file paths.
|
1587
|
-
"""
|
1588
|
-
root = self.get_anlz_dir(content)
|
1589
|
-
return read_anlz_files(root)
|
1590
|
-
|
1591
|
-
def update_content_path(self, content, path, save=True, check_path=True):
|
1592
|
-
"""Update the file path of a track in the Rekordbox v6 database.
|
1593
|
-
|
1594
|
-
This changes the `FolderPath` entry in the ``DjmdContent`` table and the
|
1595
|
-
path tag (PPTH) of the corresponding ANLZ analysis files.
|
1596
|
-
|
1597
|
-
Parameters
|
1598
|
-
----------
|
1599
|
-
content : DjmdContent or int or str
|
1600
|
-
The ``DjmdContent`` element to change. If an integer is passed the database
|
1601
|
-
is queried for the content.
|
1602
|
-
path : str or Path
|
1603
|
-
The new file path of the database entry.
|
1604
|
-
save : bool, optional
|
1605
|
-
If True, the changes made are written to disc.
|
1606
|
-
check_path : bool, optional
|
1607
|
-
If True, raise an assertion error if the given file path does not exist.
|
1608
|
-
|
1609
|
-
Examples
|
1610
|
-
--------
|
1611
|
-
If, for example, the file `NOISE.wav` was moved up a few directories
|
1612
|
-
(from `.../Sampler/OSC_SAMPLER/PRESET ONESHOT/` to `.../Sampler/`) the file
|
1613
|
-
could no longer be opened in Rekordbox, since the database still contains the
|
1614
|
-
old file path:
|
1615
|
-
|
1616
|
-
>>> db = Rekordbox6Database()
|
1617
|
-
>>> cont = db.get_content()[0]
|
1618
|
-
>>> cont.FolderPath
|
1619
|
-
C:/Music/PioneerDJ/Sampler/OSC_SAMPLER/PRESET ONESHOT/NOISE.wav
|
1620
|
-
|
1621
|
-
Updating the path changes the database entry
|
1622
|
-
|
1623
|
-
>>> new_path = "C:/Music/PioneerDJ/Sampler/PRESET ONESHOT/NOISE.wav"
|
1624
|
-
>>> db.update_content_path(cont, new_path)
|
1625
|
-
>>> cont.FolderPath
|
1626
|
-
C:/Music/PioneerDJ/Sampler/PRESET ONESHOT/NOISE.wav
|
1627
|
-
|
1628
|
-
and updates the file path in the corresponding ANLZ analysis files:
|
1629
|
-
|
1630
|
-
>>> files = self.read_anlz_files(cont.ID)
|
1631
|
-
>>> file = list(files.values())[0]
|
1632
|
-
>>> file.get("path")
|
1633
|
-
C:/Music/PioneerDJ/Sampler/PRESET ONESHOT/NOISE.wav
|
1634
|
-
|
1635
|
-
"""
|
1636
|
-
if isinstance(content, (int, str)):
|
1637
|
-
content = self.get_content(ID=content)
|
1638
|
-
cid = content.ID
|
1639
|
-
|
1640
|
-
path = Path(path)
|
1641
|
-
# Check and format path (the database and ANLZ files use "/" as path delimiter)
|
1642
|
-
if check_path:
|
1643
|
-
assert path.exists()
|
1644
|
-
path = str(path).replace("\\", "/")
|
1645
|
-
old_path = content.FolderPath
|
1646
|
-
logger.info("Replacing '%s' with '%s' of content [%s]", old_path, path, cid)
|
1647
|
-
|
1648
|
-
# Update path in ANLZ files
|
1649
|
-
anlz_files = self.read_anlz_files(cid)
|
1650
|
-
for anlz_path, anlz in anlz_files.items():
|
1651
|
-
logger.debug("Updating path of %s: %s", anlz_path, path)
|
1652
|
-
anlz.set_path(path)
|
1653
|
-
|
1654
|
-
# Update path in database (DjmdContent)
|
1655
|
-
logger.debug("Updating database file path: %s", path)
|
1656
|
-
content.FolderPath = path
|
1657
|
-
|
1658
|
-
if save:
|
1659
|
-
logger.debug("Saving changes")
|
1660
|
-
# Save ANLZ files
|
1661
|
-
for anlz_path, anlz in anlz_files.items():
|
1662
|
-
anlz.save(anlz_path)
|
1663
|
-
# Commit database changes
|
1664
|
-
self.commit()
|
1665
|
-
|
1666
|
-
def update_content_filename(self, content, name, save=True, check_path=True):
|
1667
|
-
"""Update the file name of a track in the Rekordbox v6 database.
|
1668
|
-
|
1669
|
-
This changes the `FolderPath` entry in the ``DjmdContent`` table and the
|
1670
|
-
path tag (PPTH) of the corresponding ANLZ analysis files.
|
1671
|
-
|
1672
|
-
Parameters
|
1673
|
-
----------
|
1674
|
-
content : DjmdContent or int or str
|
1675
|
-
The ``DjmdContent`` element to change. If an integer is passed the database
|
1676
|
-
is queried for the content.
|
1677
|
-
name : str
|
1678
|
-
The new file name of the database entry.
|
1679
|
-
save : bool, optional
|
1680
|
-
If True, the changes made are written to disc.
|
1681
|
-
check_path : bool, optional
|
1682
|
-
If True, raise an assertion error if the new file path does not exist.
|
1683
|
-
|
1684
|
-
See Also
|
1685
|
-
--------
|
1686
|
-
update_content_path: Update the file path of a track in the Rekordbox database.
|
1687
|
-
|
1688
|
-
Examples
|
1689
|
-
--------
|
1690
|
-
Updating the file name changes the database entry
|
1691
|
-
|
1692
|
-
>>> db = Rekordbox6Database()
|
1693
|
-
>>> cont = db.get_content()[0]
|
1694
|
-
>>> cont.FolderPath
|
1695
|
-
C:/Music/PioneerDJ/Sampler/OSC_SAMPLER/PRESET ONESHOT/NOISE.wav
|
1696
|
-
|
1697
|
-
>>> new_name = "noise"
|
1698
|
-
>>> db.update_content_filename(cont, new_name)
|
1699
|
-
>>> cont.FolderPath
|
1700
|
-
C:/Music/PioneerDJ/Sampler/OSC_SAMPLER/PRESET ONESHOT/noise.wav
|
1701
|
-
|
1702
|
-
and updates the file path in the corresponding ANLZ analysis files:
|
1703
|
-
|
1704
|
-
>>> files = self.read_anlz_files(cont.ID)
|
1705
|
-
>>> file = list(files.values())[0]
|
1706
|
-
>>> cont.FolderPath == file.get("path")
|
1707
|
-
True
|
1708
|
-
|
1709
|
-
"""
|
1710
|
-
if isinstance(content, (int, str)):
|
1711
|
-
content = self.get_content(ID=content)
|
1712
|
-
|
1713
|
-
old_path = Path(content.FolderPath)
|
1714
|
-
ext = old_path.suffix
|
1715
|
-
new_path = old_path.parent / name
|
1716
|
-
new_path = new_path.with_suffix(ext)
|
1717
|
-
self.update_content_path(content, new_path, save, check_path)
|
1718
|
-
|
1719
|
-
def to_dict(self, verbose=False):
|
1720
|
-
"""Convert the database to a dictionary.
|
1721
|
-
|
1722
|
-
Parameters
|
1723
|
-
----------
|
1724
|
-
verbose: bool, optional
|
1725
|
-
If True, print the name of the table that is currently converted.
|
1726
|
-
|
1727
|
-
Returns
|
1728
|
-
-------
|
1729
|
-
dict
|
1730
|
-
A dictionary containing the database tables as keys and the table data as
|
1731
|
-
a list of dicts.
|
1732
|
-
"""
|
1733
|
-
data = dict()
|
1734
|
-
for table_name in tables.__all__:
|
1735
|
-
if table_name.startswith("Stats") or table_name == "Base":
|
1736
|
-
continue
|
1737
|
-
if verbose:
|
1738
|
-
print(f"Converting table: {table_name}")
|
1739
|
-
table = getattr(tables, table_name)
|
1740
|
-
columns = table.columns()
|
1741
|
-
table_data = list()
|
1742
|
-
for row in self.query(table).all():
|
1743
|
-
table_data.append({column: row[column] for column in columns})
|
1744
|
-
data[table_name] = table_data
|
1745
|
-
return data
|
1746
|
-
|
1747
|
-
def to_json(self, file, indent=4, sort_keys=True, verbose=False):
|
1748
|
-
"""Convert the database to a JSON file."""
|
1749
|
-
import json
|
1750
|
-
|
1751
|
-
def json_serial(obj):
|
1752
|
-
if isinstance(obj, (datetime.datetime, datetime.date)):
|
1753
|
-
return obj.isoformat()
|
1754
|
-
raise TypeError(f"Type {type(obj)} not serializable")
|
1755
|
-
|
1756
|
-
data = self.to_dict(verbose=verbose)
|
1757
|
-
with open(file, "w") as fp:
|
1758
|
-
json.dump(data, fp, indent=indent, sort_keys=sort_keys, default=json_serial)
|
1759
|
-
|
1760
|
-
def copy_unlocked(self, output_file):
|
1761
|
-
src_engine = self.engine
|
1762
|
-
src_metadata = MetaData()
|
1763
|
-
exclude_tables = ("sqlite_master", "sqlite_sequence", "sqlite_temp_master")
|
1764
|
-
|
1765
|
-
dst_engine = create_engine(f"sqlite:///{output_file}")
|
1766
|
-
dst_metadata = MetaData()
|
1767
|
-
|
1768
|
-
@event.listens_for(src_metadata, "column_reflect")
|
1769
|
-
def genericize_datatypes(inspector, tablename, column_dict):
|
1770
|
-
type_ = column_dict["type"].as_generic(allow_nulltype=True)
|
1771
|
-
if isinstance(type_, DateTime):
|
1772
|
-
type_ = String
|
1773
|
-
column_dict["type"] = type_
|
1774
|
-
|
1775
|
-
src_conn = src_engine.connect()
|
1776
|
-
dst_conn = dst_engine.connect()
|
1777
|
-
dst_metadata.reflect(bind=dst_engine)
|
1778
|
-
# drop all tables in target database
|
1779
|
-
for table in reversed(dst_metadata.sorted_tables):
|
1780
|
-
if table.name not in exclude_tables:
|
1781
|
-
print("dropping table =", table.name)
|
1782
|
-
table.drop(bind=dst_engine)
|
1783
|
-
# Delete all data in target database
|
1784
|
-
for table in reversed(dst_metadata.sorted_tables):
|
1785
|
-
table.delete()
|
1786
|
-
dst_metadata.clear()
|
1787
|
-
dst_metadata.reflect(bind=dst_engine)
|
1788
|
-
src_metadata.reflect(bind=src_engine)
|
1789
|
-
# create all tables in target database
|
1790
|
-
for table in src_metadata.sorted_tables:
|
1791
|
-
if table.name not in exclude_tables:
|
1792
|
-
table.create(bind=dst_engine)
|
1793
|
-
# refresh metadata before you can copy data
|
1794
|
-
dst_metadata.clear()
|
1795
|
-
dst_metadata.reflect(bind=dst_engine)
|
1796
|
-
# Copy all data from src to target
|
1797
|
-
print("Copying data...")
|
1798
|
-
string = "\rCopying table {name}: Inserting row {row}"
|
1799
|
-
index = 0
|
1800
|
-
for table in dst_metadata.sorted_tables:
|
1801
|
-
src_table = src_metadata.tables[table.name]
|
1802
|
-
stmt = table.insert()
|
1803
|
-
for index, row in enumerate(src_conn.execute(src_table.select())):
|
1804
|
-
print(string.format(name=table.name, row=index), end="", flush=True)
|
1805
|
-
dst_conn.execute(stmt.values(row))
|
1806
|
-
print(f"\rCopying table {table.name}: Inserted {index} rows", flush=True)
|
1807
|
-
|
1808
|
-
dst_conn.commit()
|