pyrekordbox 0.4.2__py3-none-any.whl → 0.4.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyrekordbox/config.py CHANGED
@@ -8,30 +8,18 @@ Contains all the path and settings handling of the Rekordbox installation(s) on
8
8
  the users machine.
9
9
  """
10
10
 
11
- import base64
12
11
  import json
13
12
  import logging
14
13
  import os
15
- import re
16
14
  import sys
17
- import textwrap
18
- import time
19
15
  import xml.etree.cElementTree as xml
20
16
  from pathlib import Path
21
- from typing import Union
17
+ from typing import Any, Dict, List, Union
22
18
 
23
- import blowfish
24
- import frida
25
19
  import packaging.version
26
20
 
27
- from .utils import get_rekordbox_pid
28
-
29
21
  logger = logging.getLogger(__name__)
30
22
 
31
- # Cache file for pyrekordbox data
32
- _cache_file_version = 2
33
- _cache_file_name = "rb.cache"
34
-
35
23
  # Define empty pyrekordbox configuration
36
24
  __config__ = {
37
25
  "pioneer": {
@@ -48,29 +36,6 @@ class InvalidApplicationDirname(Exception):
48
36
  pass
49
37
 
50
38
 
51
- def get_appdata_dir() -> Path:
52
- """Returns the path of the application data directory.
53
-
54
- On Windows, the application data is stored in `/Users/user/AppData/Roaming`.
55
- On macOS the application data is stored in `~/Libary/Application Support`.
56
- """
57
- if sys.platform == "win32":
58
- # Windows: located in /Users/user/AppData/Roaming/
59
- app_data = Path(os.environ["AppData"])
60
- elif sys.platform == "darwin":
61
- # MacOS: located in ~/Library/Application Support/
62
- app_data = Path("~").expanduser() / "Library" / "Application Support"
63
- else:
64
- # Linux: not supported
65
- logger.warning(f"OS {sys.platform} not supported!")
66
- return Path("~").expanduser() / ".local" / "share"
67
- return app_data
68
-
69
-
70
- def get_cache_file() -> Path:
71
- return get_appdata_dir() / "pyrekordbox" / _cache_file_name
72
-
73
-
74
39
  def get_pioneer_install_dir(path: Union[str, Path] = None) -> Path: # pragma: no cover
75
40
  """Returns the path of the Pioneer program installation directory.
76
41
 
@@ -151,7 +116,7 @@ def get_pioneer_app_dir(path: Union[str, Path] = None) -> Path: # pragma: no co
151
116
  return path
152
117
 
153
118
 
154
- def _convert_type(s):
119
+ def _convert_type(s: str) -> Union[str, int, float, List[int], List[float]]:
155
120
  # Try to parse as int, float, list of int, list of float
156
121
  types_ = int, float
157
122
  for type_ in types_:
@@ -167,7 +132,7 @@ def _convert_type(s):
167
132
  return s
168
133
 
169
134
 
170
- def read_rekordbox_settings(rekordbox_app_dir: Union[str, Path]) -> dict:
135
+ def read_rekordbox_settings(rekordbox_app_dir: Union[str, Path]) -> Dict[str, Any]:
171
136
  """Finds and parses the 'rekordbox3.settings' file in the Rekordbox 5 or 6 app-dir.
172
137
 
173
138
  The settings file usually is called 'rekordbox3.settings' and is
@@ -197,12 +162,12 @@ def read_rekordbox_settings(rekordbox_app_dir: Union[str, Path]) -> dict:
197
162
  val = _convert_type(element.attrib["val"])
198
163
  except KeyError:
199
164
  device_setup = element.find("DEVICESETUP")
200
- val = {k: _convert_type(v) for k, v in device_setup.attrib.items()}
165
+ val = {k: _convert_type(v) for k, v in device_setup.attrib.items()} # type: ignore
201
166
  settings[name] = val
202
167
  return settings
203
168
 
204
169
 
205
- def read_rekordbox6_options(pioneer_app_dir: Union[str, Path]) -> dict:
170
+ def read_rekordbox6_options(pioneer_app_dir: Union[str, Path]) -> Dict[str, Any]:
206
171
  """Finds and parses the Rekordbox 6 `options.json` file with additional settings.
