lexicon-python 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lexicon/__init__.py +19 -4
- lexicon/client.py +19 -4
- lexicon/resources/_common_types.py +102 -53
- lexicon/resources/base.py +3 -1
- lexicon/resources/playlist_tracks.py +17 -13
- lexicon/resources/playlists.py +121 -31
- lexicon/resources/playlists_types.py +39 -19
- lexicon/resources/tag_categories.py +28 -21
- lexicon/resources/tag_categories_types.py +12 -7
- lexicon/resources/tags.py +10 -6
- lexicon/resources/tags_types.py +12 -6
- lexicon/resources/tracks.py +201 -46
- lexicon/resources/tracks_types.py +361 -105
- lexicon/tools/__init__.py +0 -2
- lexicon/tools/playlists.py +5 -1
- lexicon/tools/tracks.py +130 -0
- lexicon/utils.py +1 -0
- {lexicon_python-0.2.0.dist-info → lexicon_python-0.3.0.dist-info}/METADATA +73 -9
- lexicon_python-0.3.0.dist-info/RECORD +24 -0
- {lexicon_python-0.2.0.dist-info → lexicon_python-0.3.0.dist-info}/WHEEL +1 -1
- lexicon_python-0.2.0.dist-info/RECORD +0 -23
- {lexicon_python-0.2.0.dist-info → lexicon_python-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {lexicon_python-0.2.0.dist-info → lexicon_python-0.3.0.dist-info}/top_level.txt +0 -0
lexicon/__init__.py
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
"""Public package surface for the lexicon Python client."""
|
|
2
2
|
|
|
3
3
|
from .client import DEFAULT_HOST, LEXICON_PORT, Lexicon
|
|
4
|
-
from .resources.
|
|
4
|
+
from .resources.playlists_types import PlaylistResponse
|
|
5
|
+
from .resources.tag_categories_types import TagCategoryResponse
|
|
6
|
+
from .resources.tags_types import TagResponse
|
|
7
|
+
from .resources.tracks_types import (
|
|
8
|
+
CuePointResponse,
|
|
9
|
+
TempoMarkerResponse,
|
|
10
|
+
TrackResponse,
|
|
11
|
+
)
|
|
5
12
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
13
|
+
__all__ = [
|
|
14
|
+
"DEFAULT_HOST",
|
|
15
|
+
"LEXICON_PORT",
|
|
16
|
+
"Lexicon",
|
|
17
|
+
"TrackResponse",
|
|
18
|
+
"PlaylistResponse",
|
|
19
|
+
"TagResponse",
|
|
20
|
+
"TagCategoryResponse",
|
|
21
|
+
"CuePointResponse",
|
|
22
|
+
"TempoMarkerResponse",
|
|
23
|
+
]
|
lexicon/client.py
CHANGED
|
@@ -14,17 +14,26 @@ from .resources.tag_categories import TagCategories
|
|
|
14
14
|
from .resources.tags import Tags
|
|
15
15
|
from .resources.tracks import Tracks
|
|
16
16
|
from .tools import playlists as playlist_tools
|
|
17
|
+
from .tools import tracks as track_tools
|
|
18
|
+
|
|
17
19
|
DEFAULT_HOST = os.environ.get("LEXICON_HOST", "localhost")
|
|
18
20
|
LEXICON_PORT = int(os.environ.get("LEXICON_PORT", "48624"))
|
|
19
21
|
|
|
20
22
|
|
|
23
|
+
class Tools:
|
|
24
|
+
"""Namespace for helper tools."""
|
|
25
|
+
|
|
26
|
+
playlists = playlist_tools
|
|
27
|
+
tracks = track_tools
|
|
28
|
+
|
|
29
|
+
|
|
21
30
|
class Lexicon:
|
|
22
31
|
"""Resource-grouped client for the Lexicon Local API."""
|
|
23
32
|
|
|
24
33
|
tracks: Tracks
|
|
25
34
|
playlists: Playlists
|
|
26
35
|
tags: Tags
|
|
27
|
-
tools:
|
|
36
|
+
tools: Tools
|
|
28
37
|
|
|
29
38
|
def __init__(
|
|
30
39
|
self,
|
|
@@ -34,6 +43,7 @@ class Lexicon:
|
|
|
34
43
|
default_timeout: int = 20,
|
|
35
44
|
session: Optional[requests.Session] = None,
|
|
36
45
|
raise_on_error: bool = False,
|
|
46
|
+
raw_enums: bool = True,
|
|
37
47
|
) -> None:
|
|
38
48
|
"""Create a Lexicon client bound to an API instance.
|
|
39
49
|
|
|
@@ -49,21 +59,26 @@ class Lexicon:
|
|
|
49
59
|
Optional requests session to reuse connections.
|
|
50
60
|
raise_on_error
|
|
51
61
|
If True, raise HTTP errors instead of returning None.
|
|
62
|
+
raw_enums
|
|
63
|
+
If True (default), return API enum values as raw codes (e.g. ``"1"``).
|
|
64
|
+
If False, convert to human-readable names (e.g. ``"normal"``).
|
|
52
65
|
"""
|
|
53
66
|
self.host = host or DEFAULT_HOST
|
|
54
67
|
self.port = int(port or LEXICON_PORT)
|
|
55
68
|
self.default_timeout = default_timeout
|
|
56
69
|
self.raise_on_error = raise_on_error
|
|
70
|
+
self.raw_enums = raw_enums
|
|
57
71
|
self._logger = logging.getLogger(__name__)
|
|
58
72
|
self._session = session
|
|
59
73
|
|
|
60
74
|
self.tracks: Tracks = Tracks(self)
|
|
61
75
|
self.playlists: Playlists = Playlists(self)
|
|
62
|
-
self.playlists.tracks = PlaylistTracks(
|
|
76
|
+
self.playlists.tracks = PlaylistTracks(
|
|
77
|
+
self, tracks=self.tracks, playlists=self.playlists
|
|
78
|
+
)
|
|
63
79
|
self.tags: Tags = Tags(self)
|
|
64
80
|
self.tags.categories = TagCategories(self)
|
|
65
|
-
self.tools =
|
|
66
|
-
self.tools.playlists = playlist_tools
|
|
81
|
+
self.tools = Tools()
|
|
67
82
|
|
|
68
83
|
def request(
|
|
69
84
|
self,
|
|
@@ -22,62 +22,85 @@ ValidationMode = Literal["off", "warn", "strict"]
|
|
|
22
22
|
|
|
23
23
|
# --- Color Types and Normalization --- #
|
|
24
24
|
Color = Literal[
|
|
25
|
-
"red_dark",
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
25
|
+
"red_dark",
|
|
26
|
+
"red",
|
|
27
|
+
"red_light",
|
|
28
|
+
"red_orange",
|
|
29
|
+
"orange",
|
|
30
|
+
"beige",
|
|
31
|
+
"yellow_dark",
|
|
32
|
+
"yellow",
|
|
33
|
+
"lime",
|
|
34
|
+
"green_light",
|
|
35
|
+
"green",
|
|
36
|
+
"green_dark",
|
|
37
|
+
"teal",
|
|
38
|
+
"aqua",
|
|
39
|
+
"aqua_dark",
|
|
40
|
+
"blue_light",
|
|
41
|
+
"blue",
|
|
42
|
+
"blue_dark",
|
|
43
|
+
"blue_violet",
|
|
44
|
+
"violet",
|
|
45
|
+
"violet_light",
|
|
46
|
+
"magenta",
|
|
47
|
+
"magenta_dark",
|
|
48
|
+
"magenta_red",
|
|
49
|
+
"grey_light",
|
|
50
|
+
"grey_dark",
|
|
51
|
+
"black",
|
|
52
|
+
"white",
|
|
29
53
|
]
|
|
30
54
|
COLORS: tuple[Color, ...] = get_args(Color)
|
|
31
55
|
COLOR_RGBS: tuple[tuple[int, int, int], ...] = (
|
|
32
|
-
(158,
|
|
33
|
-
(230,
|
|
34
|
-
(242, 102,
|
|
35
|
-
(239,
|
|
36
|
-
(232, 137,
|
|
37
|
-
(255, 225, 148),
|
|
38
|
-
(245, 208,
|
|
39
|
-
(245, 245,
|
|
40
|
-
(186, 232,
|
|
41
|
-
(174, 245,
|
|
42
|
-
(127, 231,
|
|
43
|
-
(
|
|
44
|
-
(
|
|
45
|
-
(
|
|
46
|
-
(
|
|
47
|
-
(
|
|
48
|
-
(
|
|
49
|
-
(
|
|
50
|
-
(132,
|
|
51
|
-
(170,
|
|
52
|
-
(198, 140, 243),
|
|
53
|
-
(230,
|
|
54
|
-
(170,
|
|
55
|
-
(222,
|
|
56
|
-
(173, 173, 173),
|
|
57
|
-
(
|
|
58
|
-
(
|
|
59
|
-
(255, 255, 255),
|
|
56
|
+
(158, 15, 7), # red_dark
|
|
57
|
+
(230, 15, 13), # red
|
|
58
|
+
(242, 102, 92), # red_light
|
|
59
|
+
(239, 89, 15), # red_orange
|
|
60
|
+
(232, 137, 20), # orange
|
|
61
|
+
(255, 225, 148), # beige
|
|
62
|
+
(245, 208, 1), # yellow_dark
|
|
63
|
+
(245, 245, 10), # yellow
|
|
64
|
+
(186, 232, 22), # lime
|
|
65
|
+
(174, 245, 95), # green_light
|
|
66
|
+
(127, 231, 16), # green
|
|
67
|
+
(75, 140, 8), # green_dark
|
|
68
|
+
(20, 222, 120), # teal
|
|
69
|
+
(20, 222, 202), # aqua
|
|
70
|
+
(7, 148, 134), # aqua_dark
|
|
71
|
+
(47, 168, 237), # blue_light
|
|
72
|
+
(14, 88, 222), # blue
|
|
73
|
+
(0, 40, 171), # blue_dark
|
|
74
|
+
(132, 0, 255), # blue_violet
|
|
75
|
+
(170, 59, 255), # violet
|
|
76
|
+
(198, 140, 243), # violet_light
|
|
77
|
+
(230, 15, 222), # magenta
|
|
78
|
+
(170, 0, 188), # magenta_dark
|
|
79
|
+
(222, 17, 92), # magenta_red
|
|
80
|
+
(173, 173, 173), # grey_light
|
|
81
|
+
(92, 92, 92), # grey_dark
|
|
82
|
+
(48, 48, 48), # black
|
|
83
|
+
(255, 255, 255), # white
|
|
60
84
|
)
|
|
61
85
|
|
|
62
86
|
|
|
63
|
-
def
|
|
64
|
-
"""
|
|
87
|
+
def _parse_color_rgb(value: object) -> tuple[int, int, int] | None:
|
|
88
|
+
"""Parse a color input into an RGB tuple.
|
|
65
89
|
|
|
66
90
|
Parameters
|
|
67
91
|
----------
|
|
68
92
|
value
|
|
69
93
|
Color input. Supported forms:
|
|
70
|
-
- ``None`` or the string ``"None"`` (case-insensitive)
|
|
71
|
-
- Lexicon color names (see ``COLORS``)
|
|
72
|
-
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
- Packed RGB integer (``0xRRGGBB``, ``0xAARRGGBB``)
|
|
94
|
+
- ``None`` or the string ``"None"`` (case-insensitive) -> returns None
|
|
95
|
+
- Lexicon color names (see ``COLORS``) -> looked up from COLOR_RGBS
|
|
96
|
+
- Hex strings (``#RGB``, ``#RGBA``, ``#RRGGBB``, ``#RRGGBBAA``)
|
|
97
|
+
- RGB/RGBA tuples or lists (ints 0-255 or floats 0-1)
|
|
98
|
+
- Packed RGB integer (``0xRRGGBB``, ``0xAARRGGBB``)
|
|
76
99
|
|
|
77
100
|
Returns
|
|
78
101
|
-------
|
|
79
|
-
|
|
80
|
-
|
|
102
|
+
tuple[int, int, int] or None
|
|
103
|
+
RGB tuple (0-255), or ``None`` when the input is ``None``.
|
|
81
104
|
|
|
82
105
|
Raises
|
|
83
106
|
------
|
|
@@ -90,20 +113,23 @@ def _normalize_color(value: object) -> Color | None:
|
|
|
90
113
|
if value.strip().lower() == "none":
|
|
91
114
|
return None
|
|
92
115
|
if value in COLORS:
|
|
93
|
-
return value
|
|
94
|
-
hex_match = re.match(
|
|
116
|
+
return COLOR_RGBS[COLORS.index(value)]
|
|
117
|
+
hex_match = re.match(
|
|
118
|
+
r"^\s*#?([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})\s*$",
|
|
119
|
+
value,
|
|
120
|
+
flags=re.IGNORECASE,
|
|
121
|
+
)
|
|
95
122
|
if hex_match:
|
|
96
123
|
hex_value = hex_match.group(1)
|
|
97
124
|
if len(hex_value) in (3, 4):
|
|
98
125
|
hex_value = "".join(ch * 2 for ch in hex_value)
|
|
99
126
|
if len(hex_value) == 8:
|
|
100
127
|
hex_value = hex_value[:6]
|
|
101
|
-
|
|
128
|
+
return (
|
|
102
129
|
int(hex_value[0:2], 16),
|
|
103
130
|
int(hex_value[2:4], 16),
|
|
104
131
|
int(hex_value[4:6], 16),
|
|
105
132
|
)
|
|
106
|
-
return _nearest_color(rgb)
|
|
107
133
|
raise ValueError(f"Unsupported string input {value!r}")
|
|
108
134
|
|
|
109
135
|
if isinstance(value, int):
|
|
@@ -111,8 +137,7 @@ def _normalize_color(value: object) -> Color | None:
|
|
|
111
137
|
raise ValueError(f"Negative packed int {value!r}")
|
|
112
138
|
if value > 0xFFFFFF:
|
|
113
139
|
value = value & 0xFFFFFF
|
|
114
|
-
|
|
115
|
-
return _nearest_color(rgb)
|
|
140
|
+
return ((value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF)
|
|
116
141
|
|
|
117
142
|
if isinstance(value, (tuple, list)) and len(value) in (3, 4):
|
|
118
143
|
rgb_values = value[:3]
|
|
@@ -123,12 +148,34 @@ def _normalize_color(value: object) -> Color | None:
|
|
|
123
148
|
if channel_value <= 1:
|
|
124
149
|
channel_value *= 255
|
|
125
150
|
rgb.append(int(max(0, min(255, round(channel_value)))))
|
|
126
|
-
return
|
|
151
|
+
return (rgb[0], rgb[1], rgb[2])
|
|
127
152
|
raise ValueError(f"Invalid RGB tuple values {value!r}")
|
|
128
153
|
|
|
129
154
|
raise ValueError(f"Unsupported input type {type(value)}")
|
|
130
155
|
|
|
131
156
|
|
|
157
|
+
def _normalize_color(value: object) -> Color | None:
|
|
158
|
+
"""Normalize color inputs to the nearest Lexicon color name.
|
|
159
|
+
|
|
160
|
+
Used for tracks and cuepoints which use a fixed color swatch.
|
|
161
|
+
"""
|
|
162
|
+
rgb = _parse_color_rgb(value)
|
|
163
|
+
if rgb is None:
|
|
164
|
+
return None
|
|
165
|
+
return _nearest_color(rgb)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _normalize_color_hex(value: object) -> str | None:
|
|
169
|
+
"""Normalize color inputs to a hex string (#RRGGBB).
|
|
170
|
+
|
|
171
|
+
Used for tag categories which support a full color picker.
|
|
172
|
+
"""
|
|
173
|
+
rgb = _parse_color_rgb(value)
|
|
174
|
+
if rgb is None:
|
|
175
|
+
return None
|
|
176
|
+
return f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}"
|
|
177
|
+
|
|
178
|
+
|
|
132
179
|
def _nearest_color(rgb: tuple[int, int, int]) -> Color:
|
|
133
180
|
"""Find the nearest Lexicon color to the given RGB values."""
|
|
134
181
|
best_index = 0
|
|
@@ -147,19 +194,19 @@ def _nearest_color(rgb: tuple[int, int, int]) -> Color:
|
|
|
147
194
|
# --- ID Sequence Normalization --- #
|
|
148
195
|
def _normalize_id_sequence(ids: int | Sequence[int] | object) -> list[int] | None:
|
|
149
196
|
"""Normalize single ID or sequence of IDs to a deduplicated list.
|
|
150
|
-
|
|
197
|
+
|
|
151
198
|
Parameters
|
|
152
199
|
----------
|
|
153
200
|
ids
|
|
154
201
|
Single integer ID or sequence of integer IDs.
|
|
155
|
-
|
|
202
|
+
|
|
156
203
|
Returns
|
|
157
204
|
-------
|
|
158
205
|
list[int] | None
|
|
159
206
|
Deduplicated list of valid IDs (>= 1), or None if:
|
|
160
207
|
- Input is not int or sequence (or is str/bytes)
|
|
161
208
|
- No valid IDs found (all < 1)
|
|
162
|
-
|
|
209
|
+
|
|
163
210
|
Notes
|
|
164
211
|
-----
|
|
165
212
|
Used for normalizing track IDs, tag IDs, and similar integer sequences.
|
|
@@ -172,7 +219,9 @@ def _normalize_id_sequence(ids: int | Sequence[int] | object) -> list[int] | Non
|
|
|
172
219
|
else:
|
|
173
220
|
return None
|
|
174
221
|
|
|
175
|
-
valid_ids = [
|
|
222
|
+
valid_ids = [
|
|
223
|
+
id_val for id_val in id_list if isinstance(id_val, int) and id_val >= 1
|
|
224
|
+
]
|
|
176
225
|
if not valid_ids:
|
|
177
226
|
return None
|
|
178
227
|
|
lexicon/resources/base.py
CHANGED
|
@@ -27,7 +27,9 @@ class Resource:
|
|
|
27
27
|
json: Optional[dict[str, Any]] = None,
|
|
28
28
|
timeout: Optional[int] = None,
|
|
29
29
|
) -> Optional[dict[str, Any] | list[Any]]:
|
|
30
|
-
return self._client.request(
|
|
30
|
+
return self._client.request(
|
|
31
|
+
method, path, params=params, json=json, timeout=timeout
|
|
32
|
+
)
|
|
31
33
|
|
|
32
34
|
def _get(
|
|
33
35
|
self,
|
|
@@ -10,7 +10,6 @@ from .playlists_types import PlaylistResponse
|
|
|
10
10
|
from .tracks import Tracks
|
|
11
11
|
from .tracks_types import TrackResponse
|
|
12
12
|
from ._common_types import ValidationMode, _normalize_id_sequence
|
|
13
|
-
from ..utils import unique_in_order
|
|
14
13
|
|
|
15
14
|
|
|
16
15
|
class PlaylistTracks(Resource):
|
|
@@ -32,7 +31,6 @@ class PlaylistTracks(Resource):
|
|
|
32
31
|
self._tracks = tracks
|
|
33
32
|
self._playlists = playlists
|
|
34
33
|
|
|
35
|
-
|
|
36
34
|
def get(
|
|
37
35
|
self,
|
|
38
36
|
playlist_id: int,
|
|
@@ -63,14 +61,13 @@ class PlaylistTracks(Resource):
|
|
|
63
61
|
if validation == "warn":
|
|
64
62
|
self._logger.warning("Invalid playlist_id for remove: %s", playlist_id)
|
|
65
63
|
return None
|
|
66
|
-
|
|
64
|
+
|
|
67
65
|
track_ids = self.list(playlist_id, validation=validation, timeout=timeout)
|
|
68
66
|
if track_ids is None:
|
|
69
67
|
return None
|
|
70
68
|
if not track_ids:
|
|
71
69
|
return []
|
|
72
70
|
return self._tracks.get_many(track_ids, validation="off", timeout=timeout)
|
|
73
|
-
|
|
74
71
|
|
|
75
72
|
def list(
|
|
76
73
|
self,
|
|
@@ -112,7 +109,6 @@ class PlaylistTracks(Resource):
|
|
|
112
109
|
return [track_id for track_id in track_ids if isinstance(track_id, int)]
|
|
113
110
|
self._logger.warning("Playlist %s missing expected trackIds list", playlist_id)
|
|
114
111
|
return None
|
|
115
|
-
|
|
116
112
|
|
|
117
113
|
def add(
|
|
118
114
|
self,
|
|
@@ -150,7 +146,7 @@ class PlaylistTracks(Resource):
|
|
|
150
146
|
if validation == "warn":
|
|
151
147
|
self._logger.warning("Invalid playlist_id for add: %s", playlist_id)
|
|
152
148
|
return False
|
|
153
|
-
|
|
149
|
+
|
|
154
150
|
# When validation is off, pass input directly without transformation
|
|
155
151
|
if validation == "off":
|
|
156
152
|
normalized_ids = [track_ids] if isinstance(track_ids, int) else track_ids
|
|
@@ -163,7 +159,7 @@ class PlaylistTracks(Resource):
|
|
|
163
159
|
if validation == "warn": # pragma: no branch - strict raises above
|
|
164
160
|
self._logger.warning("Invalid track_ids for add: %s", track_ids)
|
|
165
161
|
return False
|
|
166
|
-
|
|
162
|
+
|
|
167
163
|
if index is not None and (not isinstance(index, int) or index < 0):
|
|
168
164
|
if validation == "strict":
|
|
169
165
|
raise ValueError(f"Invalid index: {index}")
|
|
@@ -211,7 +207,7 @@ class PlaylistTracks(Resource):
|
|
|
211
207
|
if validation == "warn":
|
|
212
208
|
self._logger.warning("Invalid playlist_id for remove: %s", playlist_id)
|
|
213
209
|
return False
|
|
214
|
-
|
|
210
|
+
|
|
215
211
|
# When validation is off, pass input directly without transformation
|
|
216
212
|
if validation == "off":
|
|
217
213
|
normalized_ids = [track_ids] if isinstance(track_ids, int) else track_ids
|
|
@@ -224,7 +220,7 @@ class PlaylistTracks(Resource):
|
|
|
224
220
|
if validation == "warn": # pragma: no branch - strict raises above
|
|
225
221
|
self._logger.warning("Invalid track_ids for remove: %s", track_ids)
|
|
226
222
|
return False
|
|
227
|
-
|
|
223
|
+
|
|
228
224
|
payload = {"id": playlist_id, "trackIds": normalized_ids}
|
|
229
225
|
response = self._delete("/playlist-tracks", json=payload, timeout=timeout)
|
|
230
226
|
return response is not None
|
|
@@ -270,14 +266,20 @@ class PlaylistTracks(Resource):
|
|
|
270
266
|
playlist_type = cast(PlaylistResponse, playlist).get("type")
|
|
271
267
|
if playlist_type is not None and str(playlist_type) != "2":
|
|
272
268
|
if validation == "strict":
|
|
273
|
-
raise ValueError(
|
|
269
|
+
raise ValueError(
|
|
270
|
+
f"Playlist {playlist_id} is not a normal playlist (type=2)"
|
|
271
|
+
)
|
|
274
272
|
if validation == "warn":
|
|
275
|
-
self._logger.warning(
|
|
273
|
+
self._logger.warning(
|
|
274
|
+
"Playlist %s is not a normal playlist (type=2)", playlist_id
|
|
275
|
+
)
|
|
276
276
|
return False
|
|
277
277
|
|
|
278
278
|
existing_ids = cast(PlaylistResponse, playlist).get("trackIds")
|
|
279
279
|
if isinstance(existing_ids, list) and existing_ids:
|
|
280
|
-
if not self.remove(
|
|
280
|
+
if not self.remove(
|
|
281
|
+
playlist_id, existing_ids, validation="off", timeout=timeout
|
|
282
|
+
):
|
|
281
283
|
return False
|
|
282
284
|
|
|
283
285
|
# When validation is off, pass input directly without transformation
|
|
@@ -295,4 +297,6 @@ class PlaylistTracks(Resource):
|
|
|
295
297
|
if not normalized_ids:
|
|
296
298
|
return True
|
|
297
299
|
|
|
298
|
-
return self.add(
|
|
300
|
+
return self.add(
|
|
301
|
+
playlist_id, normalized_ids, index=0, validation="off", timeout=timeout
|
|
302
|
+
)
|