207
172
 
208
173
  The options file contains additional settings used by Rekordbox 6, for example the
@@ -231,47 +196,7 @@ def read_rekordbox6_options(pioneer_app_dir: Union[str, Path]) -> dict:
231
196
  return options
232
197
 
233
198
 
234
- def read_rekordbox6_asar(rb6_install_dir: Union[str, Path]) -> str:
235
- """Finds and parses the Rekordbox 6 `app.asar` archive file.
236
-
237
- An ASAR file is an archive used to package source code for an application
238
- using Electron. Rekordbox 6 stores some useful information for opening the
239
- new `master.db` database in this file.
240
-
241
- Parameters
242
- ----------
243
- rb6_install_dir : str or Path
244
- The path of the Rekordbox 6 installation directory.
245
-
246
- Returns
247
- -------
248
- asar_data : str
249
- The data of the Rekordbox 6 `app.asar` archive file as ANSI encoded string.
250
- """
251
- rb6_install_dir = Path(rb6_install_dir)
252
- # Find location of app asar file
253
- if sys.platform == "win32":
254
- location = rb6_install_dir / "rekordboxAgent-win32-x64" / "resources"
255
- encoding = "ANSI"
256
- elif sys.platform == "darwin":
257
- if not str(rb6_install_dir).endswith(".app"):
258
- rb6_install_dir = rb6_install_dir / "rekordbox.app"
259
- location = (
260
- rb6_install_dir / "Contents" / "MacOS" / "rekordboxAgent.app" / "Contents" / "Resources"
261
- )
262
- encoding = "cp437"
263
- else:
264
- logger.warning(f"OS {sys.platform} not supported!")
265
- return ""
266
-
267
- # Read asar file
268
- path = (location / "app.asar").absolute()
269
- with open(path, "rb") as fh:
270
- data = fh.read().decode(encoding, errors="replace")
271
- return data
272
-
273
-
274
- def _extract_version(name, major_version):
199
+ def _extract_version(name: str, major_version: int) -> str:
275
200
  name = name.replace(".app", "") # Needed for MacOS
276
201
  ver_str = name.replace("rekordbox", "").strip()
277
202
  if not ver_str:
@@ -284,7 +209,7 @@ def _get_rb_config(
284
209
  pioneer_app_dir: Path,
285
210
  major_version: int,
286
211
  application_dirname: str = "",
287
- ) -> dict:
212
+ ) -> Dict[str, Any]:
288
213
  """Get the program configuration for a given Rekordbox major version.
289
214
 
290
215
  Parameters
@@ -378,207 +303,18 @@ def _get_rb_config(
378
303
  return conf
379
304
 
380
305
 
381
- def _get_rb5_config(pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str = "") -> dict:
306
+ def _get_rb5_config(
307
+ pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str = ""
308
+ ) -> Dict[str, Any]:
382
309
  """Get the program configuration for Rekordbox v5.x.x."""
383
310
  major_version = 5
384
311
  conf = _get_rb_config(pioneer_prog_dir, pioneer_app_dir, major_version, dirname)
385
312
  return conf
386
313
 
387
314
 
388
- def _extract_pw(pioneer_install_dir: Path) -> str: # pragma: no cover
389
- """Extract the password for decrypting the Rekordbox 6 database key."""
390
- asar_data = read_rekordbox6_asar(pioneer_install_dir)
391
- match_result = re.search('pass: ".(.*?)"', asar_data)
392
- if match_result is None:
393
- raise RuntimeError("Could not read `app.asar` file.")
394
- match = match_result.group(0)
395
- pw = match.replace("pass: ", "").strip('"')
396
- return pw
397
-
398
-
399
- class KeyExtractor:
400
- """Extracts the Rekordbox database key using code injection.
401
-
402
- This method works by injecting code into the Rekordbox process and intercepting the
403
- call to unlock the database. Works for any Rekordbox version.
404
- """
405
-
406
- # fmt: off
407
- SCRIPT = textwrap.dedent("""
408
- var sqlite3_key = Module.findExportByName(null, 'sqlite3_key');
409
-
410
- Interceptor.attach(sqlite3_key, {
411
- onEnter: function(args) {
412
- var size = args[2].toInt32();
413
- var key = args[1].readUtf8String(size);
414
- send('sqlite3_key: ' + key);
415
- }
416
- });
417
- """)
418
- # fmt: on
419
-
420
- def __init__(self, rekordbox_executable):
421
- self.executable = str(rekordbox_executable)
422
- self.key = ""
423
-
424
- def on_message(self, message, data):
425
- payload = message["payload"]
426
- if payload.startswith("sqlite3_key"):
427
- self.key = payload.split(": ")[1]
428
-
429
- def run(self):
430
- pid = get_rekordbox_pid()
431
- if pid:
432
- raise RuntimeError(
433
- "Rekordbox is running. Please close Rekordbox before running the `KeyExtractor`."
434
- )
435
- # Spawn Rekordbox process and attach to it
436
- pid = frida.spawn(self.executable)
437
- frida.resume(pid)
438
- session = frida.attach(pid)
439
- script = session.create_script(self.SCRIPT)
440
- script.on("message", self.on_message)
441
- script.load()
442
- # Wait for key to be extracted
443
- while not self.key:
444
- time.sleep(0.1)
445
- # Kill Rekordbox process
446
- frida.kill(pid)
447
-
448
- return self.key
449
-
450
-
451
- def write_db6_key_cache(key: str) -> None: # pragma: no cover
452
- r"""Writes the decrypted Rekordbox6 database key to the cache file.
453
-
454
- This method can also be used to manually cache the database key, provided
455
- the user has found the key somewhere else. The key can be, for example,
456
- found in some other projects that hard-coded it.
457
-
458
- The cache file is stored in the application data directory of pyrekordbox:
459
- Windows: `C:\Users\<user>\AppData\Roaming\pyrekordbox`
460
- macOS: `~/Library/Application Support/pyrekordbox`
461
-
462
- Parameters
463
- ----------
464
- key : str
465
- The decrypted database key. To make sure the key is valid, the first
466
- five characters are checked before writing the key to the cache file.
467
- The key should start with '402fd'.
468
-
469
- Examples
470
- --------
471
- >>> from pyrekordbox.config import write_db6_key_cache
472
- >>> from pyrekordbox import Rekordbox6Database
473
- >>> write_db6_key_cache("402fd...")
474
- >>> db = Rekordbox6Database() # The db can now be opened without providing the key
475
- """
476
- # Check if the key looks like a valid key
477
- if not key.startswith("402fd"):
478
- raise ValueError("The provided database key doesn't look valid!")
479
- lines = list()
480
- lines.append(f"version: {_cache_file_version}")
481
- lines.append("dp: " + key)
482
- text = "\n".join(lines)
483
-
484
- cache_file = get_cache_file()
485
- if not cache_file.parent.exists():
486
- cache_file.parent.mkdir()
487
-
488
- with open(cache_file, "w") as fh:
489
- fh.write(text)
490
- # Set the config key to make sure the key is present after calling method
491
- if __config__["rekordbox6"]:
492
- __config__["rekordbox6"]["dp"] = key
493
- if __config__["rekordbox7"]:
494
- __config__["rekordbox7"]["dp"] = key
495
-
496
-
497
- def _update_sqlite_key(opts, conf):
498
- cache_version = 0
499
- pw, dp = "", ""
500
-
501
- cache_file = get_cache_file()
502
-
503
- if cache_file.exists(): # pragma: no cover
504
- logger.debug("Found cache file %s", cache_file)
505
- # Read cache file
506
- with open(cache_file, "r") as fh:
507
- text = fh.read()
508
- lines = text.splitlines()
509
- if lines[0].startswith("version:"):
510
- cache_version = int(lines[0].split(":")[1].strip())
511
- else:
512
- cache_version = 1
513
- if cache_version == 1:
514
- # Cache file introduced in pyrekordbox 0.1.6 contains only the password
515
- pw = lines[0]
516
- logger.debug("Found pw in cache file")
517
- elif cache_version == 2:
518
- # Cache file introduced in pyrekordbox 0.1.7 contains version and db key
519
- dp = lines[1].split(":")[1].strip()
520
- logger.debug("Found dp in cache file")
521
- else:
522
- raise ValueError(f"Invalid cache version: {cache_version}")
523
- else:
524
- logger.debug("No cache file found")
525
-
526
- if cache_version < _cache_file_version: # pragma: no cover
527
- # Update cache file
528
- if not pw:
529
- logger.debug("Extracting pw")
530
- try:
531
- pw = _extract_pw(conf["install_dir"])
532
- logger.debug("Extracted pw from 'app.asar'")
533
- except (FileNotFoundError, RuntimeError):
534
- logger.debug("Could not extract pw from 'app.asar'")
535
- pw = ""
536
-
537
- if not dp:
538
- if pw:
539
- cipher = blowfish.Cipher(pw.encode())
540
- dp = base64.standard_b64decode(opts["dp"])
541
- dp = b"".join(cipher.decrypt_ecb(dp)).decode()
542
- logger.debug("Unlocked dp from pw: %s", dp)
543
- else:
544
- if sys.platform == "win32":
545
- executable = conf["install_dir"] / "rekordbox.exe"
546
- elif sys.platform == "darwin":
547
- install_dir = conf["install_dir"]
548
- if not str(install_dir).endswith(".app"):
549
- install_dir = install_dir / "rekordbox.app"
550
- executable = install_dir / "Contents" / "MacOS" / "rekordbox"
551
- else:
552
- # Linux: not supported
553
- logger.warning(f"OS {sys.platform} not supported!")
554
- executable = Path("")
555
-
556
- if not executable.exists():
557
- logger.warning(f"Could not find Rekordbox executable: {executable}")
558
- else:
559
- extractor = KeyExtractor(executable)
560
- try:
561
- dp = extractor.run()
562
- logger.debug("Extracted dp from Rekordbox process")
563
- except Exception as e:
564
- logger.info(f"`KeyExtractor` failed: {e}")
565
-
566
- if dp:
567
- logger.debug("Writing dp to cache file")
568
- write_db6_key_cache(dp)
569
- else:
570
- logging.warning(
571
- "Could not retrieve db-key or read it from cache file,"
572
- "use the CLI to download it from external sources: "
573
- "`python -m pyrekordbox download-key`"
574
- )
575
-
576
- # Add database key to config if found
577
- if dp:
578
- conf["dp"] = dp
579
-
580
-
581
- def _get_rb6_config(pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str = "") -> dict:
315
+ def _get_rb6_config(
316
+ pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str = ""
317
+ ) -> Dict[str, Any]:
582
318
  """Get the program configuration for Rekordbox v6.x.x."""
583
319
  major_version = 6
584
320
  conf = _get_rb_config(pioneer_prog_dir, pioneer_app_dir, major_version, dirname)
@@ -590,13 +326,12 @@ def _get_rb6_config(pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str
590
326
  assert str(conf["db_dir"]) == str(db_dir)
591
327
  assert str(conf["db_path"]) == str(db_path)
592
328
 
593
- # Update SQLite key
594
- _update_sqlite_key(opts, conf)
595
-
596
329
  return conf
597
330
 
598
331
 
599
- def _get_rb7_config(pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str = "") -> dict:
332
+ def _get_rb7_config(
333
+ pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str = ""
334
+ ) -> Dict[str, Any]:
600
335
  """Get the program configuration for Rekordbox v7.x.x."""
601
336
  major_version = 7
602
337
  conf = _get_rb_config(pioneer_prog_dir, pioneer_app_dir, major_version, dirname)
@@ -608,76 +343,16 @@ def _get_rb7_config(pioneer_prog_dir: Path, pioneer_app_dir: Path, dirname: str
608
343
  assert str(conf["db_dir"]) == str(db_dir)
609
344
  assert str(conf["db_path"]) == str(db_path)
610
345
 
611
- # Update SQLite key
612
- _update_sqlite_key(opts, conf)
613
-
614
346
  return conf
615
347
 
616
348
 
617
- # noinspection PyPackageRequirements,PyUnresolvedReferences
618
- def _read_config_file(path: str) -> dict:
619
- path = Path(path)
620
- if not path.exists():
621
- raise FileNotFoundError(f"No such file or directory: '{path}'")
622
-
623
- ext = path.suffix.lower()
624
- if ext == ".cfg":
625
- from configparser import ConfigParser
626
-
627
- parser = ConfigParser()
628
- parser.read(path)
629
- return parser
630
- elif ext == ".toml":
631
- import toml
632
-
633
- return toml.load(path)
634
- elif ext in (".yaml", ".yml"):
635
- import yaml
636
-
637
- with open(path, "r") as stream:
638
- return yaml.safe_load(stream)
639
- return dict()
640
-
641
-
642
- def read_pyrekordbox_configuration():
643
- """Reads the pyrekordbox configuration.
644
-
645
- So far only the `pioneer-install-dir` and `pioneer-app-dir` fileds in the
646
- `rekordbox` section are supported. Supported configuration files are
647
- pyproject.toml, setup.cfg, rekordbox.toml, rekordbox.cfg and rekordbox.yml.
648
- The files are searched for in that order.
649
-
650
- Returns
651
- -------
652
- pyrekordbox_config : dict
653
- The pyrekordbox configuration data, if found.
654
- """
655
- files = ["pyproject.toml", "setup.cfg"]
656
- for ext in [".toml", ".cfg", ".yaml", ".yml"]:
657
- files.append("pyrekordbox" + ext)
658
-
659
- for file in files:
660
- try:
661
- data = _read_config_file(file)
662
- config = dict(data["rekordbox"])
663
- logger.debug("Read configuration from '%s'", file)
664
- except (ImportError, FileNotFoundError) as e:
665
- logger.debug("Could not read config file '%s': %s", file, e)
666
- except KeyError:
667
- pass
668
- else:
669
- if config:
670
- return config
671
- return dict()
672
-
673
-
674
349
  def update_config(
675
350
  pioneer_install_dir: Union[str, Path] = None,
676
351
  pioneer_app_dir: Union[str, Path] = None,
677
352
  rb5_install_dirname: str = "",
678
353
  rb6_install_dirname: str = "",
679
354
  rb7_install_dirname: str = "",
680
- ):
355
+ ) -> None:
681
356
  """Update the pyrekordbox configuration.
682
357
 
683
358
  This method scans the system for the Rekordbox installation and application data
@@ -707,19 +382,6 @@ def update_config(
707
382
  The name of the Rekordbox 7 installation directory. By default, the normal
708
383
  directory name is used (Windows: 'rekordbox 7.x.x', macOS: 'rekordbox 7.app').
709
384
  """
710
- # Read config file
711
- conf = read_pyrekordbox_configuration()
712
- if pioneer_install_dir is None and "pioneer-install-dir" in conf:
713
- pioneer_install_dir = conf["pioneer-install-dir"]
714
- if pioneer_app_dir is None and "pioneer-app-dir" in conf:
715
- pioneer_app_dir = conf["pioneer-app-dir"]
716
- if not rb5_install_dirname and "rekordbox5-install-dirname" in conf:
717
- rb5_install_dirname = conf["rekordbox5-install-dirname"]
718
- if not rb6_install_dirname and "rekordbox6-install-dirname" in conf:
719
- rb6_install_dirname = conf["rekordbox6-install-dirname"]
720
- if not rb7_install_dirname and "rekordbox7-install-dirname" in conf:
721
- rb7_install_dirname = conf["rekordbox7-install-dirname"]
722
-
723
385
  # Pioneer installation directory
724
386
  try:
725
387
  pioneer_install_dir = get_pioneer_install_dir(pioneer_install_dir)
@@ -758,7 +420,7 @@ def update_config(
758
420
  logger.info(e)
759
421
 
760
422
 
761
- def get_config(section: str, key: str = None):
423
+ def get_config(section: str, key: str = None) -> Any:
762
424
  """Gets a section or value of the pyrekordbox configuration.
763
425
 
764
426
  Parameters
@@ -5,10 +5,20 @@
5
5
  import xml.etree.cElementTree as xml
6
6
  from datetime import datetime
7
7
  from pathlib import Path
8
+ from typing import Any, Dict, List, Optional, Union
8
9
 
9
10
  from ..config import get_config
10
11
  from ..utils import pretty_xml
11
12
 
13
+ Attribs = Dict[str, Any]
14
+
15
+
16
+ class XmlElementNotInitializedError(Exception):
17
+ """Raised when an XML element is not initialized."""
18
+
19
+ def __init__(self, name: str) -> None:
20
+ super().__init__(f"XML element {name} is not initialized!")
21
+
12
22
 
13
23
  class MasterPlaylistXml:
14
24
  """Rekordbox v6 masterPlaylists6.xml file handler.
@@ -26,7 +36,7 @@ class MasterPlaylistXml:
26
36
 
27
37
  KEYS = ["Id", "ParentId", "Attributes", "Timestamp", "Lib_Type", "CheckType"]
28
38
 
29
- def __init__(self, path=None, db_dir=None):
39
+ def __init__(self, path: Union[str, Path] = None, db_dir: Union[str, Path] = None):
30
40
  if path is None:
31
41
  if db_dir is None:
32
42
  db_dir = get_config("rekordbox6", "db_dir")
@@ -40,29 +50,33 @@ class MasterPlaylistXml:
40
50
  self._changed = False
41
51
 
42
52
  @property
43
- def version(self):
53
+ def version(self) -> str:
44
54
  return self.root.attrib["Version"]
45
55
 
46
56
  @property
47
- def automatic_sync(self):
57
+ def automatic_sync(self) -> str:
48
58
  return self.root.attrib["AutomaticSync"]
49
59
 
50
60
  @property
51
- def rekordbox_version(self):
61
+ def rekordbox_version(self) -> str:
62
+ if self.product is None:
63
+ raise XmlElementNotInitializedError("product")
52
64
  return self.product.attrib["Version"]
53
65
 
54
66
  @property
55
- def modified(self):
67
+ def modified(self) -> bool:
56
68
  return self._changed
57
69
 
58
- def get_playlists(self):
70
+ def get_playlists(self) -> List[Dict[str, Any]]:
59
71
  """Returns a list of the attributes of all playlist elements."""
72
+ if self.playlists is None:
73
+ raise XmlElementNotInitializedError("playlists")
60
74
  items = list()
61
75
  for playlist in self.playlists:
62
76
  items.append(playlist.attrib)
63
77
  return items
64
78
 
65
- def get(self, playlist_id):
79
+ def get(self, playlist_id: Union[str, int]) -> Optional[Attribs]:
66
80
  """Returns element attribs with the PlaylistID used in the `master.db` database.
67
81
 
68
82
  Parameters
@@ -75,11 +89,13 @@ class MasterPlaylistXml:
75
89
  -------
76
90
  playlist : dict
77
91
  """
92
+ if self.playlists is None:
93
+ raise XmlElementNotInitializedError("playlists")
78
94
  hex_id = f"{int(playlist_id):X}"
79
95
  element = self.playlists.find(f'.//NODE[@Id="{hex_id}"]')
80
96
  if element is None:
81
97
  return None
82
- attribs = dict(element.attrib)
98
+ attribs: Attribs = dict(element.attrib)
83
99
  attribs["Attribute"] = int(attribs["Attribute"])
84
100
  attribs["Timestamp"] = datetime.fromtimestamp(int(attribs["Timestamp"]) / 1000)
85
101
  attribs["Lib_Type"] = int(attribs["Lib_Type"])
@@ -94,7 +110,7 @@ class MasterPlaylistXml:
94
110
  updated_at: datetime,
95
111
  lib_type: int = 0,
96
112
  check_type: int = 0,
97
- ):
113
+ ) -> xml.Element:
98
114
  """Adds a new element with the PlaylistID used in the `master.db` database.
99
115
 
100
116
  Parameters
@@ -119,6 +135,9 @@ class MasterPlaylistXml:
119
135
  element : xml.Element
120
136
  The newly created element.
121
137
  """
138
+ if self.playlists is None:
139
+ raise XmlElementNotInitializedError("playlists")
140
+
122
141
  hex_id = f"{int(playlist_id):X}"
123
142
  parent_id = f"{int(parent_id):X}" if parent_id != "root" else "0"
124
143
  timestamp = int(updated_at.timestamp() * 1000)
@@ -136,7 +155,7 @@ class MasterPlaylistXml:
136
155
  self._changed = True
137
156
  return element
138
157
 
139
- def remove(self, playlist_id):
158
+ def remove(self, playlist_id: Union[str, int]) -> None:
140
159
  """Removes the element with the PlaylistID used in the `master.db` database.
141
160
 
142
161
  Parameters
@@ -145,6 +164,9 @@ class MasterPlaylistXml:
145
164
  The playlist ID used in the main `master.db` database. This id is converted
146
165
  to hexadecimal format before searching.
147
166
  """
167
+ if self.playlists is None:
168
+ raise XmlElementNotInitializedError("playlists")
169
+
148
170
  hex_id = f"{int(playlist_id):X}"
149
171
  element = self.playlists.find(f'.//NODE[@Id="{hex_id}"]')
150
172
  if element is None:
@@ -160,7 +182,7 @@ class MasterPlaylistXml:
160
182
  updated_at: datetime = None,
161
183
  lib_type: int = None,
162
184
  check_type: int = None,
163
- ):
185
+ ) -> None:
164
186
  """Updates the element with the PlaylistID used in the `master.db` database.
165
187
 
166
188
  Parameters
@@ -180,6 +202,9 @@ class MasterPlaylistXml:
180
202
  check_type : int, optional
181
203
  The check type. It seems to be always 0.
182
204
  """
205
+ if self.playlists is None:
206
+ raise XmlElementNotInitializedError("playlists")
207
+
183
208
  hex_id = f"{int(playlist_id):X}"
184
209
  element = self.playlists.find(f'.//NODE[@Id="{hex_id}"]')
185
210
  if element is None:
@@ -200,10 +225,11 @@ class MasterPlaylistXml:
200
225
  element.attrib.update(attribs)
201
226
  self._changed = True
202
227
 
203
- def to_string(self, indent=None):
204
- return pretty_xml(self.root, indent, encoding="utf-8")
228
+ def to_string(self, indent: str = None) -> str:
229
+ text: str = pretty_xml(self.root, indent, encoding="utf-8")
230
+ return text
205
231
 
206
- def save(self, path=None, indent=None):
232
+ def save(self, path: Union[str, Path] = None, indent: str = None) -> None:
207
233
  if path is None:
208
234
  path = self.path
209
235
  path = str(path)